chore: add docs and remove sub_agent
This commit is contained in:
parent
12c7a4bdd9
commit
5f6622a212
942
api_doc/napcat_api_report.md
Normal file
942
api_doc/napcat_api_report.md
Normal file
@ -0,0 +1,942 @@
|
||||
# NapCat API 端口使用报告
|
||||
|
||||
> 调研时间:2026-03-15
|
||||
> 版本:基于 NapCat v4.x 版本
|
||||
> 官方文档:https://napneko.github.io/
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [概述](#1-概述)
|
||||
2. [WebUI 管理界面](#2-webui-管理界面)
|
||||
3. [OneBot11 HTTP API](#3-onebot11-http-api)
|
||||
4. [WebSocket API](#4-websocket-api)
|
||||
5. [常用 API 接口](#5-常用-api-接口)
|
||||
6. [配置文件说明](#6-配置文件说明)
|
||||
7. [curl 命令示例](#7-curl-命令示例)
|
||||
8. [参考资源](#8-参考资源)
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
NapCat 是一个基于 NTQQ 的现代化 QQ Bot 框架,实现了 OneBot v11 协议标准。它支持多种通信方式:
|
||||
|
||||
- **HTTP API**:RESTful API 调用
|
||||
- **WebSocket Server**:正向 WebSocket(服务器模式)
|
||||
- **WebSocket Client**:反向 WebSocket(客户端模式)
|
||||
|
||||
### 通信方式对比
|
||||
|
||||
| 方式 | 方向 | 端口/地址 | 适用场景 |
|
||||
|------|------|-----------|----------|
|
||||
| HTTP Server | 服务端 | 自定义(默认3000) | 主动调用API |
|
||||
| HTTP Client | 客户端 | 上报到指定URL | 被动接收消息 |
|
||||
| WebSocket Server | 服务端 | 自定义(默认3001) | 双向通信 |
|
||||
| WebSocket Client | 客户端 | 连接到指定WS地址 | 连接框架 |
|
||||
|
||||
---
|
||||
|
||||
## 2. WebUI 管理界面
|
||||
|
||||
WebUI 是 NapCat 的可视化管理界面,用于配置网络和查看日志。
|
||||
|
||||
### 2.1 访问信息
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 监听地址 | `0.0.0.0` | 监听所有网络接口 |
|
||||
| 端口 | `6099` | WebUI 访问端口 |
|
||||
| Token | 随机生成 | 登录验证密钥 |
|
||||
|
||||
### 2.2 访问 URL
|
||||
|
||||
```
|
||||
http://IP地址:6099/webui
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 首次启动时,Token 会在启动日志中显示
|
||||
- 格式:`[WebUi] WebUi Local Panel Url: http://127.0.0.1:6099/webui?token=xxxx`
|
||||
- 若端口被占用,会自动尝试 `port+1`,最多尝试100次
|
||||
|
||||
### 2.3 WebUI 配置文件
|
||||
|
||||
配置文件路径:`webui.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "0.0.0.0",
|
||||
"port": 6099,
|
||||
"token": "your-token-here",
|
||||
"loginRate": 3
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 WebUI 功能
|
||||
|
||||
- QQ 登录(二维码登录)
|
||||
- 网络配置管理(HTTP/WS 服务端和客户端)
|
||||
- 日志查看
|
||||
- 系统配置
|
||||
|
||||
---
|
||||
|
||||
## 3. OneBot11 HTTP API
|
||||
|
||||
### 3.1 HTTP Server 配置
|
||||
|
||||
在 WebUI 或配置文件中添加 HTTP 服务器:
|
||||
|
||||
```json
|
||||
{
|
||||
"network": {
|
||||
"httpServers": [
|
||||
{
|
||||
"name": "HttpServer",
|
||||
"enable": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 3000,
|
||||
"token": "your-access-token",
|
||||
"messagePostFormat": "array"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 HTTP 配置参数说明
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `name` | string | 必填 | 配置名称,唯一标识 |
|
||||
| `enable` | boolean | false | 是否启用 |
|
||||
| `host` | string | 0.0.0.0 | 监听主机地址 |
|
||||
| `port` | number | 3000 | 监听端口 |
|
||||
| `token` | string | 空 | 访问令牌,用于鉴权 |
|
||||
| `messagePostFormat` | string | array | 消息上报格式:string/array |
|
||||
|
||||
### 3.3 HTTP API 调用方式
|
||||
|
||||
#### 请求格式
|
||||
|
||||
```
|
||||
POST http://host:port/{action}
|
||||
```
|
||||
|
||||
#### 认证方式
|
||||
|
||||
**方式一:Query 参数**
|
||||
```
|
||||
POST http://localhost:3000/send_private_msg?access_token=your-token
|
||||
```
|
||||
|
||||
**方式二:请求头**
|
||||
```http
|
||||
Authorization: Bearer your-token
|
||||
```
|
||||
|
||||
**方式三:自定义 Header**
|
||||
```http
|
||||
Authorization: your-token
|
||||
```
|
||||
|
||||
### 3.4 HTTP Client 配置(反向 HTTP)
|
||||
|
||||
用于将消息上报到指定的 HTTP 地址:
|
||||
|
||||
```json
|
||||
{
|
||||
"network": {
|
||||
"httpClients": [
|
||||
{
|
||||
"name": "httpClient",
|
||||
"enable": true,
|
||||
"url": "http://localhost:8080",
|
||||
"messagePostFormat": "array",
|
||||
"reportSelfMessage": false,
|
||||
"token": "",
|
||||
"debug": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. WebSocket API
|
||||
|
||||
### 4.1 WebSocket Server(正向 WS)
|
||||
|
||||
NapCat 作为 WebSocket 服务器,等待客户端连接。
|
||||
|
||||
#### 配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"network": {
|
||||
"websocketServers": [
|
||||
{
|
||||
"name": "WsServer",
|
||||
"enable": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 3001,
|
||||
"token": "your-token",
|
||||
"messagePostFormat": "array",
|
||||
"reportSelfMessage": false,
|
||||
"heartInterval": 30000,
|
||||
"enableForcePushEvent": true,
|
||||
"debug": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 连接 URL
|
||||
|
||||
```
|
||||
ws://host:port/
|
||||
```
|
||||
|
||||
#### 消息格式
|
||||
|
||||
**发送消息(客户端 → 服务器):**
|
||||
```json
|
||||
{
|
||||
"action": "send_private_msg",
|
||||
"params": {
|
||||
"user_id": 123456789,
|
||||
"message": "Hello"
|
||||
},
|
||||
"echo": "unique-id"
|
||||
}
|
||||
```
|
||||
|
||||
**接收消息(服务器 → 客户端):**
|
||||
```json
|
||||
{
|
||||
"post_type": "message",
|
||||
"message_type": "private",
|
||||
"user_id": 123456789,
|
||||
"message": "Hello"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 WebSocket Client(反向 WS)
|
||||
|
||||
NapCat 作为 WebSocket 客户端,主动连接到服务器。
|
||||
|
||||
#### 配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"network": {
|
||||
"websocketClients": [
|
||||
{
|
||||
"name": "WsClient",
|
||||
"enable": true,
|
||||
"url": "ws://127.0.0.1:8080/onebot/v11/ws",
|
||||
"token": "your-token",
|
||||
"messagePostFormat": "array",
|
||||
"reportSelfMessage": false,
|
||||
"heartInterval": 30000,
|
||||
"reconnectInterval": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 常见框架对接地址
|
||||
|
||||
| 框架 | WebSocket URL |
|
||||
|------|---------------|
|
||||
| NoneBot2 | `ws://127.0.0.1:8080/onebot/v11/ws` |
|
||||
| Koishi | `ws://127.0.0.1:5140/onebot` |
|
||||
| AstrBot | `ws://127.0.0.1:6199/ws` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 常用 API 接口
|
||||
|
||||
### 5.1 消息相关接口
|
||||
|
||||
#### 发送私聊消息
|
||||
|
||||
**接口:** `send_private_msg`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `user_id` | int64 | 是 | 对方 QQ 号 |
|
||||
| `message` | string/array | 是 | 消息内容 |
|
||||
| `auto_escape` | boolean | 否 | 是否纯文本发送 |
|
||||
|
||||
**请求示例:**
|
||||
```json
|
||||
{
|
||||
"user_id": 123456789,
|
||||
"message": "Hello World"
|
||||
}
|
||||
```
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": {
|
||||
"message_id": 12345
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 发送群消息
|
||||
|
||||
**接口:** `send_group_msg`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `message` | string/array | 是 | 消息内容 |
|
||||
| `auto_escape` | boolean | 否 | 是否纯文本发送 |
|
||||
|
||||
**请求示例:**
|
||||
```json
|
||||
{
|
||||
"group_id": 987654321,
|
||||
"message": "Hello Group"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 发送消息(通用)
|
||||
|
||||
**接口:** `send_msg`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `message_type` | string | 否 | 消息类型:private/group |
|
||||
| `user_id` | int64 | 条件 | 私聊时的 QQ 号 |
|
||||
| `group_id` | int64 | 条件 | 群聊时的群号 |
|
||||
| `message` | string/array | 是 | 消息内容 |
|
||||
|
||||
---
|
||||
|
||||
#### 撤回消息
|
||||
|
||||
**接口:** `delete_msg`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `message_id` | int32 | 是 | 消息 ID |
|
||||
|
||||
---
|
||||
|
||||
### 5.2 群管理接口
|
||||
|
||||
#### 获取群列表
|
||||
|
||||
**接口:** `get_group_list`
|
||||
|
||||
**请求参数:** 无
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": [
|
||||
{
|
||||
"group_id": 123456789,
|
||||
"group_name": "群名称",
|
||||
"member_count": 100,
|
||||
"max_member_count": 500
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 获取群信息
|
||||
|
||||
**接口:** `get_group_info`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `no_cache` | boolean | 否 | 是否不使用缓存 |
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": {
|
||||
"group_id": 123456789,
|
||||
"group_name": "群名称",
|
||||
"member_count": 100,
|
||||
"max_member_count": 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 获取群成员列表
|
||||
|
||||
**接口:** `get_group_member_list`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": [
|
||||
{
|
||||
"user_id": 123456789,
|
||||
"nickname": "昵称",
|
||||
"card": "群名片",
|
||||
"role": "member",
|
||||
"join_time": 1234567890
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 获取群成员信息
|
||||
|
||||
**接口:** `get_group_member_info`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `user_id` | int64 | 是 | QQ 号 |
|
||||
| `no_cache` | boolean | 否 | 是否不使用缓存 |
|
||||
|
||||
---
|
||||
|
||||
#### 群组踢人
|
||||
|
||||
**接口:** `set_group_kick`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `user_id` | int64 | 是 | 要踢的 QQ 号 |
|
||||
| `reject_add_request` | boolean | 否 | 是否拒绝加群申请 |
|
||||
|
||||
---
|
||||
|
||||
#### 群组禁言
|
||||
|
||||
**接口:** `set_group_ban`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `user_id` | int64 | 是 | 要禁言的 QQ 号 |
|
||||
| `duration` | int32 | 否 | 禁言时长(秒),默认1800 |
|
||||
|
||||
---
|
||||
|
||||
#### 设置群管理员
|
||||
|
||||
**接口:** `set_group_admin`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `user_id` | int64 | 是 | 要设置的 QQ 号 |
|
||||
| `enable` | boolean | 否 | true为设置,false为取消 |
|
||||
|
||||
---
|
||||
|
||||
#### 设置群名片
|
||||
|
||||
**接口:** `set_group_card`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `user_id` | int64 | 是 | QQ 号 |
|
||||
| `card` | string | 否 | 群名片内容 |
|
||||
|
||||
---
|
||||
|
||||
#### 设置群名
|
||||
|
||||
**接口:** `set_group_name`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `group_name` | string | 是 | 新群名 |
|
||||
|
||||
---
|
||||
|
||||
#### 退出群组
|
||||
|
||||
**接口:** `set_group_leave`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `is_dismiss` | boolean | 否 | 是否解散(群主专用) |
|
||||
|
||||
---
|
||||
|
||||
#### 设置群专属头衔
|
||||
|
||||
**接口:** `set_group_special_title`
|
||||
|
||||
**请求参数:**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `group_id` | int64 | 是 | 群号 |
|
||||
| `user_id` | int64 | 是 | QQ 号 |
|
||||
| `special_title` | string | 否 | 专属头衔 |
|
||||
| `duration` | int32 | 否 | 有效期(秒),-1为永久 |
|
||||
|
||||
---
|
||||
|
||||
### 5.3 账号相关接口
|
||||
|
||||
#### 获取登录号信息
|
||||
|
||||
**接口:** `get_login_info`
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": {
|
||||
"user_id": 123456789,
|
||||
"nickname": "QQ昵称"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 获取好友列表
|
||||
|
||||
**接口:** `get_friend_list`
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": [
|
||||
{
|
||||
"user_id": 123456789,
|
||||
"nickname": "好友昵称"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 获取版本信息
|
||||
|
||||
**接口:** `get_version_info`
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": {
|
||||
"app_name": "NapCat",
|
||||
"app_version": "4.17.20",
|
||||
"protocol_version": "v11"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 获取状态
|
||||
|
||||
**接口:** `get_status`
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"retcode": 0,
|
||||
"data": {
|
||||
"online": true,
|
||||
"good": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 消息类型格式
|
||||
|
||||
#### 文本消息
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"data": {
|
||||
"text": "消息内容"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 图片消息
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "image",
|
||||
"data": {
|
||||
"file": "图片路径或URL"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### At 消息
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "at",
|
||||
"data": {
|
||||
"qq": "123456789"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 合并消息段
|
||||
|
||||
```json
|
||||
[
|
||||
{"type": "text", "data": {"text": "你好"}},
|
||||
{"type": "at", "data": {"qq": "123456789"}},
|
||||
{"type": "text", "data": {"text": "欢迎!"}}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 配置文件说明
|
||||
|
||||
### 6.1 配置文件位置
|
||||
|
||||
- **Windows**: `NapCat 安装目录/config/`
|
||||
- **Linux**: `/opt/QQ/resources/app/app_launcher/napcat/config/`
|
||||
- **Docker**: `/app/napcat/config/`
|
||||
|
||||
### 6.2 主配置文件结构
|
||||
|
||||
```json
|
||||
{
|
||||
"network": {
|
||||
"httpServers": [],
|
||||
"httpClients": [],
|
||||
"websocketServers": [],
|
||||
"websocketClients": []
|
||||
},
|
||||
"musicSignUrl": "",
|
||||
"enableLocalFile2Url": false,
|
||||
"parseMultMsg": false
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 完整配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"network": {
|
||||
"httpServers": [
|
||||
{
|
||||
"name": "HttpServer",
|
||||
"enable": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 3000,
|
||||
"token": "your-http-token",
|
||||
"messagePostFormat": "array"
|
||||
}
|
||||
],
|
||||
"httpClients": [
|
||||
{
|
||||
"name": "httpClient",
|
||||
"enable": false,
|
||||
"url": "http://localhost:8080",
|
||||
"messagePostFormat": "array",
|
||||
"reportSelfMessage": false,
|
||||
"token": "",
|
||||
"debug": false
|
||||
}
|
||||
],
|
||||
"websocketServers": [
|
||||
{
|
||||
"name": "WsServer",
|
||||
"enable": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 3001,
|
||||
"token": "your-ws-token",
|
||||
"messagePostFormat": "array",
|
||||
"reportSelfMessage": false,
|
||||
"heartInterval": 30000,
|
||||
"enableForcePushEvent": true,
|
||||
"debug": false
|
||||
}
|
||||
],
|
||||
"websocketClients": [
|
||||
{
|
||||
"name": "WsClient",
|
||||
"enable": false,
|
||||
"url": "ws://127.0.0.1:8080/onebot/v11/ws",
|
||||
"token": "",
|
||||
"messagePostFormat": "array",
|
||||
"reportSelfMessage": false,
|
||||
"heartInterval": 30000,
|
||||
"reconnectInterval": 3000
|
||||
}
|
||||
]
|
||||
},
|
||||
"musicSignUrl": "",
|
||||
"enableLocalFile2Url": false,
|
||||
"parseMultMsg": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. curl 命令示例
|
||||
|
||||
### 7.1 基础调用示例
|
||||
|
||||
#### 获取登录号信息
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/get_login_info" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token"
|
||||
```
|
||||
|
||||
#### 发送私聊消息
|
||||
|
||||
```bash
|
||||
# 使用 Query 参数认证
|
||||
curl -X POST "http://localhost:3000/send_private_msg?access_token=your-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"user_id": 123456789,
|
||||
"message": "Hello from NapCat!"
|
||||
}'
|
||||
|
||||
# 使用 Header 认证
|
||||
curl -X POST "http://localhost:3000/send_private_msg" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{
|
||||
"user_id": 123456789,
|
||||
"message": "Hello from NapCat!"
|
||||
}'
|
||||
```
|
||||
|
||||
#### 发送群消息
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/send_group_msg" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{
|
||||
"group_id": 987654321,
|
||||
"message": "Hello Group!"
|
||||
}'
|
||||
```
|
||||
|
||||
#### 发送图片消息
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/send_group_msg" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{
|
||||
"group_id": 987654321,
|
||||
"message": [
|
||||
{"type": "text", "data": {"text": "看这张图:"}},
|
||||
{"type": "image", "data": {"file": "https://example.com/image.jpg"}}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 7.2 群管理示例
|
||||
|
||||
#### 获取群列表
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/get_group_list" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token"
|
||||
```
|
||||
|
||||
#### 获取群信息
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/get_group_info" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{
|
||||
"group_id": 987654321,
|
||||
"no_cache": false
|
||||
}'
|
||||
```
|
||||
|
||||
#### 获取群成员列表
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/get_group_member_list" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{
|
||||
"group_id": 987654321
|
||||
}'
|
||||
```
|
||||
|
||||
#### 群成员禁言
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/set_group_ban" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{
|
||||
"group_id": 987654321,
|
||||
"user_id": 123456789,
|
||||
"duration": 3600
|
||||
}'
|
||||
```
|
||||
|
||||
#### 设置群管理员
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/set_group_admin" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{
|
||||
"group_id": 987654321,
|
||||
"user_id": 123456789,
|
||||
"enable": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 7.3 其他常用接口
|
||||
|
||||
#### 撤回消息
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/delete_msg" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{
|
||||
"message_id": 12345
|
||||
}'
|
||||
```
|
||||
|
||||
#### 获取版本信息
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/get_version_info" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token"
|
||||
```
|
||||
|
||||
#### 获取好友列表
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/get_friend_list" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 参考资源
|
||||
|
||||
### 8.1 官方资源
|
||||
|
||||
- **GitHub 仓库**: https://github.com/NapNeko/NapCatQQ
|
||||
- **官方文档**: https://napneko.github.io/
|
||||
- **API 文档**: https://napneko.github.io/zh-CN/develop/api
|
||||
- **安装指南**: https://napneko.github.io/guide/install
|
||||
- **配置指南**: https://napneko.github.io/config/basic
|
||||
|
||||
### 8.2 OneBot 协议
|
||||
|
||||
- **OneBot 协议文档**: https://onebot.dev/
|
||||
- **OneBot v11 协议**: https://onebots.pages.dev/protocol/onebot-v11/
|
||||
|
||||
### 8.3 相关项目
|
||||
|
||||
- **go-cqhttp 文档**: https://docs.go-cqhttp.org/api/
|
||||
- **NoneBot2**: https://nonebot.dev/
|
||||
- **Koishi**: https://koishi.chat/
|
||||
|
||||
### 8.4 社区支持
|
||||
|
||||
- **Telegram 群组**: NapCatQQ
|
||||
- **QQ 交流群**: 搜索 NapCat 相关群组
|
||||
|
||||
---
|
||||
|
||||
## 附录:API 快速参考表
|
||||
|
||||
| API 名称 | 功能描述 | 权限要求 |
|
||||
|----------|----------|----------|
|
||||
| `send_private_msg` | 发送私聊消息 | 好友关系 |
|
||||
| `send_group_msg` | 发送群消息 | 群成员 |
|
||||
| `send_msg` | 通用发送消息 | - |
|
||||
| `delete_msg` | 撤回消息 | 消息发送者/管理员 |
|
||||
| `get_login_info` | 获取登录信息 | - |
|
||||
| `get_friend_list` | 获取好友列表 | - |
|
||||
| `get_group_list` | 获取群列表 | - |
|
||||
| `get_group_info` | 获取群信息 | 群成员 |
|
||||
| `get_group_member_list` | 获取群成员列表 | 群成员 |
|
||||
| `get_group_member_info` | 获取群成员信息 | 群成员 |
|
||||
| `set_group_kick` | 群组踢人 | 管理员/群主 |
|
||||
| `set_group_ban` | 群组禁言 | 管理员/群主 |
|
||||
| `set_group_admin` | 设置群管理员 | 群主 |
|
||||
| `set_group_card` | 设置群名片 | 管理员/群主 |
|
||||
| `set_group_name` | 设置群名 | 管理员/群主 |
|
||||
| `set_group_leave` | 退出群组 | 群成员 |
|
||||
| `set_group_special_title` | 设置群专属头衔 | 群主 |
|
||||
| `get_version_info` | 获取版本信息 | - |
|
||||
| `get_status` | 获取运行状态 | - |
|
||||
|
||||
---
|
||||
|
||||
*报告生成时间:2026-03-15*
|
||||
*报告版本:v1.0*
|
||||
314
api_doc/web_api.md
Normal file
314
api_doc/web_api.md
Normal file
@ -0,0 +1,314 @@
|
||||
# Web UI API 文档(非 `/api/v1`)
|
||||
|
||||
> 本文档描述**网页端使用的 Session/CSRF 接口**,不包含 `/api/v1`。
|
||||
> 适合在**不启动前端**的情况下,用脚本/服务调用同样的网页端 API。
|
||||
|
||||
---
|
||||
|
||||
## 1. 基本信息
|
||||
|
||||
- 默认地址:`http://localhost:8091`
|
||||
- 鉴权方式:**Session Cookie**
|
||||
- CSRF:除 GET/HEAD/OPTIONS/TRACE 外,`/api/*` 需要 **`X-CSRF-Token` + Cookie**
|
||||
|
||||
> 说明:CSRF 由 `attach_security_hooks` 统一拦截;`/api/csrf-token` 会写入 Session 并返回 token。
|
||||
|
||||
---
|
||||
|
||||
## 2. CSRF 获取
|
||||
|
||||
### `GET /api/csrf-token`
|
||||
|
||||
**用途**:获取 CSRF Token(同时写入 Session Cookie)。
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{ "success": true, "token": "xxx" }
|
||||
```
|
||||
|
||||
**后续请求需要**:
|
||||
```
|
||||
X-CSRF-Token: <token>
|
||||
Cookie: <session cookie>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 登录(Docker 模式)
|
||||
|
||||
### `POST /login`
|
||||
|
||||
**用途**:使用邮箱 + 密码登录(Docker 模式)。
|
||||
|
||||
**请求头**
|
||||
```
|
||||
Content-Type: application/json
|
||||
X-CSRF-Token: <token>
|
||||
```
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"email": "jojo@local",
|
||||
"password": "******"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
**失败响应(示例)**
|
||||
```json
|
||||
{ "success": false, "error": "账号或密码错误" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 宿主机模式登录
|
||||
|
||||
### `GET /api/host-mode-enabled`
|
||||
|
||||
**用途**:确认是否允许宿主机模式。
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{ "success": true, "enabled": true }
|
||||
```
|
||||
|
||||
### `POST /host-login`
|
||||
|
||||
**用途**:宿主机一键登录(仅当 `TERMINAL_SANDBOX_MODE=host` 时可用)。
|
||||
|
||||
**请求头**
|
||||
```
|
||||
X-CSRF-Token: <token>
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 对话管理
|
||||
|
||||
### 5.1 创建对话
|
||||
`POST /api/conversations`
|
||||
|
||||
**请求体(可选)**
|
||||
```json
|
||||
{
|
||||
"preserve_mode": false,
|
||||
"mode": "fast",
|
||||
"thinking_mode": false
|
||||
}
|
||||
```
|
||||
|
||||
- `preserve_mode=true` 时才会使用 `mode/thinking_mode`;否则回到默认配置。
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"conversation_id": "conv_20260315_162256_599",
|
||||
"message": "已创建新对话: conv_20260315_162256_599"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 列表查询
|
||||
`GET /api/conversations?limit=20&offset=0`
|
||||
|
||||
### 5.3 获取对话信息
|
||||
`GET /api/conversations/<conversation_id>`
|
||||
|
||||
### 5.4 加载/切换对话
|
||||
`PUT /api/conversations/<conversation_id>/load`
|
||||
|
||||
### 5.5 获取对话消息
|
||||
`GET /api/conversations/<conversation_id>/messages?limit=50`
|
||||
|
||||
### 5.6 删除对话
|
||||
`DELETE /api/conversations/<conversation_id>`
|
||||
|
||||
---
|
||||
|
||||
## 6. 模型与思考模式
|
||||
|
||||
### 6.1 切换模型
|
||||
`POST /api/model`
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{ "model_key": "qwen3-vl-plus" }
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"model_key": "qwen3-vl-plus",
|
||||
"run_mode": "fast",
|
||||
"thinking_mode": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 切换思考模式
|
||||
`POST /api/thinking-mode`
|
||||
|
||||
**请求体(两种写法)**
|
||||
```json
|
||||
{ "mode": "thinking" }
|
||||
```
|
||||
或
|
||||
```json
|
||||
{ "thinking_mode": true }
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "thinking_mode": true, "mode": "thinking" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 图片/视频上传(用于多模态消息)
|
||||
|
||||
### 7.1 上传文件
|
||||
`POST /api/upload`
|
||||
|
||||
**用途**:上传图片/视频,返回相对路径(通常为 `user_upload/<filename>`),之后将该路径放到 `POST /api/tasks` 的 `images` 或 `videos` 里。
|
||||
|
||||
**请求类型**
|
||||
```
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**表单字段**
|
||||
- `file`: 文件本体
|
||||
- `filename`: 原始文件名(可选)
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"path": "user_upload/1.jpg",
|
||||
"filename": "1.jpg",
|
||||
"folder": "user_upload",
|
||||
"scan": { "status": "skipped" },
|
||||
"sha256": "....",
|
||||
"size": 118897
|
||||
}
|
||||
```
|
||||
|
||||
**注意事项**
|
||||
- 上传功能可能被管理员策略禁用(返回 403)。
|
||||
- 文件过大时返回 413(单文件大小受 `MAX_UPLOAD_SIZE` 限制)。
|
||||
|
||||
### 7.2 多模态模型要求
|
||||
当前网页端逻辑限制:
|
||||
**仅以下模型支持图片/视频**
|
||||
- `qwen3-vl-plus`
|
||||
- `kimi-k2.5`
|
||||
|
||||
在发送图片/视频前,请先 `POST /api/model` 切换到上述模型,否则会报错。
|
||||
|
||||
---
|
||||
|
||||
## 8. 发送消息(任务式 / 轮询)
|
||||
|
||||
### 8.1 创建任务(发送消息)
|
||||
`POST /api/tasks`
|
||||
|
||||
**请求体**
|
||||
```json
|
||||
{
|
||||
"message": "今天英伟达的股价是多少?",
|
||||
"conversation_id": "conv_20260315_162256_599",
|
||||
"images": ["user_upload/1.jpg"],
|
||||
"videos": [],
|
||||
"model_key": null,
|
||||
"thinking_mode": null,
|
||||
"run_mode": null,
|
||||
"max_iterations": 100
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(202)**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"task_id": "5aae14a2-cd12-4644-b5eb-c394b83f4045",
|
||||
"status": "running",
|
||||
"created_at": 1773562976.607287,
|
||||
"conversation_id": "conv_20260315_162256_599"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 轮询任务事件
|
||||
`GET /api/tasks/<task_id>?from=0`
|
||||
|
||||
**响应要点**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "succeeded",
|
||||
"events": [
|
||||
{ "type": "text_chunk", "data": { "content": "..." } },
|
||||
{ "type": "text_end", "data": { "full_content": "..." } }
|
||||
],
|
||||
"next_offset": 437
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `text_end.data.full_content` 是最终整段回复;`next_offset` 用于增量轮询。
|
||||
|
||||
### 8.3 查询任务列表
|
||||
`GET /api/tasks`
|
||||
|
||||
### 8.4 取消任务
|
||||
`POST /api/tasks/<task_id>/cancel`
|
||||
|
||||
---
|
||||
|
||||
## 9. 可选:WebSocket Token(如果需要实时推送)
|
||||
|
||||
### `GET /api/socket-token`
|
||||
|
||||
返回 `{ success: true, token: "...", expires_at: ... }`
|
||||
Socket.IO 连接时将 token 放到 `auth` 字段中:
|
||||
|
||||
```js
|
||||
io('/', { auth: { socket_token: '<token>' } })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 最小调用流程(示例)
|
||||
|
||||
1) `GET /api/csrf-token`
|
||||
2) `POST /login`(或 `POST /host-login`)
|
||||
3) `GET /api/csrf-token`(刷新 token)
|
||||
4) `POST /api/conversations`
|
||||
5) `POST /api/tasks`
|
||||
6) `GET /api/tasks/<task_id>?from=0` 轮询直到 `status=succeeded`
|
||||
|
||||
---
|
||||
|
||||
## 11. 常见错误码
|
||||
|
||||
- `401 Unauthorized`:未登录或 Session 失效
|
||||
- `403 CSRF validation failed`:缺少/错误的 `X-CSRF-Token`
|
||||
- `409`:同一用户同一工作区已有运行中任务(任务互斥)
|
||||
- `503`:资源繁忙 / 宿主机模式不可用
|
||||
49
config/skill_hints.json
Normal file
49
config/skill_hints.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"terminal-guide": {
|
||||
"keywords": [
|
||||
"终端", "terminal", "会话", "session", "长时间", "后台", "持久",
|
||||
"git clone", "下载", "安装", "pip install", "npm install",
|
||||
"开发服务器", "npm run dev", "output_wait", "超时",
|
||||
"background", "long-running", "download", "install", "dev server"
|
||||
],
|
||||
"hint": "检测到用户可能需要下载,克隆仓库或执行长时间运行的任务或使用持久化终端。如果情况符合,必须先阅读 terminal-guide skill。"
|
||||
},
|
||||
"sub-agent-guide": {
|
||||
"keywords": [
|
||||
"并行", "同时", "一起", "分别", "多个任务", "子智能体", "sub agent",
|
||||
"调研", "搜集", "收集信息", "分析多个", "批量","调查","逐一",
|
||||
"parallel", "simultaneously", "multiple tasks", "sub-agent",
|
||||
"research", "gather", "collect", "analyze multiple"
|
||||
],
|
||||
"hint": "检测到用户可能需要并行处理多个独立任务,可能创建子智能体为最优解。如果情况符合,必须先阅读 sub-agent-guide skill。"
|
||||
},
|
||||
"docx": {
|
||||
"keywords": [
|
||||
"word", "docx", "文档", "报告", "合同", "简历", "信函", "备忘录",
|
||||
"模板", "格式化", "表格", "目录", "页眉", "页脚", "修订", "批注",
|
||||
"document", "report", "contract", "resume", "letter", "memo",
|
||||
"template", "formatting", "table", "toc", "header", "footer",
|
||||
"tracked changes", "comments"
|
||||
],
|
||||
"hint": "检测到用户可能需要处理 Word 文档。如果情况符合,必须先阅读 docx skill。"
|
||||
},
|
||||
"frontend-design": {
|
||||
"keywords": [
|
||||
"网页", "前端", "界面", "UI", "页面", "组件", "网站", "落地页",
|
||||
"仪表板", "dashboard", "react", "vue", "html", "css", "样式",
|
||||
"美化", "设计",
|
||||
"webpage", "frontend", "interface", "page", "component", "website",
|
||||
"landing page", "styling", "beautify", "design"
|
||||
],
|
||||
"hint": "检测到用户需要构建web前端界面。如果情况符合,必须先阅读 frontend-design skill。"
|
||||
},
|
||||
"skill-creator": {
|
||||
"keywords": [
|
||||
"创建技能", "新技能", "agent skill", "扩展能力", "自定义功能", "打包",
|
||||
"技能开发",
|
||||
"create skill", "new skill", "extend capability",
|
||||
"custom functionality", "package", "skill development"
|
||||
],
|
||||
"hint": "检测到用户想要创建新的 skill。建议先阅读 skill-creator skill。"
|
||||
}
|
||||
}
|
||||
170
modules/skill_hint_manager.py
Normal file
170
modules/skill_hint_manager.py
Normal file
@ -0,0 +1,170 @@
|
||||
# modules/skill_hint_manager.py - Skill 提示系统管理器
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
class SkillHintManager:
|
||||
"""Skill 提示系统管理器
|
||||
|
||||
检测用户输入中的关键词,并在需要时插入 system 消息提示阅读相关 skill。
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
"""初始化提示系统管理器
|
||||
|
||||
Args:
|
||||
config_path: 配置文件路径,默认为 config/skill_hints.json
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = Path(__file__).parent.parent / "config" / "skill_hints.json"
|
||||
else:
|
||||
config_path = Path(config_path)
|
||||
|
||||
self.config_path = config_path
|
||||
self.hints_config: Dict[str, Dict] = {}
|
||||
self.enabled = False # 默认关闭
|
||||
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
"""加载配置文件"""
|
||||
try:
|
||||
if self.config_path.exists():
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
self.hints_config = json.load(f)
|
||||
logger.info(f"已加载 skill hints 配置: {len(self.hints_config)} 个 skills")
|
||||
else:
|
||||
logger.warning(f"Skill hints 配置文件不存在: {self.config_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"加载 skill hints 配置失败: {e}")
|
||||
self.hints_config = {}
|
||||
|
||||
def reload_config(self):
|
||||
"""重新加载配置文件"""
|
||||
self._load_config()
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
"""设置是否启用提示系统
|
||||
|
||||
Args:
|
||||
enabled: True 启用,False 禁用
|
||||
"""
|
||||
self.enabled = enabled
|
||||
logger.info(f"Skill hints 系统已{'启用' if enabled else '禁用'}")
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""检查提示系统是否启用"""
|
||||
return self.enabled
|
||||
|
||||
def detect_skills(self, user_input: str) -> List[str]:
|
||||
"""检测用户输入中匹配的 skills
|
||||
|
||||
Args:
|
||||
user_input: 用户输入文本
|
||||
|
||||
Returns:
|
||||
匹配的 skill 名称列表
|
||||
"""
|
||||
if not self.enabled or not user_input:
|
||||
return []
|
||||
|
||||
user_input_lower = user_input.lower()
|
||||
matched_skills = []
|
||||
|
||||
for skill_name, config in self.hints_config.items():
|
||||
keywords = config.get("keywords", [])
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in user_input_lower:
|
||||
matched_skills.append(skill_name)
|
||||
break # 一个 skill 只需匹配一次
|
||||
|
||||
return matched_skills
|
||||
|
||||
def get_hint_message(self, skill_name: str) -> Optional[str]:
|
||||
"""获取指定 skill 的提示消息
|
||||
|
||||
Args:
|
||||
skill_name: skill 名称
|
||||
|
||||
Returns:
|
||||
提示消息,如果 skill 不存在则返回 None
|
||||
"""
|
||||
config = self.hints_config.get(skill_name)
|
||||
if config:
|
||||
return config.get("hint")
|
||||
return None
|
||||
|
||||
def build_hint_messages(self, user_input: str) -> List[Dict]:
|
||||
"""根据用户输入构建提示消息列表
|
||||
|
||||
Args:
|
||||
user_input: 用户输入文本
|
||||
|
||||
Returns:
|
||||
system 消息列表(如果有多个匹配的 skill,会合并为一条消息)
|
||||
"""
|
||||
if not self.enabled:
|
||||
return []
|
||||
|
||||
matched_skills = self.detect_skills(user_input)
|
||||
if not matched_skills:
|
||||
return []
|
||||
|
||||
# 收集所有提示内容(去掉开头的标识)
|
||||
hint_contents = []
|
||||
for skill_name in matched_skills:
|
||||
hint = self.get_hint_message(skill_name)
|
||||
if hint:
|
||||
# 去掉 [系统自动插入的提示信息] 标识(如果存在)
|
||||
clean_hint = hint.replace("[系统自动插入的提示信息] ", "").strip()
|
||||
hint_contents.append(clean_hint)
|
||||
logger.info(f"为用户输入添加 skill hint: {skill_name}")
|
||||
|
||||
if not hint_contents:
|
||||
return []
|
||||
|
||||
# 合并所有提示为一条消息,开头加上统一的标识
|
||||
combined_hint = "[系统自动插入的提示信息] " + "\n".join(hint_contents)
|
||||
|
||||
return [{
|
||||
"role": "system",
|
||||
"content": combined_hint
|
||||
}]
|
||||
|
||||
def add_skill_hint(self, skill_name: str, keywords: List[str], hint: str):
|
||||
"""动态添加或更新 skill hint 配置
|
||||
|
||||
Args:
|
||||
skill_name: skill 名称
|
||||
keywords: 关键词列表
|
||||
hint: 提示消息
|
||||
"""
|
||||
self.hints_config[skill_name] = {
|
||||
"keywords": keywords,
|
||||
"hint": hint
|
||||
}
|
||||
logger.info(f"已添加/更新 skill hint: {skill_name}")
|
||||
|
||||
def remove_skill_hint(self, skill_name: str):
|
||||
"""移除指定的 skill hint 配置
|
||||
|
||||
Args:
|
||||
skill_name: skill 名称
|
||||
"""
|
||||
if skill_name in self.hints_config:
|
||||
del self.hints_config[skill_name]
|
||||
logger.info(f"已移除 skill hint: {skill_name}")
|
||||
|
||||
def save_config(self):
|
||||
"""保存配置到文件"""
|
||||
try:
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.hints_config, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"已保存 skill hints 配置到: {self.config_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"保存 skill hints 配置失败: {e}")
|
||||
185
scripts/qq_bot/DEPLOY.md
Normal file
185
scripts/qq_bot/DEPLOY.md
Normal file
@ -0,0 +1,185 @@
|
||||
# QQ Bot 部署指南
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd /Users/jojo/Desktop/agents/正在修复中/agents
|
||||
pip install aiohttp
|
||||
```
|
||||
|
||||
### 2. 测试连接
|
||||
|
||||
```bash
|
||||
python scripts/qq_bot/test_connection.py
|
||||
```
|
||||
|
||||
这会测试:
|
||||
- NapCat API 连接
|
||||
- Agents Web API 连接
|
||||
- 创建对话功能
|
||||
|
||||
### 3. 启动机器人
|
||||
|
||||
```bash
|
||||
python scripts/qq_bot/run.py
|
||||
```
|
||||
|
||||
或者:
|
||||
|
||||
```bash
|
||||
python -m scripts.qq_bot.main
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 在 QQ 群中使用
|
||||
|
||||
1. **创建新对话**
|
||||
```
|
||||
@机器人 /new
|
||||
```
|
||||
|
||||
2. **查看对话列表**
|
||||
```
|
||||
@机器人 /resume 10
|
||||
```
|
||||
|
||||
3. **切换对话**
|
||||
```
|
||||
@机器人 /switch conv_xxx
|
||||
```
|
||||
|
||||
4. **停止任务**
|
||||
```
|
||||
@机器人 /stop
|
||||
```
|
||||
|
||||
5. **发送消息**
|
||||
```
|
||||
@机器人 帮我查一下今天的天气
|
||||
```
|
||||
|
||||
### 发送文件
|
||||
|
||||
模型可以使用 `send_qq_file` 工具发送文件:
|
||||
|
||||
```
|
||||
@机器人 生成一个柱状图并发送给我
|
||||
```
|
||||
|
||||
模型会:
|
||||
1. 生成图表文件
|
||||
2. 调用 `send_qq_file` 工具
|
||||
3. 文件自动发送到 QQ 群
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
scripts/qq_bot/
|
||||
├── __init__.py # 包初始化
|
||||
├── main.py # 主程序
|
||||
├── run.py # 启动脚本
|
||||
├── test_connection.py # 连接测试
|
||||
├── config.py # 配置
|
||||
├── napcat_client.py # NapCat 客户端
|
||||
├── web_api_client.py # Web API 客户端
|
||||
├── message_handler.py # 消息处理
|
||||
├── state_manager.py # 状态管理
|
||||
├── poller.py # 消息轮询
|
||||
├── requirements.txt # 依赖
|
||||
└── README.md # 详细文档
|
||||
|
||||
data/custom_tools/send_qq_file/
|
||||
├── definition.json # 工具定义
|
||||
├── execution.py # 执行代码
|
||||
└── return.json # 返回配置
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
默认配置(可通过环境变量覆盖):
|
||||
|
||||
- `NAPCAT_HOST`: http://localhost:3000
|
||||
- `NAPCAT_TOKEN`: 283222464f13
|
||||
- `AGENTS_HOST`: http://localhost:8091
|
||||
- `AGENTS_EMAIL`: qqbot@qq.com
|
||||
- `AGENTS_PASSWORD`: 12345678
|
||||
- `TARGET_GROUP_ID`: 1092770403
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **确保 NapCat 正在运行**
|
||||
- HTTP Server 端口:3000
|
||||
- Token 已配置
|
||||
|
||||
2. **确保 Agents Web 服务器正在运行**
|
||||
```bash
|
||||
python web_server.py --port 8091
|
||||
```
|
||||
|
||||
3. **确保 qqbot 用户已创建**
|
||||
- 用户名:qqbot
|
||||
- 邮箱:qqbot@qq.com
|
||||
- 密码:12345678
|
||||
- 必须使用 Docker 模式
|
||||
|
||||
4. **自定义工具**
|
||||
- `send_qq_file` 工具已创建在 `data/custom_tools/send_qq_file/`
|
||||
- 重启 Agents 服务器后生效
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 测试失败
|
||||
|
||||
运行测试脚本查看详细错误:
|
||||
```bash
|
||||
python scripts/qq_bot/test_connection.py
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
tail -f qq_bot.log
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **NapCat 连接失败**
|
||||
- 检查 NapCat 是否运行
|
||||
- 检查端口和 token 是否正确
|
||||
|
||||
2. **Agents 登录失败**
|
||||
- 检查 Web 服务器是否运行
|
||||
- 检查用户名密码是否正确
|
||||
- 确认用户已创建
|
||||
|
||||
3. **机器人无响应**
|
||||
- 检查是否正确 @ 机器人
|
||||
- 查看日志了解详细错误
|
||||
|
||||
## 开发
|
||||
|
||||
### 添加新指令
|
||||
|
||||
编辑 `message_handler.py`:
|
||||
|
||||
```python
|
||||
async def _handle_command(self, command: str):
|
||||
# ...
|
||||
elif cmd == "/your_command":
|
||||
await self._cmd_your_command(args)
|
||||
```
|
||||
|
||||
### 修改轮询间隔
|
||||
|
||||
编辑 `config.py`:
|
||||
|
||||
```python
|
||||
POLL_INTERVAL = 2.0 # 秒
|
||||
```
|
||||
|
||||
### 添加新的自定义工具
|
||||
|
||||
在 `data/custom_tools/` 下创建新文件夹,参考 `send_qq_file` 的结构。
|
||||
249
scripts/qq_bot/README.md
Normal file
249
scripts/qq_bot/README.md
Normal file
@ -0,0 +1,249 @@
|
||||
# QQ Bot for Agents
|
||||
|
||||
通过 QQ 群调用 Agents 系统的桥接程序。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 支持通过 @ 机器人触发对话
|
||||
- ✅ 支持创建/切换/查看对话
|
||||
- ✅ 支持停止运行中的任务
|
||||
- ✅ 实时推送工具调用信息和模型输出
|
||||
- ✅ 支持发送工作区文件到 QQ 群(图片/普通文件)
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Python 3.9+
|
||||
- NapCat(已配置 HTTP Server)
|
||||
- Agents 系统(Web 模式运行)
|
||||
- aiohttp 库
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
pip install aiohttp
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
### 1. NapCat 配置
|
||||
|
||||
确保 NapCat 已启用 HTTP Server,配置文件示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"network": {
|
||||
"httpServers": [
|
||||
{
|
||||
"name": "HttpServer",
|
||||
"enable": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 3000,
|
||||
"token": "your-token-here",
|
||||
"messagePostFormat": "array"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Agents 系统配置
|
||||
|
||||
确保 Agents Web 服务器正在运行:
|
||||
|
||||
```bash
|
||||
cd /Users/jojo/Desktop/agents/正在修复中/agents
|
||||
python web_server.py --port 8091
|
||||
```
|
||||
|
||||
确保已创建 qqbot 用户:
|
||||
- 用户名: qqbot
|
||||
- 邮箱: qqbot@qq.com
|
||||
- 密码: 12345678
|
||||
|
||||
### 3. 环境变量(可选)
|
||||
|
||||
可以通过环境变量覆盖默认配置:
|
||||
|
||||
```bash
|
||||
export NAPCAT_HOST="http://localhost:3000"
|
||||
export NAPCAT_TOKEN="your-token"
|
||||
export AGENTS_HOST="http://localhost:8091"
|
||||
export AGENTS_EMAIL="qqbot@qq.com"
|
||||
export AGENTS_PASSWORD="12345678"
|
||||
export TARGET_GROUP_ID="1092770403"
|
||||
export LOG_LEVEL="INFO"
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cd /Users/jojo/Desktop/agents/正在修复中/agents
|
||||
python -m scripts.qq_bot.main
|
||||
```
|
||||
|
||||
或者:
|
||||
|
||||
```bash
|
||||
cd /Users/jojo/Desktop/agents/正在修复中/agents/scripts/qq_bot
|
||||
python main.py
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基本指令
|
||||
|
||||
在 QQ 群中 @ 机器人并发送以下指令:
|
||||
|
||||
#### 创建新对话
|
||||
```
|
||||
@机器人 /new
|
||||
```
|
||||
|
||||
#### 查看最近对话
|
||||
```
|
||||
@机器人 /resume 10
|
||||
```
|
||||
获取最近 10 条对话的标题和 ID。
|
||||
|
||||
#### 切换对话
|
||||
```
|
||||
@机器人 /switch conv_20260315_162256_599
|
||||
```
|
||||
|
||||
#### 停止当前任务
|
||||
```
|
||||
@机器人 /stop
|
||||
```
|
||||
只能在任务运行期间使用。
|
||||
|
||||
### 发送消息
|
||||
|
||||
直接 @ 机器人并发送消息:
|
||||
|
||||
```
|
||||
@机器人 英伟达今天的股票价格是多少?
|
||||
```
|
||||
|
||||
如果没有当前对话,会自动创建一个新对话。
|
||||
|
||||
### 发送文件
|
||||
|
||||
模型可以使用 `send_qq_file` 工具发送工作区内的文件:
|
||||
|
||||
```
|
||||
@机器人 生成一张图表并发送给我
|
||||
```
|
||||
|
||||
模型会调用工具生成图表,然后使用 `send_qq_file` 工具将图片发送到群里。
|
||||
|
||||
## 消息格式
|
||||
|
||||
### 工具调用
|
||||
|
||||
当模型调用工具时,会发送格式化的工具信息:
|
||||
|
||||
```
|
||||
[工具] run_command
|
||||
参数: ls -la
|
||||
|
||||
[工具] web_search
|
||||
参数: 英伟达2026年3月15日股价
|
||||
```
|
||||
|
||||
### 模型输出
|
||||
|
||||
模型的文本输出会直接发送到群里,不包含思考过程和工具结果。
|
||||
|
||||
## 架构说明
|
||||
|
||||
```
|
||||
scripts/qq_bot/
|
||||
├── __init__.py # 包初始化
|
||||
├── main.py # 主程序入口
|
||||
├── config.py # 配置管理
|
||||
├── napcat_client.py # NapCat API 客户端
|
||||
├── web_api_client.py # Agents Web API 客户端
|
||||
├── message_handler.py # 消息处理器
|
||||
├── state_manager.py # 状态管理
|
||||
├── poller.py # HTTP 轮询器
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. **消息接收**: HTTP 轮询器定期获取群消息
|
||||
2. **消息解析**: 检测 @ 机器人的消息,解析指令或普通消息
|
||||
3. **任务创建**: 通过 Web API 发送消息到 Agents 系统
|
||||
4. **事件轮询**: 轮询任务状态,获取工具调用和模型输出
|
||||
5. **消息推送**: 将工具调用和模型输出发送到 QQ 群
|
||||
|
||||
## 自定义工具
|
||||
|
||||
### send_qq_file
|
||||
|
||||
位置:`data/custom_tools/send_qq_file/`
|
||||
|
||||
功能:发送工作区内的文件到 QQ 群
|
||||
|
||||
参数:
|
||||
- `file_path`: 文件相对路径(必填)
|
||||
- `description`: 文件描述(可选)
|
||||
|
||||
示例:
|
||||
```python
|
||||
# 模型会这样调用
|
||||
send_qq_file(file_path="output/chart.png", description="这是生成的图表")
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **单群模式**: 当前只支持一个群(TARGET_GROUP_ID)
|
||||
2. **任务互斥**: 同一时间只能运行一个任务
|
||||
3. **Docker 隔离**: qqbot 用户必须使用 Docker 模式,不能使用 host 模式
|
||||
4. **文件路径**: send_qq_file 工具只能访问工作区内的文件
|
||||
5. **消息轮询**: 使用 HTTP 轮询方式获取消息,可能有 1-2 秒延迟
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 机器人无响应
|
||||
|
||||
1. 检查 NapCat 是否正常运行
|
||||
2. 检查 Agents Web 服务器是否运行
|
||||
3. 检查日志文件 `qq_bot.log`
|
||||
4. 确认机器人 QQ 号是否正确
|
||||
|
||||
### 登录失败
|
||||
|
||||
1. 确认 qqbot 用户已创建
|
||||
2. 检查邮箱和密码是否正确
|
||||
3. 查看 Agents 日志确认登录请求
|
||||
|
||||
### 文件发送失败
|
||||
|
||||
1. 确认文件路径正确(相对于工作区)
|
||||
2. 检查文件是否存在
|
||||
3. 确认 NapCat 支持该文件类型
|
||||
4. 查看日志了解详细错误信息
|
||||
|
||||
## 开发
|
||||
|
||||
### 添加新指令
|
||||
|
||||
在 `message_handler.py` 的 `_handle_command` 方法中添加新的指令处理:
|
||||
|
||||
```python
|
||||
elif cmd == "/your_command":
|
||||
await self._cmd_your_command(args)
|
||||
```
|
||||
|
||||
### 修改消息格式
|
||||
|
||||
在 `message_handler.py` 的 `_handle_tool_start` 和 `_handle_text_end` 方法中修改。
|
||||
|
||||
### 调整轮询间隔
|
||||
|
||||
修改 `config.py` 中的 `POLL_INTERVAL` 变量。
|
||||
|
||||
## 许可证
|
||||
|
||||
与 Agents 项目保持一致。
|
||||
3
scripts/qq_bot/__init__.py
Normal file
3
scripts/qq_bot/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""QQ Bot for Agents - 通过 NapCat 调用 Agents 系统"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
30
scripts/qq_bot/config.py
Normal file
30
scripts/qq_bot/config.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""配置管理模块"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# NapCat 配置
|
||||
NAPCAT_HOST = os.getenv("NAPCAT_HOST", "http://localhost:3000")
|
||||
NAPCAT_TOKEN = os.getenv("NAPCAT_TOKEN", "283222464f13")
|
||||
|
||||
# Agents Web API 配置
|
||||
AGENTS_HOST = os.getenv("AGENTS_HOST", "http://localhost:8091")
|
||||
AGENTS_EMAIL = os.getenv("AGENTS_EMAIL", "qqbot@qq.com")
|
||||
AGENTS_PASSWORD = os.getenv("AGENTS_PASSWORD", "12345678")
|
||||
|
||||
# QQ 群配置
|
||||
TARGET_GROUP_ID = int(os.getenv("TARGET_GROUP_ID", "1092770403"))
|
||||
|
||||
# 机器人 QQ 号(需要从 NapCat 获取)
|
||||
BOT_QQ_ID = None # 将在运行时获取
|
||||
|
||||
# 轮询配置
|
||||
POLL_INTERVAL = float(os.getenv("POLL_INTERVAL", "1.0")) # 秒
|
||||
POLL_TIMEOUT = int(os.getenv("POLL_TIMEOUT", "300")) # 任务超时时间(秒)
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
LOG_FILE = os.getenv("LOG_FILE", "qq_bot.log")
|
||||
|
||||
# 图片文件扩展名
|
||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
||||
152
scripts/qq_bot/main.py
Normal file
152
scripts/qq_bot/main.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""主程序入口"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from scripts.qq_bot.config import (
|
||||
LOG_LEVEL,
|
||||
LOG_FILE,
|
||||
TARGET_GROUP_ID,
|
||||
NAPCAT_HOST,
|
||||
AGENTS_HOST,
|
||||
)
|
||||
from scripts.qq_bot.napcat_client import NapCatClient
|
||||
from scripts.qq_bot.web_api_client import WebAPIClient
|
||||
from scripts.qq_bot.state_manager import StateManager
|
||||
from scripts.qq_bot.message_handler import MessageHandler
|
||||
from scripts.qq_bot.message_poller import MessagePoller
|
||||
import scripts.qq_bot.config as config
|
||||
|
||||
# 配置日志
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG, # 文件记录 DEBUG
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE, encoding="utf-8"),
|
||||
],
|
||||
)
|
||||
|
||||
# 终端只输出 INFO 及以上
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||
logging.getLogger().addHandler(console_handler)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QQBot:
|
||||
"""QQ 机器人主类"""
|
||||
|
||||
def __init__(self):
|
||||
self.napcat_client: Optional[NapCatClient] = None
|
||||
self.web_client: Optional[WebAPIClient] = None
|
||||
self.state_manager = StateManager()
|
||||
self.message_handler: Optional[MessageHandler] = None
|
||||
self.poller: Optional[MessagePoller] = None
|
||||
self.running = False
|
||||
|
||||
async def start(self):
|
||||
"""启动机器人"""
|
||||
logger.info("=" * 60)
|
||||
logger.info("QQ Bot for Agents 启动中...")
|
||||
logger.info(f"目标群号: {TARGET_GROUP_ID}")
|
||||
logger.info(f"NapCat: {NAPCAT_HOST}")
|
||||
logger.info(f"Agents: {AGENTS_HOST}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
try:
|
||||
# 初始化客户端
|
||||
self.napcat_client = NapCatClient()
|
||||
self.web_client = WebAPIClient()
|
||||
|
||||
async with self.napcat_client, self.web_client:
|
||||
# 获取机器人 QQ 号
|
||||
login_info = await self.napcat_client.get_login_info()
|
||||
if login_info.get("status") == "ok":
|
||||
bot_qq = login_info.get("data", {}).get("user_id")
|
||||
config.BOT_QQ_ID = bot_qq
|
||||
logger.info(f"机器人 QQ 号: {bot_qq}")
|
||||
else:
|
||||
logger.error("无法获取机器人 QQ 号")
|
||||
return
|
||||
|
||||
# 初始化消息处理器
|
||||
self.message_handler = MessageHandler(
|
||||
self.napcat_client, self.web_client, self.state_manager
|
||||
)
|
||||
|
||||
# 初始化消息轮询器
|
||||
self.poller = MessagePoller(
|
||||
self.napcat_client, self.message_handler
|
||||
)
|
||||
|
||||
# 发送启动消息
|
||||
await self.napcat_client.send_group_msg(
|
||||
TARGET_GROUP_ID, "🤖 QQ Bot 已启动,@ 我来使用 Agents 系统"
|
||||
)
|
||||
|
||||
logger.info("机器人已启动,开始轮询消息...")
|
||||
self.running = True
|
||||
|
||||
# 启动轮询
|
||||
await self.poller.start()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到中断信号,正在关闭...")
|
||||
except Exception as e:
|
||||
logger.error(f"机器人运行异常: {e}", exc_info=True)
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def stop(self):
|
||||
"""停止机器人"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
logger.info("正在停止机器人...")
|
||||
self.running = False
|
||||
|
||||
if self.poller:
|
||||
self.poller.stop()
|
||||
|
||||
if self.napcat_client:
|
||||
try:
|
||||
await self.napcat_client.send_group_msg(
|
||||
TARGET_GROUP_ID, "🤖 QQ Bot 已停止"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("机器人已停止")
|
||||
|
||||
|
||||
async def main():
|
||||
"""主函数"""
|
||||
bot = QQBot()
|
||||
|
||||
# 注册信号处理
|
||||
def signal_handler(sig, frame):
|
||||
logger.info(f"收到信号 {sig},准备退出...")
|
||||
asyncio.create_task(bot.stop())
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
await bot.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("程序已退出")
|
||||
577
scripts/qq_bot/message_handler.py
Normal file
577
scripts/qq_bot/message_handler.py
Normal file
@ -0,0 +1,577 @@
|
||||
"""消息处理器"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from .config import TARGET_GROUP_ID, POLL_INTERVAL, POLL_TIMEOUT
|
||||
from .napcat_client import NapCatClient
|
||||
from .web_api_client import WebAPIClient
|
||||
from .state_manager import StateManager
|
||||
from . import config # 导入 config 模块,而不是导入 BOT_QQ_ID
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageHandler:
|
||||
"""消息处理器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
napcat_client: NapCatClient,
|
||||
web_client: WebAPIClient,
|
||||
state_manager: StateManager,
|
||||
):
|
||||
self.napcat = napcat_client
|
||||
self.web = web_client
|
||||
self.state = state_manager
|
||||
|
||||
async def handle_message(self, message: str, user_id: int) -> bool:
|
||||
"""处理群消息
|
||||
|
||||
Args:
|
||||
message: 消息内容
|
||||
user_id: 发送者 QQ 号
|
||||
|
||||
Returns:
|
||||
是否处理了该消息
|
||||
"""
|
||||
logger.debug(f"handle_message 被调用: message={message}, user_id={user_id}")
|
||||
logger.debug(f"BOT_QQ_ID={config.BOT_QQ_ID}")
|
||||
|
||||
# 检查是否 @ 机器人
|
||||
is_mentioned = self._is_mentioned(message)
|
||||
logger.debug(f"是否 @ 机器人: {is_mentioned}")
|
||||
|
||||
if not is_mentioned:
|
||||
logger.debug("未 @ 机器人,忽略消息")
|
||||
return False
|
||||
|
||||
# 移除 @ 标记
|
||||
clean_msg = self._clean_message(message)
|
||||
logger.info(f"处理消息: {clean_msg}")
|
||||
|
||||
# 解析指令
|
||||
if clean_msg.startswith("/"):
|
||||
logger.info(f"处理指令: {clean_msg}")
|
||||
await self._handle_command(clean_msg)
|
||||
else:
|
||||
# 普通消息
|
||||
logger.info(f"处理普通消息: {clean_msg}")
|
||||
await self._handle_chat_message(clean_msg)
|
||||
|
||||
return True
|
||||
|
||||
def _is_mentioned(self, message: str) -> bool:
|
||||
"""检查是否 @ 了机器人"""
|
||||
logger.debug(f"检查是否 @ 机器人: BOT_QQ_ID={config.BOT_QQ_ID}, message={message}")
|
||||
|
||||
if not config.BOT_QQ_ID:
|
||||
logger.warning("BOT_QQ_ID 未设置")
|
||||
return False
|
||||
|
||||
# 检查 CQ 码格式的 at
|
||||
pattern = rf"\[CQ:at,qq={config.BOT_QQ_ID}\]"
|
||||
result = bool(re.search(pattern, message))
|
||||
logger.debug(f"匹配结果: {result}, pattern={pattern}")
|
||||
return result
|
||||
|
||||
def _clean_message(self, message: str) -> str:
|
||||
"""清理消息,移除 @ 标记"""
|
||||
# 移除所有 CQ:at 标记
|
||||
cleaned = re.sub(r"\[CQ:at,qq=\d+\]", "", message)
|
||||
return cleaned.strip()
|
||||
|
||||
async def _handle_command(self, command: str):
|
||||
"""处理指令"""
|
||||
parts = command.split(maxsplit=1)
|
||||
cmd = parts[0].lower()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
try:
|
||||
if cmd == "/help":
|
||||
await self._cmd_help()
|
||||
elif cmd == "/test":
|
||||
await self._cmd_test()
|
||||
elif cmd == "/new":
|
||||
await self._cmd_new()
|
||||
elif cmd == "/resume":
|
||||
limit = int(args) if args.isdigit() else 10
|
||||
await self._cmd_resume(limit)
|
||||
elif cmd == "/switch":
|
||||
if not args:
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, "❌ 请提供对话 ID,例如:/switch conv_xxx"
|
||||
)
|
||||
return
|
||||
await self._cmd_switch(args.strip())
|
||||
elif cmd == "/stop":
|
||||
await self._cmd_stop()
|
||||
else:
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 未知指令: {cmd}\n发送 /help 查看帮助"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"处理指令失败: {e}", exc_info=True)
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 指令执行失败: {str(e)}"
|
||||
)
|
||||
|
||||
async def _cmd_help(self):
|
||||
"""显示帮助信息"""
|
||||
help_text = """🤖 QQ Bot 使用帮助
|
||||
|
||||
📝 基本指令:
|
||||
/help - 显示此帮助信息
|
||||
/new - 创建新对话
|
||||
/resume [数量] - 查看最近的对话列表(默认10条)
|
||||
/switch <对话ID> - 切换到指定对话
|
||||
/stop - 停止当前运行的任务
|
||||
/test - 测试自定义工具是否可用
|
||||
|
||||
💬 发送消息:
|
||||
直接 @ 我并输入消息即可与 AI 对话
|
||||
例如:@机器人 今天天气怎么样?
|
||||
|
||||
📌 注意事项:
|
||||
• 只能在空闲时发送消息或执行指令(/stop 除外)
|
||||
• 运行期间的消息会被忽略
|
||||
• 模型可以使用工具执行命令、搜索网页等"""
|
||||
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, help_text)
|
||||
|
||||
async def _cmd_test(self):
|
||||
"""测试自定义工具"""
|
||||
try:
|
||||
# 发送一个简单的测试消息
|
||||
message = "请告诉我你有哪些可用的工具?特别是有没有 send_qq_file 工具?"
|
||||
await self._send_message(message)
|
||||
except Exception as e:
|
||||
logger.error(f"测试失败: {e}", exc_info=True)
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, f"❌ 测试失败: {str(e)}")
|
||||
|
||||
async def _cmd_new(self):
|
||||
"""创建新对话"""
|
||||
state = self.state.get_state()
|
||||
if state.is_running():
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, "❌ 当前有任务正在运行,请等待完成或使用 /stop 停止"
|
||||
)
|
||||
return
|
||||
|
||||
result = await self.web.create_conversation(thinking_mode=False, mode="fast")
|
||||
if result.get("success"):
|
||||
conv_id = result.get("conversation_id")
|
||||
state.set_conversation(conv_id)
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"✅ 已创建新对话: {conv_id}"
|
||||
)
|
||||
else:
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 创建对话失败: {result.get('error')}"
|
||||
)
|
||||
|
||||
async def _cmd_resume(self, limit: int):
|
||||
"""获取最近对话列表"""
|
||||
state = self.state.get_state()
|
||||
if state.is_running():
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, "❌ 当前有任务正在运行,请等待完成或使用 /stop 停止"
|
||||
)
|
||||
return
|
||||
|
||||
result = await self.web.list_conversations(limit=limit)
|
||||
if not result.get("success"):
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 获取对话列表失败: {result.get('error')}"
|
||||
)
|
||||
return
|
||||
|
||||
conversations = result.get("data", {}).get("conversations", [])
|
||||
if not conversations:
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, "📋 暂无对话记录")
|
||||
return
|
||||
|
||||
# 格式化对话列表
|
||||
lines = [f"📋 最近 {len(conversations)} 条对话:\n"]
|
||||
for conv in conversations:
|
||||
conv_id = conv.get("id", "")
|
||||
title = conv.get("title", "无标题")
|
||||
lines.append(f"• {conv_id}\n {title}")
|
||||
|
||||
message = "\n".join(lines)
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, message)
|
||||
|
||||
async def _cmd_switch(self, conversation_id: str):
|
||||
"""切换对话"""
|
||||
state = self.state.get_state()
|
||||
if state.is_running():
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, "❌ 当前有任务正在运行,请等待完成或使用 /stop 停止"
|
||||
)
|
||||
return
|
||||
|
||||
result = await self.web.load_conversation(conversation_id)
|
||||
if result.get("success"):
|
||||
state.set_conversation(conversation_id)
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"✅ 已切换到对话: {conversation_id}"
|
||||
)
|
||||
else:
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 切换对话失败: {result.get('error')}"
|
||||
)
|
||||
|
||||
async def _cmd_stop(self):
|
||||
"""停止当前任务"""
|
||||
state = self.state.get_state()
|
||||
if not state.is_running():
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, "ℹ️ 当前没有运行中的任务")
|
||||
return
|
||||
|
||||
if not state.task_id:
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, "❌ 任务 ID 丢失")
|
||||
state.finish_task()
|
||||
return
|
||||
|
||||
result = await self.web.cancel_task(state.task_id)
|
||||
if result.get("success"):
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, "✅ 已停止任务")
|
||||
state.finish_task()
|
||||
else:
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 停止任务失败: {result.get('error')}"
|
||||
)
|
||||
|
||||
async def _handle_chat_message(self, message: str):
|
||||
"""处理普通聊天消息"""
|
||||
state = self.state.get_state()
|
||||
|
||||
# 检查是否空闲
|
||||
if state.is_running():
|
||||
logger.info("任务运行中,忽略消息")
|
||||
return
|
||||
|
||||
# 检查是否有对话
|
||||
if not state.conversation_id:
|
||||
# 自动创建对话
|
||||
logger.info("自动创建对话...")
|
||||
result = await self.web.create_conversation(thinking_mode=False, mode="fast")
|
||||
if result.get("success"):
|
||||
conv_id = result.get("conversation_id")
|
||||
state.set_conversation(conv_id)
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"✅ 已自动创建对话: {conv_id}"
|
||||
)
|
||||
else:
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 创建对话失败: {result.get('error')}"
|
||||
)
|
||||
return
|
||||
|
||||
# 发送消息
|
||||
try:
|
||||
logger.info(f"发送消息到对话 {state.conversation_id}: {message}")
|
||||
result = await self.web.send_message(message, state.conversation_id)
|
||||
if not result.get("success"):
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 发送消息失败: {result.get('error')}"
|
||||
)
|
||||
return
|
||||
|
||||
task_id = result.get("data", {}).get("task_id")
|
||||
if not task_id:
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, "❌ 未获取到任务 ID")
|
||||
return
|
||||
|
||||
logger.info(f"任务已创建: {task_id}")
|
||||
state.start_task(task_id)
|
||||
|
||||
# 开始轮询任务
|
||||
logger.info(f"开始轮询任务 {task_id}")
|
||||
await self._poll_task(task_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理消息失败: {e}", exc_info=True)
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 处理消息失败: {str(e)}"
|
||||
)
|
||||
state.finish_task()
|
||||
|
||||
async def _poll_task(self, task_id: str):
|
||||
"""轮询任务直到完成"""
|
||||
state = self.state.get_state()
|
||||
timeout_counter = 0
|
||||
max_timeout = POLL_TIMEOUT / POLL_INTERVAL
|
||||
|
||||
logger.info(f"开始轮询任务 {task_id},间隔 {POLL_INTERVAL}秒")
|
||||
|
||||
while state.is_running():
|
||||
try:
|
||||
logger.debug(f"轮询任务 {task_id},offset={state.last_offset}")
|
||||
result = await self.web.poll_task(task_id, state.last_offset)
|
||||
if not result.get("success"):
|
||||
logger.error(f"轮询任务失败: {result}")
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 任务执行失败: {result.get('error')}"
|
||||
)
|
||||
state.finish_task()
|
||||
break
|
||||
|
||||
data = result.get("data", {})
|
||||
status = data.get("status")
|
||||
events = data.get("events", [])
|
||||
next_offset = data.get("next_offset", state.last_offset)
|
||||
|
||||
logger.debug(f"任务状态: {status}, 事件数: {len(events)}, next_offset: {next_offset}")
|
||||
|
||||
# 更新偏移量
|
||||
state.last_offset = next_offset
|
||||
|
||||
# 处理事件
|
||||
if events:
|
||||
logger.info(f"处理 {len(events)} 个事件")
|
||||
await self._process_events(events)
|
||||
|
||||
# 检查任务状态
|
||||
if status in {"succeeded", "failed", "cancelled"}:
|
||||
logger.info(f"任务完成,状态: {status}")
|
||||
if status == "failed":
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, "❌ 任务执行失败"
|
||||
)
|
||||
elif status == "cancelled":
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, "ℹ️ 任务已取消"
|
||||
)
|
||||
state.finish_task()
|
||||
break
|
||||
|
||||
# 超时检查
|
||||
timeout_counter += 1
|
||||
if timeout_counter > max_timeout:
|
||||
logger.warning("任务超时")
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, "⚠️ 任务超时,已自动停止"
|
||||
)
|
||||
await self.web.cancel_task(task_id)
|
||||
state.finish_task()
|
||||
break
|
||||
|
||||
# 等待下次轮询
|
||||
await asyncio.sleep(POLL_INTERVAL)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"轮询任务异常: {e}", exc_info=True)
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 任务执行异常: {str(e)}"
|
||||
)
|
||||
state.finish_task()
|
||||
break
|
||||
|
||||
async def _process_events(self, events: List[Dict[str, Any]]):
|
||||
"""处理任务事件"""
|
||||
for event in events:
|
||||
event_type = event.get("type")
|
||||
event_data = event.get("data", {})
|
||||
|
||||
logger.debug(f"收到事件: type={event_type}, data_keys={list(event_data.keys())}")
|
||||
|
||||
try:
|
||||
if event_type == "tool_start":
|
||||
# 工具开始执行
|
||||
tool_name = data.get("name", "")
|
||||
logger.info(f"工具开始: {tool_name}")
|
||||
await self._handle_tool_start(event_data)
|
||||
|
||||
# 检测到 send_qq_file 工具,记录工具 ID
|
||||
if tool_name == "send_qq_file":
|
||||
tool_id = event_data.get("id", "")
|
||||
logger.info(f"检测到 send_qq_file 工具调用,ID: {tool_id}")
|
||||
# 保存到状态,等待 update_action
|
||||
state = self.state.get_state()
|
||||
if not hasattr(state, 'pending_send_file_tools'):
|
||||
state.pending_send_file_tools = set()
|
||||
state.pending_send_file_tools.add(tool_id)
|
||||
elif event_type == "update_action":
|
||||
# 工具执行完成(包含结果)
|
||||
tool_id = event_data.get("id", "")
|
||||
status = event_data.get("status", "")
|
||||
result = event_data.get("result", {})
|
||||
|
||||
# 检查是否是 send_qq_file 工具
|
||||
state = self.state.get_state()
|
||||
if hasattr(state, 'pending_send_file_tools') and tool_id in state.pending_send_file_tools:
|
||||
logger.info(f"收到 send_qq_file 工具结果,status: {status}")
|
||||
state.pending_send_file_tools.remove(tool_id)
|
||||
|
||||
# 处理文件发送
|
||||
if status == "success":
|
||||
await self._handle_send_file_result(result)
|
||||
elif event_type == "text_end":
|
||||
# 模型输出完成
|
||||
await self._handle_text_end(event_data)
|
||||
except Exception as e:
|
||||
logger.error(f"处理事件失败: {event_type}, {e}", exc_info=True)
|
||||
|
||||
async def _handle_tool_start(self, data: Dict[str, Any]):
|
||||
"""处理工具调用开始"""
|
||||
tool_name = data.get("name", "unknown")
|
||||
arguments = data.get("arguments", {})
|
||||
|
||||
# 格式化工具调用信息
|
||||
lines = [f"[工具] {tool_name}"]
|
||||
|
||||
# 提取关键参数
|
||||
if tool_name == "run_command":
|
||||
cmd = arguments.get("command", "")
|
||||
lines.append(f"参数: {cmd}")
|
||||
elif tool_name == "web_search":
|
||||
query = arguments.get("query", "")
|
||||
lines.append(f"参数: {query}")
|
||||
elif tool_name == "read_file":
|
||||
path = arguments.get("path", "")
|
||||
lines.append(f"参数: {path}")
|
||||
elif tool_name == "write_file":
|
||||
path = arguments.get("path", "")
|
||||
lines.append(f"参数: {path}")
|
||||
else:
|
||||
# 其他工具,显示所有参数
|
||||
if arguments:
|
||||
args_str = ", ".join(f"{k}={v}" for k, v in arguments.items())
|
||||
lines.append(f"参数: {args_str}")
|
||||
|
||||
message = "\n".join(lines)
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, message)
|
||||
|
||||
async def _handle_text_end(self, data: Dict[str, Any]):
|
||||
"""处理模型输出完成"""
|
||||
content = data.get("full_content", "")
|
||||
if content:
|
||||
# 发送模型输出
|
||||
await self.napcat.send_group_msg(TARGET_GROUP_ID, content)
|
||||
|
||||
async def _handle_send_file_result(self, result: Dict[str, Any]):
|
||||
"""处理 send_qq_file 工具结果"""
|
||||
try:
|
||||
# result 可能是字符串或字典
|
||||
if isinstance(result, str):
|
||||
import json
|
||||
result_data = json.loads(result)
|
||||
else:
|
||||
result_data = result
|
||||
|
||||
if not result_data.get("success"):
|
||||
error = result_data.get("error", "未知错误")
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 发送文件失败: {error}"
|
||||
)
|
||||
return
|
||||
|
||||
file_path = result_data.get("file_path", "")
|
||||
description = result_data.get("description", "")
|
||||
|
||||
if not file_path:
|
||||
logger.warning("send_qq_file 工具未提供文件路径")
|
||||
return
|
||||
|
||||
# 转换容器内路径到宿主机路径
|
||||
# 容器内: /workspace/xxx -> 宿主机: users/qqbot/project/xxx
|
||||
if file_path.startswith("/workspace/"):
|
||||
relative_path = file_path[len("/workspace/"):]
|
||||
# 获取项目根目录
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
host_path = os.path.join(project_root, "users", "qqbot", "project", relative_path)
|
||||
else:
|
||||
# 如果不是 /workspace 开头,假设是相对路径
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
host_path = os.path.join(project_root, "users", "qqbot", "project", file_path)
|
||||
|
||||
logger.info(f"转换文件路径: {file_path} -> {host_path}")
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(host_path):
|
||||
logger.error(f"文件不存在: {host_path}")
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 文件不存在: {file_path}"
|
||||
)
|
||||
return
|
||||
|
||||
# 发送文件到 QQ 群
|
||||
await self.napcat.send_file(TARGET_GROUP_ID, host_path, description)
|
||||
logger.info(f"已发送文件: {host_path}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析工具输出失败: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"发送文件失败: {e}", exc_info=True)
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 发送文件失败: {str(e)}"
|
||||
)
|
||||
|
||||
async def _handle_tool_result(self, data: Dict[str, Any]):
|
||||
"""处理工具执行结果"""
|
||||
tool_name = data.get("name", "")
|
||||
|
||||
# 只处理 send_qq_file 工具
|
||||
if tool_name != "send_qq_file":
|
||||
return
|
||||
|
||||
# 解析工具输出
|
||||
output = data.get("output", "")
|
||||
if not output:
|
||||
logger.warning("send_qq_file 工具无输出")
|
||||
return
|
||||
|
||||
try:
|
||||
import json
|
||||
result = json.loads(output)
|
||||
|
||||
if not result.get("success"):
|
||||
error = result.get("error", "未知错误")
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 发送文件失败: {error}"
|
||||
)
|
||||
return
|
||||
|
||||
file_path = result.get("file_path", "")
|
||||
description = result.get("description", "")
|
||||
|
||||
if not file_path:
|
||||
logger.warning("send_qq_file 工具未提供文件路径")
|
||||
return
|
||||
|
||||
# 转换容器内路径到宿主机路径
|
||||
# 容器内: /workspace/xxx -> 宿主机: users/qqbot/project/xxx
|
||||
if file_path.startswith("/workspace/"):
|
||||
relative_path = file_path[len("/workspace/"):]
|
||||
# 获取项目根目录
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
host_path = os.path.join(project_root, "users", "qqbot", "project", relative_path)
|
||||
else:
|
||||
# 如果不是 /workspace 开头,假设是相对路径
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
host_path = os.path.join(project_root, "users", "qqbot", "project", file_path)
|
||||
|
||||
logger.info(f"转换文件路径: {file_path} -> {host_path}")
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(host_path):
|
||||
logger.error(f"文件不存在: {host_path}")
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 文件不存在: {file_path}"
|
||||
)
|
||||
return
|
||||
|
||||
# 发送文件到 QQ 群
|
||||
await self.napcat.send_file(TARGET_GROUP_ID, host_path, description)
|
||||
logger.info(f"已发送文件: {host_path}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析工具输出失败: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"发送文件失败: {e}", exc_info=True)
|
||||
await self.napcat.send_group_msg(
|
||||
TARGET_GROUP_ID, f"❌ 发送文件失败: {str(e)}"
|
||||
)
|
||||
165
scripts/qq_bot/message_poller.py
Normal file
165
scripts/qq_bot/message_poller.py
Normal file
@ -0,0 +1,165 @@
|
||||
"""消息轮询器 - 定期获取群消息"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Set, Optional
|
||||
|
||||
from .config import TARGET_GROUP_ID, POLL_INTERVAL
|
||||
from .napcat_client import NapCatClient
|
||||
from .message_handler import MessageHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessagePoller:
|
||||
"""消息轮询器 - 使用 get_group_msg_history 获取消息"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
napcat_client: NapCatClient,
|
||||
message_handler: MessageHandler,
|
||||
poll_interval: float = POLL_INTERVAL,
|
||||
fetch_count: int = 20,
|
||||
):
|
||||
self.napcat = napcat_client
|
||||
self.handler = message_handler
|
||||
self.poll_interval = poll_interval
|
||||
self.fetch_count = fetch_count
|
||||
self.running = False
|
||||
|
||||
# 已处理的消息 ID 集合
|
||||
self.processed_ids: Set[int] = set()
|
||||
|
||||
# 机器人自己的 QQ 号
|
||||
self.bot_qq_id: Optional[int] = None
|
||||
|
||||
async def start(self):
|
||||
"""开始轮询"""
|
||||
self.running = True
|
||||
logger.info("消息轮询器已启动")
|
||||
|
||||
# 获取机器人 QQ 号
|
||||
login_info = await self.napcat.get_login_info()
|
||||
if login_info.get("status") == "ok":
|
||||
self.bot_qq_id = login_info.get("data", {}).get("user_id")
|
||||
logger.info(f"机器人 QQ 号: {self.bot_qq_id}")
|
||||
|
||||
# 设置到 config 模块
|
||||
from . import config
|
||||
config.BOT_QQ_ID = self.bot_qq_id
|
||||
logger.info(f"已设置 config.BOT_QQ_ID = {self.bot_qq_id}")
|
||||
|
||||
# 首次轮询,标记所有历史消息为已处理(避免重复处理旧消息)
|
||||
logger.info("首次轮询,标记历史消息...")
|
||||
try:
|
||||
result = await self.napcat.get_group_msg_history(
|
||||
TARGET_GROUP_ID, message_seq=0, count=self.fetch_count
|
||||
)
|
||||
if result.get("status") == "ok":
|
||||
messages = result.get("data", {}).get("messages", [])
|
||||
for msg in messages:
|
||||
msg_id = msg.get("message_id")
|
||||
if msg_id:
|
||||
self.processed_ids.add(msg_id)
|
||||
logger.info(f"已标记 {len(self.processed_ids)} 条历史消息")
|
||||
except Exception as e:
|
||||
logger.error(f"首次轮询失败: {e}")
|
||||
|
||||
logger.info("开始监听新消息...")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
await self._poll_once()
|
||||
except Exception as e:
|
||||
logger.error(f"轮询异常: {e}", exc_info=True)
|
||||
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
def stop(self):
|
||||
"""停止轮询"""
|
||||
self.running = False
|
||||
logger.info("消息轮询器已停止")
|
||||
|
||||
async def _poll_once(self):
|
||||
"""执行一次轮询"""
|
||||
try:
|
||||
logger.debug(f"开始轮询群 {TARGET_GROUP_ID} 的消息...")
|
||||
|
||||
# 获取群消息历史(从最新消息开始)
|
||||
result = await self.napcat.get_group_msg_history(
|
||||
TARGET_GROUP_ID, message_seq=0, count=self.fetch_count
|
||||
)
|
||||
|
||||
logger.debug(f"轮询结果: {result}")
|
||||
|
||||
if result.get("status") != "ok":
|
||||
logger.warning(f"获取消息历史失败: {result}")
|
||||
return
|
||||
|
||||
data = result.get("data", {})
|
||||
messages = data.get("messages", [])
|
||||
|
||||
logger.debug(f"获取到 {len(messages)} 条消息")
|
||||
|
||||
if not messages:
|
||||
return
|
||||
|
||||
# 消息按时间倒序(最新的在前),需要反转
|
||||
messages.reverse()
|
||||
|
||||
# 处理每条消息
|
||||
for msg in messages:
|
||||
await self._process_message(msg)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"轮询消息失败: {e}", exc_info=True)
|
||||
|
||||
async def _process_message(self, msg: dict):
|
||||
"""处理单条消息"""
|
||||
try:
|
||||
message_id = msg.get("message_id")
|
||||
logger.debug(f"处理消息 ID: {message_id}")
|
||||
|
||||
if not message_id:
|
||||
logger.debug("消息 ID 为空,跳过")
|
||||
return
|
||||
|
||||
# 跳过已处理的消息
|
||||
if message_id in self.processed_ids:
|
||||
logger.debug(f"消息 {message_id} 已处理,跳过")
|
||||
return
|
||||
|
||||
# 记录消息 ID
|
||||
self.processed_ids.add(message_id)
|
||||
logger.debug(f"已处理消息数: {len(self.processed_ids)}")
|
||||
|
||||
# 限制集合大小
|
||||
if len(self.processed_ids) > 1000:
|
||||
# 保留最近的 500 条
|
||||
self.processed_ids = set(list(self.processed_ids)[-500:])
|
||||
|
||||
# 获取发送者信息
|
||||
sender = msg.get("sender", {})
|
||||
user_id = sender.get("user_id")
|
||||
|
||||
logger.debug(f"发送者 ID: {user_id}, 机器人 ID: {self.bot_qq_id}")
|
||||
|
||||
# 跳过机器人自己的消息
|
||||
if self.bot_qq_id and user_id == self.bot_qq_id:
|
||||
logger.debug("跳过机器人自己的消息")
|
||||
return
|
||||
|
||||
# 解析消息内容
|
||||
raw_message = msg.get("raw_message", "")
|
||||
if not raw_message:
|
||||
logger.debug("消息内容为空,跳过")
|
||||
return
|
||||
|
||||
logger.info(f"收到新消息 [{user_id}]: {raw_message}")
|
||||
|
||||
# 处理消息
|
||||
handled = await self.handler.handle_message(raw_message, user_id)
|
||||
logger.info(f"消息处理结果: {handled}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理消息失败: {e}", exc_info=True)
|
||||
142
scripts/qq_bot/napcat_client.py
Normal file
142
scripts/qq_bot/napcat_client.py
Normal file
@ -0,0 +1,142 @@
|
||||
"""NapCat API 客户端"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .config import NAPCAT_HOST, NAPCAT_TOKEN, IMAGE_EXTENSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NapCatClient:
|
||||
"""NapCat OneBot11 HTTP API 客户端"""
|
||||
|
||||
def __init__(self, host: str = NAPCAT_HOST, token: str = NAPCAT_TOKEN):
|
||||
self.host = host.rstrip("/")
|
||||
self.token = token
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
self.session = aiohttp.ClientSession()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def _request(self, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""发送 HTTP 请求到 NapCat"""
|
||||
if not self.session:
|
||||
raise RuntimeError("Client not initialized. Use 'async with' context manager.")
|
||||
|
||||
url = f"{self.host}/{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
logger.debug(f"请求 NapCat API: {endpoint}, data={data}")
|
||||
|
||||
try:
|
||||
async with self.session.post(url, json=data or {}, headers=headers) as resp:
|
||||
result = await resp.json()
|
||||
logger.debug(f"NapCat API 响应: {result}")
|
||||
if result.get("status") != "ok":
|
||||
logger.warning(f"NapCat API 返回非 ok 状态: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"NapCat API 请求失败: {e}")
|
||||
raise
|
||||
|
||||
async def get_login_info(self) -> Dict[str, Any]:
|
||||
"""获取登录账号信息"""
|
||||
return await self._request("get_login_info")
|
||||
|
||||
async def send_group_msg(self, group_id: int, message: str) -> Dict[str, Any]:
|
||||
"""发送群消息(纯文本)"""
|
||||
return await self._request("send_group_msg", {
|
||||
"group_id": group_id,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
async def send_group_msg_with_segments(
|
||||
self, group_id: int, segments: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""发送群消息(消息段数组)"""
|
||||
return await self._request("send_group_msg", {
|
||||
"group_id": group_id,
|
||||
"message": segments,
|
||||
})
|
||||
|
||||
async def send_file(self, group_id: int, file_path: str, description: str = "") -> Dict[str, Any]:
|
||||
"""发送文件到群
|
||||
|
||||
Args:
|
||||
group_id: 群号
|
||||
file_path: 文件绝对路径
|
||||
description: 文件描述
|
||||
"""
|
||||
import shutil
|
||||
import os
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||
|
||||
# 复制文件到 NapCat 能访问的目录
|
||||
# 根据你的测试,NapCat 在容器内,需要复制到 ~/napcat-config/
|
||||
napcat_dir = Path.home() / "napcat-config"
|
||||
napcat_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest_file = napcat_dir / path.name
|
||||
shutil.copy2(file_path, dest_file)
|
||||
logger.info(f"已复制文件到 NapCat 目录: {file_path} -> {dest_file}")
|
||||
|
||||
# 使用 NapCat 容器内的路径
|
||||
# 宿主机的 ~/napcat-config/ 映射到容器内的 /app/napcat/config/
|
||||
napcat_file_path = f"/app/napcat/config/{path.name}"
|
||||
|
||||
# 判断是否为图片
|
||||
is_image = path.suffix.lower() in IMAGE_EXTENSIONS
|
||||
|
||||
if is_image:
|
||||
# 发送图片消息
|
||||
segments = []
|
||||
if description:
|
||||
segments.append({"type": "text", "data": {"text": description}})
|
||||
segments.append({"type": "image", "data": {"file": f"file://{napcat_file_path}"}})
|
||||
return await self.send_group_msg_with_segments(group_id, segments)
|
||||
else:
|
||||
# 发送普通文件
|
||||
segments = []
|
||||
if description:
|
||||
segments.append({"type": "text", "data": {"text": description}})
|
||||
segments.append({
|
||||
"type": "file",
|
||||
"data": {
|
||||
"file": f"file://{napcat_file_path}",
|
||||
"name": path.name
|
||||
}
|
||||
})
|
||||
return await self.send_group_msg_with_segments(group_id, segments)
|
||||
|
||||
async def get_group_msg_history(
|
||||
self, group_id: int, message_seq: int = 0, count: int = 20
|
||||
) -> Dict[str, Any]:
|
||||
"""获取群消息历史
|
||||
|
||||
Args:
|
||||
group_id: 群号
|
||||
message_seq: 起始消息序号,0表示从最新消息开始
|
||||
count: 获取消息数量
|
||||
"""
|
||||
data = {
|
||||
"group_id": group_id,
|
||||
"message_seq": message_seq,
|
||||
"count": count
|
||||
}
|
||||
return await self._request("get_group_msg_history", data)
|
||||
2
scripts/qq_bot/requirements.txt
Normal file
2
scripts/qq_bot/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
# QQ Bot 依赖
|
||||
aiohttp>=3.9.0
|
||||
15
scripts/qq_bot/run.py
Executable file
15
scripts/qq_bot/run.py
Executable file
@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""QQ Bot 启动脚本"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from scripts.qq_bot.main import main
|
||||
import asyncio
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
67
scripts/qq_bot/state_manager.py
Normal file
67
scripts/qq_bot/state_manager.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""状态管理器"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotState:
|
||||
"""机器人状态"""
|
||||
|
||||
# 当前对话 ID
|
||||
conversation_id: Optional[str] = None
|
||||
|
||||
# 当前任务 ID
|
||||
task_id: Optional[str] = None
|
||||
|
||||
# 任务状态:idle, running
|
||||
status: str = "idle"
|
||||
|
||||
# 轮询偏移量
|
||||
last_offset: int = 0
|
||||
|
||||
def is_idle(self) -> bool:
|
||||
"""是否空闲"""
|
||||
return self.status == "idle"
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""是否运行中"""
|
||||
return self.status == "running"
|
||||
|
||||
def start_task(self, task_id: str):
|
||||
"""开始任务"""
|
||||
self.task_id = task_id
|
||||
self.status = "running"
|
||||
self.last_offset = 0
|
||||
logger.info(f"任务开始: {task_id}")
|
||||
|
||||
def finish_task(self):
|
||||
"""完成任务"""
|
||||
logger.info(f"任务完成: {self.task_id}")
|
||||
self.task_id = None
|
||||
self.status = "idle"
|
||||
self.last_offset = 0
|
||||
|
||||
def set_conversation(self, conversation_id: str):
|
||||
"""设置当前对话"""
|
||||
self.conversation_id = conversation_id
|
||||
logger.info(f"切换到对话: {conversation_id}")
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""状态管理器(单群模式)"""
|
||||
|
||||
def __init__(self):
|
||||
self.state = BotState()
|
||||
|
||||
def get_state(self) -> BotState:
|
||||
"""获取当前状态"""
|
||||
return self.state
|
||||
|
||||
def reset(self):
|
||||
"""重置状态"""
|
||||
self.state = BotState()
|
||||
logger.info("状态已重置")
|
||||
101
scripts/qq_bot/test_connection.py
Executable file
101
scripts/qq_bot/test_connection.py
Executable file
@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试 NapCat 和 Agents API 连接"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from scripts.qq_bot.napcat_client import NapCatClient
|
||||
from scripts.qq_bot.web_api_client import WebAPIClient
|
||||
from scripts.qq_bot.config import (
|
||||
NAPCAT_HOST,
|
||||
NAPCAT_TOKEN,
|
||||
AGENTS_HOST,
|
||||
AGENTS_EMAIL,
|
||||
TARGET_GROUP_ID,
|
||||
)
|
||||
|
||||
|
||||
async def test_napcat():
|
||||
"""测试 NapCat 连接"""
|
||||
print("=" * 60)
|
||||
print("测试 NapCat 连接...")
|
||||
print(f"Host: {NAPCAT_HOST}")
|
||||
print(f"Token: {NAPCAT_TOKEN[:10]}...")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
async with NapCatClient() as client:
|
||||
# 获取登录信息
|
||||
result = await client.get_login_info()
|
||||
if result.get("status") == "ok":
|
||||
data = result.get("data", {})
|
||||
print(f"✅ NapCat 连接成功")
|
||||
print(f" 机器人 QQ 号: {data.get('user_id')}")
|
||||
print(f" 昵称: {data.get('nickname')}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ NapCat 连接失败: {result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ NapCat 连接异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_agents():
|
||||
"""测试 Agents API 连接"""
|
||||
print("\n" + "=" * 60)
|
||||
print("测试 Agents API 连接...")
|
||||
print(f"Host: {AGENTS_HOST}")
|
||||
print(f"Email: {AGENTS_EMAIL}")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
async with WebAPIClient() as client:
|
||||
print(f"✅ Agents API 连接成功")
|
||||
print(f" 已登录为: {AGENTS_EMAIL}")
|
||||
|
||||
# 测试创建对话
|
||||
result = await client.create_conversation()
|
||||
if result.get("success"):
|
||||
conv_id = result.get("conversation_id")
|
||||
print(f"✅ 创建对话成功: {conv_id}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 创建对话失败: {result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Agents API 连接异常: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
"""主测试函数"""
|
||||
print("\n🤖 QQ Bot 配置测试\n")
|
||||
|
||||
napcat_ok = await test_napcat()
|
||||
agents_ok = await test_agents()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试结果:")
|
||||
print(f" NapCat: {'✅ 通过' if napcat_ok else '❌ 失败'}")
|
||||
print(f" Agents: {'✅ 通过' if agents_ok else '❌ 失败'}")
|
||||
print(f" 目标群号: {TARGET_GROUP_ID}")
|
||||
print("=" * 60)
|
||||
|
||||
if napcat_ok and agents_ok:
|
||||
print("\n✅ 所有测试通过,可以启动 QQ Bot")
|
||||
return 0
|
||||
else:
|
||||
print("\n❌ 部分测试失败,请检查配置")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
151
scripts/qq_bot/web_api_client.py
Normal file
151
scripts/qq_bot/web_api_client.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""Agents Web API 客户端"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .config import AGENTS_HOST, AGENTS_EMAIL, AGENTS_PASSWORD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebAPIClient:
|
||||
"""Agents Web API 客户端(基于 Session + CSRF)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = AGENTS_HOST,
|
||||
email: str = AGENTS_EMAIL,
|
||||
password: str = AGENTS_PASSWORD,
|
||||
):
|
||||
self.host = host.rstrip("/")
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.csrf_token: Optional[str] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
# 创建 session 并启用 cookie jar
|
||||
self.session = aiohttp.ClientSession(
|
||||
cookie_jar=aiohttp.CookieJar(unsafe=True)
|
||||
)
|
||||
await self.login()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def _get_csrf_token(self) -> str:
|
||||
"""获取 CSRF Token"""
|
||||
url = f"{self.host}/api/csrf-token"
|
||||
async with self.session.get(url) as resp:
|
||||
data = await resp.json()
|
||||
if not data.get("success"):
|
||||
raise RuntimeError("获取 CSRF Token 失败")
|
||||
return data["token"]
|
||||
|
||||
async def login(self):
|
||||
"""登录到 Agents 系统"""
|
||||
# 获取 CSRF Token
|
||||
self.csrf_token = await self._get_csrf_token()
|
||||
logger.info("已获取 CSRF Token")
|
||||
|
||||
# 登录
|
||||
url = f"{self.host}/login"
|
||||
headers = {"X-CSRF-Token": self.csrf_token}
|
||||
data = {"email": self.email, "password": self.password}
|
||||
|
||||
async with self.session.post(url, json=data, headers=headers) as resp:
|
||||
result = await resp.json()
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(f"登录失败: {result.get('error')}")
|
||||
logger.info(f"已登录为用户: {self.email}")
|
||||
|
||||
# 刷新 CSRF Token
|
||||
self.csrf_token = await self._get_csrf_token()
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""发送请求到 Web API"""
|
||||
if not self.session:
|
||||
raise RuntimeError("Client not initialized")
|
||||
|
||||
url = f"{self.host}{endpoint}"
|
||||
headers = {}
|
||||
if method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"}:
|
||||
headers["X-CSRF-Token"] = self.csrf_token
|
||||
|
||||
try:
|
||||
async with self.session.request(
|
||||
method, url, json=data, params=params, headers=headers
|
||||
) as resp:
|
||||
if resp.status == 401:
|
||||
# Session 过期,重新登录
|
||||
logger.warning("Session 过期,重新登录")
|
||||
await self.login()
|
||||
# 重试请求
|
||||
headers["X-CSRF-Token"] = self.csrf_token
|
||||
async with self.session.request(
|
||||
method, url, json=data, params=params, headers=headers
|
||||
) as retry_resp:
|
||||
return await retry_resp.json()
|
||||
return await resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Web API 请求失败: {e}")
|
||||
raise
|
||||
|
||||
async def create_conversation(
|
||||
self, thinking_mode: bool = False, mode: str = "fast"
|
||||
) -> Dict[str, Any]:
|
||||
"""创建新对话"""
|
||||
data = {
|
||||
"preserve_mode": True,
|
||||
"mode": mode,
|
||||
"thinking_mode": thinking_mode,
|
||||
}
|
||||
return await self._request("POST", "/api/conversations", data)
|
||||
|
||||
async def list_conversations(self, limit: int = 20, offset: int = 0) -> Dict[str, Any]:
|
||||
"""获取对话列表"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
return await self._request("GET", "/api/conversations", params=params)
|
||||
|
||||
async def load_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
||||
"""加载/切换对话"""
|
||||
return await self._request("PUT", f"/api/conversations/{conversation_id}/load")
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: str,
|
||||
max_iterations: int = 100,
|
||||
) -> Dict[str, Any]:
|
||||
"""发送消息(创建任务)"""
|
||||
data = {
|
||||
"message": message,
|
||||
"conversation_id": conversation_id,
|
||||
"images": [],
|
||||
"videos": [],
|
||||
"model_key": None,
|
||||
"thinking_mode": None,
|
||||
"run_mode": None,
|
||||
"max_iterations": max_iterations,
|
||||
}
|
||||
return await self._request("POST", "/api/tasks", data)
|
||||
|
||||
async def poll_task(self, task_id: str, from_offset: int = 0) -> Dict[str, Any]:
|
||||
"""轮询任务事件"""
|
||||
params = {"from": from_offset}
|
||||
return await self._request("GET", f"/api/tasks/{task_id}", params=params)
|
||||
|
||||
async def cancel_task(self, task_id: str) -> Dict[str, Any]:
|
||||
"""取消任务"""
|
||||
return await self._request("POST", f"/api/tasks/{task_id}/cancel")
|
||||
@ -1 +0,0 @@
|
||||
"""子智能体独立服务与工具集合。"""
|
||||
@ -1,33 +0,0 @@
|
||||
"""Config package initializer,保持对旧 `from config import ...` 的兼容。"""
|
||||
|
||||
from . import api as _api
|
||||
from . import paths as _paths
|
||||
from . import limits as _limits
|
||||
from . import terminal as _terminal
|
||||
from . import conversation as _conversation
|
||||
from . import security as _security
|
||||
from . import ui as _ui
|
||||
from . import memory as _memory
|
||||
from . import ocr as _ocr
|
||||
from . import todo as _todo
|
||||
from . import auth as _auth
|
||||
from . import sub_agent as _sub_agent
|
||||
|
||||
from .api import *
|
||||
from .paths import *
|
||||
from .limits import *
|
||||
from .terminal import *
|
||||
from .conversation import *
|
||||
from .security import *
|
||||
from .ui import *
|
||||
from .memory import *
|
||||
from .ocr import *
|
||||
from .todo import *
|
||||
from .auth import *
|
||||
from .sub_agent import *
|
||||
|
||||
__all__ = []
|
||||
for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _sub_agent):
|
||||
__all__ += getattr(module, "__all__", [])
|
||||
|
||||
del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _sub_agent
|
||||
@ -1,25 +0,0 @@
|
||||
"""API 和外部服务配置。"""
|
||||
|
||||
API_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
API_KEY = "3e96a682-919d-45c1-acb2-53bc4e9660d3"
|
||||
MODEL_ID = "kimi-k2-250905"
|
||||
|
||||
# Tavily 搜索
|
||||
TAVILY_API_KEY = "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"
|
||||
|
||||
# 默认响应 token 限制
|
||||
DEFAULT_RESPONSE_MAX_TOKENS = 32768
|
||||
|
||||
__all__ = [
|
||||
"API_BASE_URL",
|
||||
"API_KEY",
|
||||
"MODEL_ID",
|
||||
"TAVILY_API_KEY",
|
||||
"DEFAULT_RESPONSE_MAX_TOKENS",
|
||||
]
|
||||
|
||||
'''
|
||||
API_BASE_URL = "https://api.moonshot.cn/v1",
|
||||
API_KEY = "sk-xW0xjfQM6Mp9ZCWMLlnHiRJcpEOIZPTkXcN0dQ15xpZSuw2y",
|
||||
MODEL_ID = "kimi-k2-0905-preview"
|
||||
'''
|
||||
@ -1,9 +0,0 @@
|
||||
"""认证与后台账户配置。"""
|
||||
|
||||
ADMIN_USERNAME = "jojo"
|
||||
ADMIN_PASSWORD_HASH = "pbkdf2:sha256:600000$FSNAVncPXW6CBtfj$b7f093f4256de9d1a16d588565d4b1e108a9c66b2901884dd118c515258d78c7"
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_USERNAME",
|
||||
"ADMIN_PASSWORD_HASH",
|
||||
]
|
||||
@ -1,52 +0,0 @@
|
||||
"""对话持久化与索引配置。"""
|
||||
|
||||
from .paths import DATA_DIR
|
||||
|
||||
CONVERSATION_HISTORY_FILE = f"{DATA_DIR}/conversation_history.json"
|
||||
CONVERSATIONS_DIR = f"{DATA_DIR}/conversations"
|
||||
CONVERSATION_INDEX_FILE = "index.json"
|
||||
CONVERSATION_FILE_PREFIX = "conv_"
|
||||
|
||||
DEFAULT_CONVERSATIONS_LIMIT = 20
|
||||
MAX_CONVERSATIONS_LIMIT = 100
|
||||
CONVERSATION_TITLE_MAX_LENGTH = 100
|
||||
CONVERSATION_SEARCH_MAX_RESULTS = 50
|
||||
|
||||
CONVERSATION_AUTO_CLEANUP_ENABLED = False
|
||||
CONVERSATION_RETENTION_DAYS = 30
|
||||
CONVERSATION_MAX_TOTAL = 1000
|
||||
|
||||
CONVERSATION_BACKUP_ENABLED = True
|
||||
CONVERSATION_BACKUP_INTERVAL_HOURS = 24
|
||||
CONVERSATION_BACKUP_MAX_COUNT = 7
|
||||
|
||||
CONVERSATION_MAX_MESSAGE_SIZE = 50000
|
||||
CONVERSATION_MAX_MESSAGES_PER_CONVERSATION = 10000
|
||||
CONVERSATION_EXPORT_MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
CONVERSATION_LAZY_LOADING = True
|
||||
CONVERSATION_CACHE_SIZE = 50
|
||||
CONVERSATION_INDEX_UPDATE_BATCH_SIZE = 100
|
||||
|
||||
__all__ = [
|
||||
"CONVERSATION_HISTORY_FILE",
|
||||
"CONVERSATIONS_DIR",
|
||||
"CONVERSATION_INDEX_FILE",
|
||||
"CONVERSATION_FILE_PREFIX",
|
||||
"DEFAULT_CONVERSATIONS_LIMIT",
|
||||
"MAX_CONVERSATIONS_LIMIT",
|
||||
"CONVERSATION_TITLE_MAX_LENGTH",
|
||||
"CONVERSATION_SEARCH_MAX_RESULTS",
|
||||
"CONVERSATION_AUTO_CLEANUP_ENABLED",
|
||||
"CONVERSATION_RETENTION_DAYS",
|
||||
"CONVERSATION_MAX_TOTAL",
|
||||
"CONVERSATION_BACKUP_ENABLED",
|
||||
"CONVERSATION_BACKUP_INTERVAL_HOURS",
|
||||
"CONVERSATION_BACKUP_MAX_COUNT",
|
||||
"CONVERSATION_MAX_MESSAGE_SIZE",
|
||||
"CONVERSATION_MAX_MESSAGES_PER_CONVERSATION",
|
||||
"CONVERSATION_EXPORT_MAX_SIZE",
|
||||
"CONVERSATION_LAZY_LOADING",
|
||||
"CONVERSATION_CACHE_SIZE",
|
||||
"CONVERSATION_INDEX_UPDATE_BATCH_SIZE",
|
||||
]
|
||||
@ -1,64 +0,0 @@
|
||||
"""全局额度与工具限制配置。"""
|
||||
|
||||
# 上下文与文件
|
||||
MAX_CONTEXT_SIZE = 100000
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
MAX_OPEN_FILES = 20
|
||||
MAX_UPLOAD_SIZE = 50 * 1024 * 1024
|
||||
|
||||
# 执行超时
|
||||
CODE_EXECUTION_TIMEOUT = 60
|
||||
TERMINAL_COMMAND_TIMEOUT = 30
|
||||
SEARCH_MAX_RESULTS = 10
|
||||
|
||||
# 自动修复与工具调用限制
|
||||
AUTO_FIX_TOOL_CALL = False
|
||||
AUTO_FIX_MAX_ATTEMPTS = 3
|
||||
MAX_ITERATIONS_PER_TASK = 100
|
||||
MAX_CONSECUTIVE_SAME_TOOL = 50
|
||||
MAX_TOTAL_TOOL_CALLS = 100
|
||||
TOOL_CALL_COOLDOWN = 0.5
|
||||
|
||||
# 工具字符/体积限制
|
||||
MAX_READ_FILE_CHARS = 30000
|
||||
MAX_FOCUS_FILE_CHARS = 30000
|
||||
MAX_RUN_COMMAND_CHARS = 10000
|
||||
MAX_EXTRACT_WEBPAGE_CHARS = 80000
|
||||
|
||||
# read_file 子配置
|
||||
READ_TOOL_MAX_FILE_SIZE = 100 * 1024 * 1024
|
||||
READ_TOOL_DEFAULT_MAX_CHARS = MAX_READ_FILE_CHARS
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE = 1
|
||||
READ_TOOL_DEFAULT_CONTEXT_AFTER = 1
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE = 3
|
||||
READ_TOOL_MAX_CONTEXT_AFTER = 5
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES = 5
|
||||
READ_TOOL_MAX_MATCHES = 50
|
||||
|
||||
__all__ = [
|
||||
"MAX_CONTEXT_SIZE",
|
||||
"MAX_FILE_SIZE",
|
||||
"MAX_OPEN_FILES",
|
||||
"MAX_UPLOAD_SIZE",
|
||||
"CODE_EXECUTION_TIMEOUT",
|
||||
"TERMINAL_COMMAND_TIMEOUT",
|
||||
"SEARCH_MAX_RESULTS",
|
||||
"AUTO_FIX_TOOL_CALL",
|
||||
"AUTO_FIX_MAX_ATTEMPTS",
|
||||
"MAX_ITERATIONS_PER_TASK",
|
||||
"MAX_CONSECUTIVE_SAME_TOOL",
|
||||
"MAX_TOTAL_TOOL_CALLS",
|
||||
"TOOL_CALL_COOLDOWN",
|
||||
"MAX_READ_FILE_CHARS",
|
||||
"MAX_FOCUS_FILE_CHARS",
|
||||
"MAX_RUN_COMMAND_CHARS",
|
||||
"MAX_EXTRACT_WEBPAGE_CHARS",
|
||||
"READ_TOOL_MAX_FILE_SIZE",
|
||||
"READ_TOOL_DEFAULT_MAX_CHARS",
|
||||
"READ_TOOL_DEFAULT_CONTEXT_BEFORE",
|
||||
"READ_TOOL_DEFAULT_CONTEXT_AFTER",
|
||||
"READ_TOOL_MAX_CONTEXT_BEFORE",
|
||||
"READ_TOOL_MAX_CONTEXT_AFTER",
|
||||
"READ_TOOL_DEFAULT_MAX_MATCHES",
|
||||
"READ_TOOL_MAX_MATCHES",
|
||||
]
|
||||
@ -1,11 +0,0 @@
|
||||
"""记忆文件配置。"""
|
||||
|
||||
from .paths import DATA_DIR
|
||||
|
||||
MAIN_MEMORY_FILE = f"{DATA_DIR}/memory.md"
|
||||
TASK_MEMORY_FILE = f"{DATA_DIR}/task_memory.md"
|
||||
|
||||
__all__ = [
|
||||
"MAIN_MEMORY_FILE",
|
||||
"TASK_MEMORY_FILE",
|
||||
]
|
||||
@ -1,13 +0,0 @@
|
||||
"""OCR 配置:DeepSeek-OCR 接口信息(子智能体)。"""
|
||||
|
||||
OCR_API_BASE_URL = "https://api.siliconflow.cn"
|
||||
OCR_API_KEY = "sk-suqqgewtlwajjkylvnotdhkzmsrshmrqptkakdqjmlrilaes"
|
||||
OCR_MODEL_ID = "deepseek-ai/DeepSeek-OCR"
|
||||
OCR_MAX_TOKENS = 4096
|
||||
|
||||
__all__ = [
|
||||
"OCR_API_BASE_URL",
|
||||
"OCR_API_KEY",
|
||||
"OCR_MODEL_ID",
|
||||
"OCR_MAX_TOKENS",
|
||||
]
|
||||
@ -1,21 +0,0 @@
|
||||
"""项目路径与目录配置。"""
|
||||
|
||||
DEFAULT_PROJECT_PATH = "./project"
|
||||
PROMPTS_DIR = "./prompts"
|
||||
DATA_DIR = "./data"
|
||||
LOGS_DIR = "./logs"
|
||||
|
||||
# 多用户空间
|
||||
USER_SPACE_DIR = "./users"
|
||||
USERS_DB_FILE = f"{DATA_DIR}/users.json"
|
||||
INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROJECT_PATH",
|
||||
"PROMPTS_DIR",
|
||||
"DATA_DIR",
|
||||
"LOGS_DIR",
|
||||
"USER_SPACE_DIR",
|
||||
"USERS_DB_FILE",
|
||||
"INVITE_CODES_FILE",
|
||||
]
|
||||
@ -1,48 +0,0 @@
|
||||
"""安全与确认策略配置。"""
|
||||
|
||||
FORBIDDEN_COMMANDS = [
|
||||
"rm -rf /",
|
||||
"rm -rf ~",
|
||||
"format",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"kill -9",
|
||||
"dd if=",
|
||||
]
|
||||
|
||||
FORBIDDEN_PATHS = [
|
||||
"/System",
|
||||
"/usr",
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/etc",
|
||||
"/var",
|
||||
"/tmp",
|
||||
"/Applications",
|
||||
"/Library",
|
||||
"C:\\Windows",
|
||||
"C:\\Program Files",
|
||||
"C:\\Program Files (x86)",
|
||||
"C:\\ProgramData",
|
||||
]
|
||||
|
||||
FORBIDDEN_ROOT_PATHS = [
|
||||
"/",
|
||||
"C:\\",
|
||||
"~",
|
||||
]
|
||||
|
||||
NEED_CONFIRMATION = [
|
||||
"delete_file",
|
||||
"delete_folder",
|
||||
"clear_file",
|
||||
"execute_terminal",
|
||||
"batch_delete",
|
||||
]
|
||||
|
||||
__all__ = [
|
||||
"FORBIDDEN_COMMANDS",
|
||||
"FORBIDDEN_PATHS",
|
||||
"FORBIDDEN_ROOT_PATHS",
|
||||
"NEED_CONFIRMATION",
|
||||
]
|
||||
@ -1,30 +0,0 @@
|
||||
"""子智能体服务专用配置。"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_PORT = int(os.environ.get("SUB_AGENT_SERVICE_PORT", "8092"))
|
||||
TASKS_ROOT = Path(os.environ.get("SUB_AGENT_TASKS_ROOT", BASE_DIR / "tasks")).resolve()
|
||||
LOGS_DIR = Path(os.environ.get("SUB_AGENT_LOGS_DIR", BASE_DIR / "logs")).resolve()
|
||||
DATA_ROOT = Path(os.environ.get("SUB_AGENT_DATA_ROOT", BASE_DIR / "data")).resolve()
|
||||
MAX_ACTIVE_AGENTS = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5"))
|
||||
MAX_REFERENCE_FILES = int(os.environ.get("SUB_AGENT_MAX_REFERENCE_FILES", "10"))
|
||||
DEFAULT_TIMEOUT_SECONDS = int(os.environ.get("SUB_AGENT_TIMEOUT", "180"))
|
||||
STATUS_POLL_INTERVAL = float(os.environ.get("SUB_AGENT_STATUS_POLL_INTERVAL", "2.0"))
|
||||
|
||||
TASKS_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DATA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
__all__ = [
|
||||
"BASE_DIR",
|
||||
"DEFAULT_PORT",
|
||||
"TASKS_ROOT",
|
||||
"LOGS_DIR",
|
||||
"DATA_ROOT",
|
||||
"MAX_ACTIVE_AGENTS",
|
||||
"MAX_REFERENCE_FILES",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"STATUS_POLL_INTERVAL",
|
||||
]
|
||||
@ -1,24 +0,0 @@
|
||||
"""子智能体相关配置。"""
|
||||
|
||||
import os
|
||||
|
||||
# 子智能体服务
|
||||
SUB_AGENT_SERVICE_BASE_URL = os.environ.get("SUB_AGENT_SERVICE_URL", "http://127.0.0.1:8092")
|
||||
SUB_AGENT_DEFAULT_TIMEOUT = int(os.environ.get("SUB_AGENT_DEFAULT_TIMEOUT", "180")) # 秒
|
||||
SUB_AGENT_STATUS_POLL_INTERVAL = float(os.environ.get("SUB_AGENT_STATUS_POLL_INTERVAL", "2.0"))
|
||||
|
||||
# 存储与并发限制
|
||||
SUB_AGENT_TASKS_BASE_DIR = os.environ.get("SUB_AGENT_TASKS_BASE_DIR", "./sub_agent/tasks")
|
||||
SUB_AGENT_PROJECT_RESULTS_DIR = os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", "./project/sub_agent_results")
|
||||
SUB_AGENT_STATE_FILE = os.environ.get("SUB_AGENT_STATE_FILE", "./data/sub_agents.json")
|
||||
SUB_AGENT_MAX_ACTIVE = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5"))
|
||||
|
||||
__all__ = [
|
||||
"SUB_AGENT_SERVICE_BASE_URL",
|
||||
"SUB_AGENT_DEFAULT_TIMEOUT",
|
||||
"SUB_AGENT_STATUS_POLL_INTERVAL",
|
||||
"SUB_AGENT_TASKS_BASE_DIR",
|
||||
"SUB_AGENT_PROJECT_RESULTS_DIR",
|
||||
"SUB_AGENT_STATE_FILE",
|
||||
"SUB_AGENT_MAX_ACTIVE",
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
"""终端与会话管理配置。"""
|
||||
|
||||
MAX_TERMINALS = 3
|
||||
TERMINAL_BUFFER_SIZE = 100000
|
||||
TERMINAL_DISPLAY_SIZE = 50000
|
||||
TERMINAL_TIMEOUT = 300
|
||||
TERMINAL_OUTPUT_WAIT = 5
|
||||
TERMINAL_SNAPSHOT_DEFAULT_LINES = 50
|
||||
TERMINAL_SNAPSHOT_MAX_LINES = 200
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS = 60000
|
||||
TERMINAL_INPUT_MAX_CHARS = 20000
|
||||
|
||||
__all__ = [
|
||||
"MAX_TERMINALS",
|
||||
"TERMINAL_BUFFER_SIZE",
|
||||
"TERMINAL_DISPLAY_SIZE",
|
||||
"TERMINAL_TIMEOUT",
|
||||
"TERMINAL_OUTPUT_WAIT",
|
||||
"TERMINAL_SNAPSHOT_DEFAULT_LINES",
|
||||
"TERMINAL_SNAPSHOT_MAX_LINES",
|
||||
"TERMINAL_SNAPSHOT_MAX_CHARS",
|
||||
"TERMINAL_INPUT_MAX_CHARS",
|
||||
]
|
||||
@ -1,11 +0,0 @@
|
||||
"""待办事项工具配置。"""
|
||||
|
||||
TODO_MAX_TASKS = 4
|
||||
TODO_MAX_OVERVIEW_LENGTH = 999
|
||||
TODO_MAX_TASK_LENGTH = 999
|
||||
|
||||
__all__ = [
|
||||
"TODO_MAX_TASKS",
|
||||
"TODO_MAX_OVERVIEW_LENGTH",
|
||||
"TODO_MAX_TASK_LENGTH",
|
||||
]
|
||||
@ -1,29 +0,0 @@
|
||||
"""界面展示与日志配置。"""
|
||||
|
||||
OUTPUT_FORMATS = {
|
||||
"thinking": "💭 [思考]",
|
||||
"action": "🔧 [执行]",
|
||||
"file": "📁 [文件]",
|
||||
"search": "🔍 [搜索]",
|
||||
"code": "💻 [代码]",
|
||||
"terminal": "⚡ [终端]",
|
||||
"memory": "📝 [记忆]",
|
||||
"success": "✅ [成功]",
|
||||
"error": "❌ [错误]",
|
||||
"warning": "⚠️ [警告]",
|
||||
"confirm": "❓ [确认]",
|
||||
"info": "ℹ️ [信息]",
|
||||
"session": "📺 [会话]",
|
||||
}
|
||||
|
||||
AGENT_VERSION = "v5.5"
|
||||
|
||||
LOG_LEVEL = "INFO"
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
__all__ = [
|
||||
"OUTPUT_FORMATS",
|
||||
"AGENT_VERSION",
|
||||
"LOG_LEVEL",
|
||||
"LOG_FORMAT",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,129 +0,0 @@
|
||||
"""子智能体专用终端,实现工具白名单与完成工具。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
from core.web_terminal import WebTerminal
|
||||
from config import PROMPTS_DIR
|
||||
|
||||
FORBIDDEN_SUB_AGENT_TOOLS = {"update_memory", "todo_create", "todo_update_task", "todo_finish", "todo_finish_confirm"}
|
||||
|
||||
|
||||
class SubAgentTerminal(WebTerminal):
|
||||
"""子智能体 Web 终端,限制工具并提供 finish_sub_agent。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace_dir: str,
|
||||
data_dir: str,
|
||||
metadata: Dict,
|
||||
message_callback=None,
|
||||
):
|
||||
super().__init__(
|
||||
project_path=workspace_dir,
|
||||
thinking_mode=True,
|
||||
message_callback=message_callback,
|
||||
data_dir=data_dir,
|
||||
)
|
||||
self.sub_agent_meta = metadata
|
||||
self.finish_callback = None
|
||||
self._system_prompt_cache: Optional[str] = None
|
||||
|
||||
def set_finish_callback(self, callback):
|
||||
self.finish_callback = callback
|
||||
|
||||
def load_prompt(self, name: str) -> str:
|
||||
if name != "main_system":
|
||||
return super().load_prompt(name)
|
||||
|
||||
if self._system_prompt_cache:
|
||||
return self._system_prompt_cache
|
||||
|
||||
template_path = Path(PROMPTS_DIR) / "sub_agent_system.txt"
|
||||
if not template_path.exists():
|
||||
return super().load_prompt(name)
|
||||
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
data = {
|
||||
"summary": self.sub_agent_meta.get("summary", ""),
|
||||
"task": self.sub_agent_meta.get("task", ""),
|
||||
"workspace": self.sub_agent_meta.get("workspace_dir", ""),
|
||||
"references": self.sub_agent_meta.get("references_dir", ""),
|
||||
"deliverables": self.sub_agent_meta.get("deliverables_dir", ""),
|
||||
"target_project_dir": self.sub_agent_meta.get("target_project_dir", ""),
|
||||
"agent_id": self.sub_agent_meta.get("agent_id", ""),
|
||||
"task_id": self.sub_agent_meta.get("task_id", ""),
|
||||
}
|
||||
self._system_prompt_cache = template.format(**data)
|
||||
return self._system_prompt_cache
|
||||
|
||||
def define_tools(self) -> List[Dict]:
|
||||
tools = super().define_tools()
|
||||
filtered: List[Dict] = []
|
||||
for tool in tools:
|
||||
name = tool.get("function", {}).get("name")
|
||||
if name in FORBIDDEN_SUB_AGENT_TOOLS:
|
||||
continue
|
||||
filtered.append(tool)
|
||||
|
||||
filtered.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "finish_sub_agent",
|
||||
"description": (
|
||||
"当你确定交付成果已准备完毕时调用此工具。调用前请确认 deliverables 文件夹存在 result.md,"
|
||||
"其中包含交付说明。参数 reason 用于向主智能体总结本轮完成情况。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "向主智能体说明任务完成情况、交付内容、下一步建议。"
|
||||
}
|
||||
},
|
||||
"required": ["reason"]
|
||||
}
|
||||
}
|
||||
})
|
||||
return filtered
|
||||
|
||||
async def handle_tool_call(self, tool_name: str, arguments: Dict) -> str:
|
||||
if tool_name == "finish_sub_agent":
|
||||
result = self._finalize_sub_agent(arguments or {})
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
return await super().handle_tool_call(tool_name, arguments)
|
||||
|
||||
def _finalize_sub_agent(self, arguments: Dict) -> Dict:
|
||||
deliverables_dir = Path(self.sub_agent_meta.get("deliverables_dir", self.project_path))
|
||||
result_md = deliverables_dir / "result.md"
|
||||
if not result_md.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "deliverables 目录缺少 result.md,无法结束任务。"
|
||||
}
|
||||
content = result_md.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "result.md 为空,请写入任务总结与交付说明后再结束任务。"
|
||||
}
|
||||
reason = (arguments.get("reason") or "").strip()
|
||||
if not reason:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "缺少 reason 字段,请说明完成情况。"
|
||||
}
|
||||
result = {
|
||||
"success": True,
|
||||
"message": "子智能体任务已标记为完成。",
|
||||
"reason": reason,
|
||||
}
|
||||
if self.finish_callback:
|
||||
try:
|
||||
self.finish_callback(result)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
@ -1,77 +0,0 @@
|
||||
"""工具类别配置。
|
||||
|
||||
提供前端和终端公用的工具分组定义,方便按类别控制启用状态。
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class ToolCategory:
|
||||
"""工具类别的结构化定义。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str,
|
||||
tools: List[str],
|
||||
default_enabled: bool = True,
|
||||
silent_when_disabled: bool = False,
|
||||
):
|
||||
self.label = label
|
||||
self.tools = tools
|
||||
self.default_enabled = default_enabled
|
||||
self.silent_when_disabled = silent_when_disabled
|
||||
|
||||
|
||||
TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
||||
"network": ToolCategory(
|
||||
label="网络检索",
|
||||
tools=["web_search", "extract_webpage", "save_webpage"],
|
||||
),
|
||||
"file_edit": ToolCategory(
|
||||
label="文件编辑",
|
||||
tools=[
|
||||
"create_file",
|
||||
"append_to_file",
|
||||
"modify_file",
|
||||
"delete_file",
|
||||
"rename_file",
|
||||
"create_folder",
|
||||
],
|
||||
),
|
||||
"read_focus": ToolCategory(
|
||||
label="阅读聚焦",
|
||||
tools=["read_file", "focus_file", "unfocus_file", "ocr_image", "vlm_analyze", "view_image"],
|
||||
),
|
||||
"terminal_realtime": ToolCategory(
|
||||
label="实时终端",
|
||||
tools=[
|
||||
"terminal_session",
|
||||
"terminal_input",
|
||||
"terminal_snapshot",
|
||||
"terminal_reset",
|
||||
"sleep",
|
||||
],
|
||||
),
|
||||
"terminal_command": ToolCategory(
|
||||
label="终端指令",
|
||||
tools=["run_command", "run_python"],
|
||||
),
|
||||
"memory": ToolCategory(
|
||||
label="记忆",
|
||||
tools=["update_memory"],
|
||||
),
|
||||
"todo": ToolCategory(
|
||||
label="待办事项",
|
||||
tools=["todo_create", "todo_update_task", "todo_finish", "todo_finish_confirm"],
|
||||
),
|
||||
"sub_agent": ToolCategory(
|
||||
label="子智能体",
|
||||
tools=["create_sub_agent", "wait_sub_agent", "close_sub_agent"],
|
||||
),
|
||||
"easter_egg": ToolCategory(
|
||||
label="彩蛋实验",
|
||||
tools=["trigger_easter_egg"],
|
||||
default_enabled=False,
|
||||
silent_when_disabled=True,
|
||||
),
|
||||
}
|
||||
@ -1,610 +0,0 @@
|
||||
# core/web_terminal.py - Web终端(集成对话持久化)
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Optional, Callable
|
||||
import os
|
||||
from core.main_terminal import MainTerminal
|
||||
from utils.logger import setup_logger
|
||||
try:
|
||||
from config import MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE
|
||||
from modules.terminal_manager import TerminalManager
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
class WebTerminal(MainTerminal):
|
||||
"""Web版本的终端,继承自MainTerminal,包含对话持久化功能"""
|
||||
|
||||
def _ensure_conversation(self):
|
||||
"""确保Web端在首次进入时自动加载或创建对话"""
|
||||
if self.context_manager.current_conversation_id:
|
||||
return
|
||||
|
||||
latest_list = self.context_manager.get_conversation_list(limit=1, offset=0)
|
||||
conversations = latest_list.get("conversations", []) if latest_list else []
|
||||
|
||||
if conversations:
|
||||
latest = conversations[0]
|
||||
conv_id = latest.get("id")
|
||||
if conv_id and self.context_manager.load_conversation_by_id(conv_id):
|
||||
print(f"[WebTerminal] 已加载最近对话: {conv_id}")
|
||||
return
|
||||
|
||||
conversation_id = self.context_manager.start_new_conversation(
|
||||
project_path=self.project_path,
|
||||
thinking_mode=self.thinking_mode
|
||||
)
|
||||
print(f"[WebTerminal] 自动创建新对话: {conversation_id}")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: str,
|
||||
thinking_mode: bool = False,
|
||||
message_callback: Optional[Callable] = None,
|
||||
data_dir: Optional[str] = None
|
||||
):
|
||||
# 调用父类初始化(包含对话持久化功能)
|
||||
super().__init__(project_path, thinking_mode, data_dir=data_dir)
|
||||
|
||||
# Web特有属性
|
||||
self.message_callback = message_callback
|
||||
self.web_mode = True
|
||||
|
||||
# 默认允许输出,便于排查(若需静默可设置环境变量 WEB_API_SILENT=1)
|
||||
self.api_client.web_mode = bool(os.environ.get("WEB_API_SILENT"))
|
||||
|
||||
# 重新初始化终端管理器
|
||||
self.terminal_manager = TerminalManager(
|
||||
project_path=project_path,
|
||||
max_terminals=MAX_TERMINALS,
|
||||
terminal_buffer_size=TERMINAL_BUFFER_SIZE,
|
||||
terminal_display_size=TERMINAL_DISPLAY_SIZE,
|
||||
broadcast_callback=message_callback
|
||||
)
|
||||
|
||||
print(f"[WebTerminal] 初始化完成,项目路径: {project_path}")
|
||||
print(f"[WebTerminal] 思考模式: {'开启' if thinking_mode else '关闭'}")
|
||||
print(f"[WebTerminal] 对话管理已就绪")
|
||||
|
||||
# 设置token更新回调
|
||||
if message_callback is not None:
|
||||
self.context_manager._web_terminal_callback = message_callback
|
||||
self.context_manager._focused_files = self.focused_files
|
||||
print(f"[WebTerminal] 实时token统计已启用")
|
||||
else:
|
||||
print(f"[WebTerminal] 警告:message_callback为None,无法启用实时token统计")
|
||||
# ===========================================
|
||||
# 新增:对话管理相关方法(Web版本)
|
||||
# ===========================================
|
||||
|
||||
def create_new_conversation(self, thinking_mode: bool = None) -> Dict:
|
||||
"""
|
||||
创建新对话(Web版本)
|
||||
|
||||
Args:
|
||||
thinking_mode: 思考模式,None则使用当前设置
|
||||
|
||||
Returns:
|
||||
Dict: 包含新对话信息
|
||||
"""
|
||||
if thinking_mode is None:
|
||||
thinking_mode = self.thinking_mode
|
||||
|
||||
try:
|
||||
conversation_id = self.context_manager.start_new_conversation(
|
||||
project_path=self.project_path,
|
||||
thinking_mode=thinking_mode
|
||||
)
|
||||
|
||||
# 重置相关状态
|
||||
if self.thinking_mode:
|
||||
self.api_client.start_new_task()
|
||||
|
||||
self.current_session_id += 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"conversation_id": conversation_id,
|
||||
"message": f"已创建新对话: {conversation_id}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"创建新对话失败: {e}"
|
||||
}
|
||||
|
||||
def load_conversation(self, conversation_id: str) -> Dict:
|
||||
"""
|
||||
加载指定对话(Web版本)
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
Dict: 加载结果
|
||||
"""
|
||||
try:
|
||||
success = self.context_manager.load_conversation_by_id(conversation_id)
|
||||
if success:
|
||||
# 重置相关状态
|
||||
if self.thinking_mode:
|
||||
self.api_client.start_new_task()
|
||||
|
||||
self.current_session_id += 1
|
||||
|
||||
# 获取对话信息
|
||||
conversation_data = self.context_manager.conversation_manager.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "对话数据缺失",
|
||||
"message": f"对话数据缺失: {conversation_id}"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"conversation_id": conversation_id,
|
||||
"title": conversation_data.get("title", "未知对话"),
|
||||
"messages_count": len(self.context_manager.conversation_history),
|
||||
"message": f"对话已加载: {conversation_id}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "对话不存在或加载失败",
|
||||
"message": f"对话加载失败: {conversation_id}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"加载对话异常: {e}"
|
||||
}
|
||||
|
||||
def get_conversations_list(self, limit: int = 20, offset: int = 0) -> Dict:
|
||||
"""获取对话列表(Web版本)"""
|
||||
try:
|
||||
result = self.context_manager.get_conversation_list(limit=limit, offset=offset)
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"获取对话列表失败: {e}"
|
||||
}
|
||||
|
||||
def delete_conversation(self, conversation_id: str) -> Dict:
|
||||
"""删除指定对话(Web版本)"""
|
||||
try:
|
||||
success = self.context_manager.delete_conversation_by_id(conversation_id)
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"对话已删除: {conversation_id}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "删除失败",
|
||||
"message": f"对话删除失败: {conversation_id}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"删除对话异常: {e}"
|
||||
}
|
||||
|
||||
def search_conversations(self, query: str, limit: int = 20) -> Dict:
|
||||
"""搜索对话(Web版本)"""
|
||||
try:
|
||||
results = self.context_manager.search_conversations(query, limit)
|
||||
return {
|
||||
"success": True,
|
||||
"results": results,
|
||||
"count": len(results)
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"搜索对话失败: {e}"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 修改现有方法,保持兼容性
|
||||
# ===========================================
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""获取系统状态(Web版本,集成对话信息)"""
|
||||
# 获取基础状态
|
||||
context_status = self.context_manager.check_context_size()
|
||||
memory_stats = self.memory_manager.get_memory_stats()
|
||||
structure = self.context_manager.get_project_structure()
|
||||
|
||||
# 聚焦文件状态 - 使用与 /api/focused 相同的格式(字典格式)
|
||||
focused_files_dict = {}
|
||||
for path, content in self.focused_files.items():
|
||||
focused_files_dict[path] = {
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
"lines": content.count('\n') + 1
|
||||
}
|
||||
|
||||
# 终端状态
|
||||
terminal_status = None
|
||||
if self.terminal_manager:
|
||||
terminal_status = self.terminal_manager.list_terminals()
|
||||
|
||||
# 新增:对话状态
|
||||
conversation_stats = self.context_manager.get_conversation_statistics()
|
||||
|
||||
# 构建状态信息
|
||||
status = {
|
||||
"project_path": self.project_path,
|
||||
"thinking_mode": self.thinking_mode,
|
||||
"thinking_status": self.get_thinking_mode_status(),
|
||||
"context": {
|
||||
"usage_percent": context_status['usage_percent'],
|
||||
"total_size": context_status['sizes']['total'],
|
||||
"conversation_count": len(self.context_manager.conversation_history)
|
||||
},
|
||||
"focused_files": focused_files_dict, # 使用字典格式,与 /api/focused 一致
|
||||
"focused_files_count": len(self.focused_files), # 单独提供计数
|
||||
"terminals": terminal_status,
|
||||
"project": {
|
||||
"total_files": structure['total_files'],
|
||||
"total_size": structure['total_size']
|
||||
},
|
||||
"memory": {
|
||||
"main": memory_stats['main_memory']['lines'],
|
||||
"task": memory_stats['task_memory']['lines']
|
||||
},
|
||||
# 新增:对话状态
|
||||
"conversation": {
|
||||
"current_id": self.context_manager.current_conversation_id,
|
||||
"total_conversations": conversation_stats.get('total_conversations', 0),
|
||||
"total_messages": conversation_stats.get('total_messages', 0),
|
||||
"total_tools": conversation_stats.get('total_tools', 0)
|
||||
}
|
||||
}
|
||||
status["todo_list"] = self.context_manager.get_todo_snapshot()
|
||||
|
||||
return status
|
||||
|
||||
def get_thinking_mode_status(self) -> str:
|
||||
"""获取思考模式状态描述"""
|
||||
if not self.thinking_mode:
|
||||
return "快速模式"
|
||||
else:
|
||||
if self.api_client.current_task_first_call:
|
||||
return "思考模式(等待新任务)"
|
||||
else:
|
||||
return "思考模式(任务进行中)"
|
||||
|
||||
def get_focused_files_info(self) -> Dict:
|
||||
"""获取聚焦文件信息(用于WebSocket更新)- 使用与 /api/focused 一致的格式"""
|
||||
focused_files_dict = {}
|
||||
for path, content in self.focused_files.items():
|
||||
focused_files_dict[path] = {
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
"lines": content.count('\n') + 1
|
||||
}
|
||||
|
||||
return focused_files_dict
|
||||
|
||||
def broadcast(self, event_type: str, data: Dict):
|
||||
"""广播事件到WebSocket"""
|
||||
if self.message_callback:
|
||||
payload = dict(data or {})
|
||||
payload.setdefault('conversation_id', self.context_manager.current_conversation_id)
|
||||
self.message_callback(event_type, payload)
|
||||
|
||||
# ===========================================
|
||||
# 覆盖父类方法,添加Web特有的广播功能
|
||||
# ===========================================
|
||||
|
||||
async def handle_tool_call(self, tool_name: str, arguments: Dict) -> str:
|
||||
"""
|
||||
处理工具调用(Web版本)
|
||||
覆盖父类方法,添加增强的实时广播功能
|
||||
"""
|
||||
# 立即广播工具执行开始事件(不等待)
|
||||
self.broadcast('tool_execution_start', {
|
||||
'tool': tool_name,
|
||||
'arguments': arguments,
|
||||
'status': 'executing',
|
||||
'message': f'正在执行 {tool_name}...'
|
||||
})
|
||||
|
||||
# 对于某些工具,发送更详细的状态
|
||||
if tool_name == "create_file":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'creating',
|
||||
'detail': f'创建文件: {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "read_file":
|
||||
read_type = arguments.get("type", "read")
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'reading',
|
||||
'detail': f'读取文件({read_type}): {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "modify_file":
|
||||
path = arguments.get("path", "未知路径")
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'modifying',
|
||||
'detail': f'准备修改文件: {path}'
|
||||
})
|
||||
elif tool_name == "delete_file":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'deleting',
|
||||
'detail': f'删除文件: {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "focus_file":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'focusing',
|
||||
'detail': f'聚焦文件: {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "unfocus_file":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'unfocusing',
|
||||
'detail': f'取消聚焦: {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "web_search":
|
||||
query = arguments.get("query", "")
|
||||
filters = []
|
||||
topic = arguments.get("topic")
|
||||
if topic:
|
||||
filters.append(f"topic={topic}")
|
||||
else:
|
||||
filters.append("topic=general")
|
||||
if arguments.get("time_range"):
|
||||
filters.append(f"time_range={arguments['time_range']}")
|
||||
if arguments.get("days") is not None:
|
||||
filters.append(f"days={arguments.get('days')}")
|
||||
if arguments.get("start_date") and arguments.get("end_date"):
|
||||
filters.append(f"{arguments.get('start_date')}~{arguments.get('end_date')}")
|
||||
if arguments.get("country"):
|
||||
filters.append(f"country={arguments.get('country')}")
|
||||
filter_text = " | ".join(filter_item for filter_item in filters if filter_item)
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'searching',
|
||||
'detail': f'搜索: {query}' + (f' ({filter_text})' if filter_text else '')
|
||||
})
|
||||
elif tool_name == "extract_webpage":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'extracting',
|
||||
'detail': f'提取网页: {arguments.get("url", "")}'
|
||||
})
|
||||
elif tool_name == "save_webpage":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'saving_webpage',
|
||||
'detail': f'保存网页: {arguments.get("url", "")}'
|
||||
})
|
||||
elif tool_name == "run_python":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'running_code',
|
||||
'detail': '执行Python代码'
|
||||
})
|
||||
elif tool_name == "run_command":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'running_command',
|
||||
'detail': f'执行命令: {arguments.get("command", "")}'
|
||||
})
|
||||
elif tool_name == "terminal_session":
|
||||
action = arguments.get("action", "")
|
||||
session_name = arguments.get("session_name", "default")
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': f'terminal_{action}',
|
||||
'detail': f'终端操作: {action} - {session_name}'
|
||||
})
|
||||
elif tool_name == "terminal_input":
|
||||
command = arguments.get("command", "")
|
||||
# 只显示命令的前50个字符避免过长
|
||||
display_command = command[:50] + "..." if len(command) > 50 else command
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'sending_input',
|
||||
'detail': f'发送终端输入: {display_command}'
|
||||
})
|
||||
elif tool_name == "sleep":
|
||||
seconds = arguments.get("seconds", 1)
|
||||
reason = arguments.get("reason", "等待操作完成")
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'waiting',
|
||||
'detail': f'等待 {seconds} 秒: {reason}'
|
||||
})
|
||||
|
||||
# 调用父类的工具处理(包含我们的新逻辑)
|
||||
result = await super().handle_tool_call(tool_name, arguments)
|
||||
logger.debug(
|
||||
"[SubAgent][WebTerminal] tool=%s 执行完成,result前200=%s",
|
||||
tool_name,
|
||||
result[:200] if isinstance(result, str) else result,
|
||||
)
|
||||
|
||||
# 解析结果并广播工具结束事件
|
||||
try:
|
||||
result_data = json.loads(result)
|
||||
success = result_data.get('success', False)
|
||||
|
||||
# 特殊处理某些错误类型
|
||||
if not success:
|
||||
error_msg = result_data.get('error', '执行失败')
|
||||
|
||||
# 检查是否是参数预检查失败
|
||||
if '参数过大' in error_msg or '内容过长' in error_msg:
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': False,
|
||||
'result': result_data,
|
||||
'message': f'{tool_name} 执行失败: 参数过长',
|
||||
'error_type': 'parameter_too_long',
|
||||
'suggestion': result_data.get('suggestion', '建议分块处理')
|
||||
})
|
||||
elif 'JSON解析' in error_msg or '参数解析失败' in error_msg:
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': False,
|
||||
'result': result_data,
|
||||
'message': f'{tool_name} 执行失败: 参数格式错误',
|
||||
'error_type': 'parameter_format_error',
|
||||
'suggestion': result_data.get('suggestion', '请检查参数格式')
|
||||
})
|
||||
else:
|
||||
# 一般错误
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': False,
|
||||
'result': result_data,
|
||||
'message': f'{tool_name} 执行失败: {error_msg}',
|
||||
'error_type': 'general_error'
|
||||
})
|
||||
else:
|
||||
# 成功的情况
|
||||
success_msg = result_data.get('message', f'{tool_name} 执行成功')
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': True,
|
||||
'result': result_data,
|
||||
'message': success_msg
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# 无法解析JSON结果
|
||||
success = False
|
||||
result_data = {'output': result, 'raw_result': True}
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': False,
|
||||
'result': result_data,
|
||||
'message': f'{tool_name} 返回了非JSON格式结果',
|
||||
'error_type': 'invalid_result_format'
|
||||
})
|
||||
|
||||
# 如果是终端相关操作,广播终端更新
|
||||
if tool_name in ['terminal_session', 'terminal_input'] and self.terminal_manager:
|
||||
try:
|
||||
terminals = self.terminal_manager.get_terminal_list()
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': terminals,
|
||||
'active': self.terminal_manager.active_terminal
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"广播终端更新失败: {e}")
|
||||
|
||||
# 如果是文件操作,广播文件树更新
|
||||
if tool_name in ['create_file', 'delete_file', 'rename_file', 'create_folder', 'save_webpage']:
|
||||
try:
|
||||
structure = self.context_manager.get_project_structure()
|
||||
self.broadcast('file_tree_update', structure)
|
||||
except Exception as e:
|
||||
logger.error(f"广播文件树更新失败: {e}")
|
||||
|
||||
|
||||
# 如果是聚焦操作,广播聚焦文件更新
|
||||
if tool_name in ['focus_file', 'unfocus_file', 'modify_file']:
|
||||
try:
|
||||
focused_files_dict = self.get_focused_files_info()
|
||||
self.broadcast('focused_files_update', focused_files_dict)
|
||||
|
||||
# 聚焦文件变化后,更新token统计
|
||||
self.context_manager.safe_broadcast_token_update()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"广播聚焦文件更新失败: {e}")
|
||||
|
||||
# 如果是记忆操作,广播记忆状态更新
|
||||
if tool_name == 'update_memory':
|
||||
try:
|
||||
memory_stats = self.memory_manager.get_memory_stats()
|
||||
self.broadcast('memory_update', {
|
||||
'main': memory_stats['main_memory']['lines'],
|
||||
'task': memory_stats['task_memory']['lines']
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"广播记忆更新失败: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def build_context(self) -> Dict:
|
||||
"""构建上下文(Web版本)"""
|
||||
context = super().build_context()
|
||||
|
||||
# 添加Web特有的上下文信息
|
||||
context['web_mode'] = True
|
||||
context['terminal_sessions'] = []
|
||||
|
||||
if self.terminal_manager:
|
||||
for name, terminal in self.terminal_manager.terminals.items():
|
||||
context['terminal_sessions'].append({
|
||||
'name': name,
|
||||
'is_active': name == self.terminal_manager.active_terminal,
|
||||
'is_running': terminal.is_running
|
||||
})
|
||||
|
||||
# 添加对话信息
|
||||
context['conversation_info'] = {
|
||||
'current_id': self.context_manager.current_conversation_id,
|
||||
'messages_count': len(self.context_manager.conversation_history)
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
async def confirm_action(self, action: str, arguments: Dict) -> bool:
|
||||
"""
|
||||
确认危险操作(Web版本)
|
||||
在Web模式下,我们自动确认或通过WebSocket请求确认
|
||||
"""
|
||||
# 在Web模式下,暂时自动确认
|
||||
# 未来可以通过WebSocket向前端请求确认
|
||||
print(f"[WebTerminal] 自动确认操作: {action}")
|
||||
|
||||
# 广播确认事件,让前端知道正在执行危险操作
|
||||
self.broadcast('dangerous_action', {
|
||||
'action': action,
|
||||
'arguments': arguments,
|
||||
'auto_confirmed': True
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保资源释放"""
|
||||
try:
|
||||
# 保存当前对话
|
||||
if hasattr(self, 'context_manager') and self.context_manager:
|
||||
if self.context_manager.current_conversation_id:
|
||||
self.context_manager.save_current_conversation()
|
||||
|
||||
# 关闭所有终端
|
||||
if hasattr(self, 'terminal_manager') and self.terminal_manager:
|
||||
self.terminal_manager.close_all()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WebTerminal] 资源清理失败: {e}")
|
||||
@ -1,274 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# main.py - 主程序入口(修复路径引号和中文支持问题)
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from config import *
|
||||
from core.main_terminal import MainTerminal
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
class AgentSystem:
|
||||
def __init__(self):
|
||||
self.project_path = None
|
||||
self.thinking_mode = False # False=快速模式, True=思考模式
|
||||
self.web_mode = False # Web模式标志
|
||||
self.main_terminal = None
|
||||
|
||||
async def initialize(self):
|
||||
"""初始化系统"""
|
||||
print("\n" + "="*50)
|
||||
print("ð¤ AI Agent 系统启动")
|
||||
print("="*50)
|
||||
|
||||
# 1. 获取项目路径
|
||||
await self.setup_project_path()
|
||||
|
||||
# 2. 选择运行模式(CLI或Web)
|
||||
await self.setup_run_mode()
|
||||
|
||||
if not self.web_mode:
|
||||
# CLI模式:继续原有流程
|
||||
# 3. 选择思考模式
|
||||
await self.setup_thinking_mode()
|
||||
|
||||
# 4. 初始化系统
|
||||
await self.init_system()
|
||||
|
||||
# 5. 创建主终端
|
||||
self.main_terminal = MainTerminal(
|
||||
project_path=self.project_path,
|
||||
thinking_mode=self.thinking_mode
|
||||
)
|
||||
|
||||
print(f"\n{OUTPUT_FORMATS['success']} 系统初始化完成")
|
||||
print(f"{OUTPUT_FORMATS['info']} 项目路径: {self.project_path}")
|
||||
print(f"{OUTPUT_FORMATS['info']} 运行模式: {'思考模式(智能)' if self.thinking_mode else '快速模式(无思考)'}")
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("输入 'exit' 退出,'help' 查看帮助,'/clear' 清除对话")
|
||||
print("="*50 + "\n")
|
||||
else:
|
||||
# Web模式:启动Web服务器
|
||||
# 3. 选择思考模式
|
||||
await self.setup_thinking_mode()
|
||||
|
||||
# 4. 初始化系统
|
||||
await self.init_system()
|
||||
|
||||
# 5. 启动Web服务器
|
||||
await self.start_web_server()
|
||||
|
||||
def clean_path_input(self, path_str: str) -> str:
|
||||
"""清理路径输入,去除引号和多余空格"""
|
||||
if not path_str:
|
||||
return path_str
|
||||
|
||||
# 保存原始输入用于调试
|
||||
original = path_str
|
||||
|
||||
# 去除首尾空格
|
||||
path_str = path_str.strip()
|
||||
|
||||
# 去除各种引号(包括中文引号)
|
||||
quote_pairs = [
|
||||
('"', '"'), # 英文双引号
|
||||
("'", "'"), # 英文单引号
|
||||
('"', '"'), # 中文双引号
|
||||
(''', '''), # 中文单引号
|
||||
('`', '`'), # 反引号
|
||||
('「', '」'), # 日文引号
|
||||
('『', '』'), # 日文引号
|
||||
]
|
||||
|
||||
for start_quote, end_quote in quote_pairs:
|
||||
if path_str.startswith(start_quote) and path_str.endswith(end_quote):
|
||||
path_str = path_str[len(start_quote):-len(end_quote)]
|
||||
break
|
||||
|
||||
# 处理只有一边引号的情况
|
||||
single_quotes = ['"', "'", '"', '"', ''', ''', '`', '「', '」', '『', '』']
|
||||
for quote in single_quotes:
|
||||
if path_str.startswith(quote):
|
||||
path_str = path_str[len(quote):]
|
||||
if path_str.endswith(quote):
|
||||
path_str = path_str[:-len(quote)]
|
||||
|
||||
# 再次去除空格
|
||||
path_str = path_str.strip()
|
||||
|
||||
# 调试输出
|
||||
if path_str != original.strip():
|
||||
print(f"{OUTPUT_FORMATS['info']} 路径已清理: {original.strip()} -> {path_str}")
|
||||
|
||||
return path_str
|
||||
|
||||
async def setup_project_path(self):
|
||||
"""设置项目路径"""
|
||||
path_input = os.path.expanduser(str(DEFAULT_PROJECT_PATH))
|
||||
project_path = Path(path_input).resolve()
|
||||
|
||||
if self.is_unsafe_path(str(project_path)):
|
||||
raise RuntimeError(f"默认项目路径不安全: {project_path}")
|
||||
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not os.access(project_path, os.R_OK | os.W_OK):
|
||||
print(f"{OUTPUT_FORMATS['warning']} 对默认项目路径缺少读写权限: {project_path}")
|
||||
|
||||
self.project_path = str(project_path)
|
||||
print(f"{OUTPUT_FORMATS['success']} 已选择项目路径: {self.project_path}")
|
||||
|
||||
async def setup_run_mode(self):
|
||||
"""选择运行模式"""
|
||||
self.web_mode = True
|
||||
print(f"{OUTPUT_FORMATS['info']} 运行模式: Web(默认)")
|
||||
|
||||
async def setup_thinking_mode(self):
|
||||
"""选择思考模式"""
|
||||
self.thinking_mode = True
|
||||
print(f"{OUTPUT_FORMATS['info']} 思考模式: {'开启' if self.thinking_mode else '关闭'}(默认)")
|
||||
|
||||
async def init_system(self):
|
||||
"""初始化系统文件"""
|
||||
# 确保数据目录存在
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||
os.makedirs(f"{LOGS_DIR}/tasks", exist_ok=True)
|
||||
os.makedirs(f"{LOGS_DIR}/errors", exist_ok=True)
|
||||
|
||||
# 初始化记忆文件
|
||||
if not os.path.exists(MAIN_MEMORY_FILE):
|
||||
with open(MAIN_MEMORY_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(f"# 主记忆文件\n\n创建时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
||||
|
||||
if not os.path.exists(TASK_MEMORY_FILE):
|
||||
with open(TASK_MEMORY_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(f"# 任务记忆文件\n\n")
|
||||
|
||||
# 初始化或修复对话历史
|
||||
conversation_file = Path(CONVERSATION_HISTORY_FILE)
|
||||
if conversation_file.exists():
|
||||
try:
|
||||
with open(conversation_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if content.strip():
|
||||
json.loads(content)
|
||||
else:
|
||||
raise json.JSONDecodeError("Empty file", "", 0)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
print(f"{OUTPUT_FORMATS['warning']} 修复对话历史文件...")
|
||||
with open(conversation_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({"conversations": []}, f, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
with open(conversation_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({"conversations": []}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
async def start_web_server(self):
|
||||
"""启动Web服务器"""
|
||||
try:
|
||||
# 检查是否安装了必要的包
|
||||
import flask
|
||||
import flask_socketio
|
||||
import flask_cors
|
||||
except ImportError:
|
||||
print(f"{OUTPUT_FORMATS['error']} 缺少Web依赖包,请安装:")
|
||||
print("pip install flask flask-socketio flask-cors")
|
||||
sys.exit(1)
|
||||
|
||||
# 导入Web服务器
|
||||
from web_server import run_server
|
||||
|
||||
print(f"\n{OUTPUT_FORMATS['success']} 正在启动Web服务器...")
|
||||
print(f"{OUTPUT_FORMATS['info']} 项目路径: {self.project_path}")
|
||||
port = int(os.environ.get("WEB_SERVER_PORT", "8091"))
|
||||
|
||||
# 运行服务器(这会阻塞)
|
||||
run_server(
|
||||
path=self.project_path,
|
||||
thinking_mode=self.thinking_mode,
|
||||
port=port
|
||||
)
|
||||
|
||||
def is_unsafe_path(self, path: str) -> bool:
|
||||
"""检查路径是否安全"""
|
||||
resolved_path = str(Path(path).resolve())
|
||||
|
||||
# 检查是否是根路径
|
||||
for forbidden_root in FORBIDDEN_ROOT_PATHS:
|
||||
expanded = os.path.expanduser(forbidden_root)
|
||||
if resolved_path == expanded or resolved_path == forbidden_root:
|
||||
return True
|
||||
|
||||
# 检查是否在系统目录
|
||||
for forbidden in FORBIDDEN_PATHS:
|
||||
if resolved_path.startswith(forbidden + os.sep) or resolved_path == forbidden:
|
||||
return True
|
||||
|
||||
# 检查是否包含向上遍历
|
||||
if ".." in path:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""运行主循环"""
|
||||
await self.initialize()
|
||||
|
||||
if not self.web_mode:
|
||||
# CLI模式
|
||||
try:
|
||||
await self.main_terminal.run()
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{OUTPUT_FORMATS['info']} 收到中断信号")
|
||||
except Exception as e:
|
||||
logger.error(f"系统错误: {e}", exc_info=True)
|
||||
print(f"{OUTPUT_FORMATS['error']} 系统错误: {e}")
|
||||
finally:
|
||||
await self.cleanup()
|
||||
# Web模式在start_web_server中运行,不会到达这里
|
||||
|
||||
async def cleanup(self):
|
||||
"""清理资源"""
|
||||
print(f"\n{OUTPUT_FORMATS['info']} 正在保存状态...")
|
||||
|
||||
if self.main_terminal:
|
||||
await self.main_terminal.save_state()
|
||||
|
||||
print(f"{OUTPUT_FORMATS['success']} 系统已安全退出")
|
||||
print("\nð 再见!\n")
|
||||
|
||||
async def main():
|
||||
"""主函数"""
|
||||
system = AgentSystem()
|
||||
await system.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
# 设置控制台编码为UTF-8(Windows中文路径支持)
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
import locale
|
||||
# 尝试设置为UTF-8
|
||||
try:
|
||||
os.system("chcp 65001 > nul") # 设置控制台代码页为UTF-8
|
||||
except:
|
||||
pass
|
||||
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nð 再见!")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\n{OUTPUT_FORMATS['error']} 程序异常退出: {e}")
|
||||
sys.exit(1)
|
||||
@ -1,865 +0,0 @@
|
||||
# modules/file_manager.py - 文件管理模块(添加行编辑功能)
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from datetime import datetime
|
||||
try:
|
||||
from config import (
|
||||
MAX_FILE_SIZE,
|
||||
FORBIDDEN_PATHS,
|
||||
FORBIDDEN_ROOT_PATHS,
|
||||
OUTPUT_FORMATS,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
)
|
||||
except ImportError: # 兼容全局环境中存在同名包的情况
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
MAX_FILE_SIZE,
|
||||
FORBIDDEN_PATHS,
|
||||
FORBIDDEN_ROOT_PATHS,
|
||||
OUTPUT_FORMATS,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
)
|
||||
# 临时禁用长度检查
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
class FileManager:
|
||||
def __init__(self, project_path: str):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
|
||||
def _validate_path(self, path: str) -> Tuple[bool, str, Path]:
|
||||
"""
|
||||
验证路径安全性
|
||||
|
||||
Returns:
|
||||
(是否有效, 错误信息, 完整路径)
|
||||
"""
|
||||
project_root = Path(self.project_path).resolve()
|
||||
if project_root != self.project_path:
|
||||
self.project_path = project_root
|
||||
|
||||
# 不允许绝对路径(除非是在项目内的绝对路径)
|
||||
if path.startswith('/') or path.startswith('\\') or (len(path) > 1 and path[1] == ':'):
|
||||
# 如果是绝对路径,检查是否指向项目内
|
||||
try:
|
||||
test_path = Path(path).resolve()
|
||||
test_path.relative_to(project_root)
|
||||
# 如果成功,说明绝对路径在项目内,转换为相对路径
|
||||
path = str(test_path.relative_to(project_root))
|
||||
except ValueError:
|
||||
return False, "路径必须在项目文件夹内", None
|
||||
|
||||
# 检查是否包含向上遍历
|
||||
if ".." in path:
|
||||
return False, "不允许使用../向上遍历", None
|
||||
|
||||
# 构建完整路径
|
||||
full_path = (project_root / path).resolve()
|
||||
|
||||
# 检查是否在项目目录内
|
||||
try:
|
||||
full_path.relative_to(project_root)
|
||||
except ValueError:
|
||||
return False, "路径必须在项目文件夹内", None
|
||||
|
||||
# 检查禁止的路径
|
||||
path_str = str(full_path)
|
||||
|
||||
for forbidden_root in FORBIDDEN_ROOT_PATHS:
|
||||
if path_str == forbidden_root:
|
||||
return False, f"禁止访问根目录: {forbidden_root}", None
|
||||
|
||||
for forbidden in FORBIDDEN_PATHS:
|
||||
if path_str.startswith(forbidden + os.sep) or path_str == forbidden:
|
||||
return False, f"禁止访问系统目录: {forbidden}", None
|
||||
|
||||
return True, "", full_path
|
||||
|
||||
def create_file(self, path: str, content: str = "", file_type: str = "txt") -> Dict:
|
||||
"""创建文件"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
# 添加文件扩展名
|
||||
if not full_path.suffix:
|
||||
full_path = full_path.with_suffix(f".{file_type}")
|
||||
|
||||
try:
|
||||
if full_path.parent == self.project_path:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "禁止在项目根目录直接创建文件,请先创建或选择子目录。",
|
||||
"suggestion": "创建文件所属文件夹,在其中创建新文件。"
|
||||
}
|
||||
# 创建父目录
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 固定创建空文件,忽略传入内容
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
f.write("")
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
print(f"{OUTPUT_FORMATS['file']} 创建文件: {relative_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"size": 0
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_file(self, path: str) -> Dict:
|
||||
"""删除文件"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
try:
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
full_path.unlink()
|
||||
print(f"{OUTPUT_FORMATS['file']} 删除文件: {relative_path}")
|
||||
|
||||
# 删除文件备注(如果存在)
|
||||
# 这需要通过context_manager处理,但file_manager没有直接访问权限
|
||||
# 所以返回相对路径,让调用者处理备注删除
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"action": "deleted"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def rename_file(self, old_path: str, new_path: str) -> Dict:
|
||||
"""重命名文件"""
|
||||
valid_old, error_old, full_old_path = self._validate_path(old_path)
|
||||
if not valid_old:
|
||||
return {"success": False, "error": error_old}
|
||||
|
||||
valid_new, error_new, full_new_path = self._validate_path(new_path)
|
||||
if not valid_new:
|
||||
return {"success": False, "error": error_new}
|
||||
|
||||
if not full_old_path.exists():
|
||||
return {"success": False, "error": "原文件不存在"}
|
||||
|
||||
if full_new_path.exists():
|
||||
return {"success": False, "error": "目标文件已存在"}
|
||||
|
||||
try:
|
||||
full_old_path.rename(full_new_path)
|
||||
|
||||
old_relative = str(full_old_path.relative_to(self.project_path))
|
||||
new_relative = str(full_new_path.relative_to(self.project_path))
|
||||
print(f"{OUTPUT_FORMATS['file']} 重命名: {old_relative} -> {new_relative}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"old_path": old_relative,
|
||||
"new_path": new_relative,
|
||||
"action": "renamed"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def create_folder(self, path: str) -> Dict:
|
||||
"""创建文件夹"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if full_path.exists():
|
||||
return {"success": False, "error": "文件夹已存在"}
|
||||
|
||||
try:
|
||||
full_path.mkdir(parents=True, exist_ok=True)
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
print(f"{OUTPUT_FORMATS['file']} 创建文件夹: {relative_path}")
|
||||
|
||||
return {"success": True, "path": relative_path}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_folder(self, path: str) -> Dict:
|
||||
"""删除文件夹"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件夹不存在"}
|
||||
|
||||
if not full_path.is_dir():
|
||||
return {"success": False, "error": "不是文件夹"}
|
||||
|
||||
try:
|
||||
shutil.rmtree(full_path)
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
print(f"{OUTPUT_FORMATS['file']} 删除文件夹: {relative_path}")
|
||||
|
||||
return {"success": True, "path": relative_path}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _read_text_lines(
|
||||
self,
|
||||
full_path: Path,
|
||||
*,
|
||||
size_limit: Optional[int] = None,
|
||||
encoding: str = "utf-8",
|
||||
) -> Dict:
|
||||
"""读取UTF-8文本并返回行列表。"""
|
||||
try:
|
||||
file_size = full_path.stat().st_size
|
||||
except FileNotFoundError:
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if size_limit and file_size > size_limit:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"文件太大 ({file_size / 1024 / 1024:.2f}MB > {size_limit / 1024 / 1024}MB)"
|
||||
}
|
||||
|
||||
try:
|
||||
with open(full_path, 'r', encoding=encoding) as f:
|
||||
lines = f.readlines()
|
||||
except UnicodeDecodeError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "文件不是 UTF-8 文本,无法直接读取,请改用 run_python 解析。"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"读取文件失败: {e}"}
|
||||
|
||||
content = "".join(lines)
|
||||
return {
|
||||
"success": True,
|
||||
"content": content,
|
||||
"lines": lines,
|
||||
"size": file_size
|
||||
}
|
||||
|
||||
def read_file(self, path: str) -> Dict:
|
||||
"""读取文件内容(兼容旧逻辑,限制为 MAX_FILE_SIZE)。"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
result = self._read_text_lines(full_path, size_limit=MAX_FILE_SIZE)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"content": result["content"],
|
||||
"size": result["size"]
|
||||
}
|
||||
|
||||
def read_text_segment(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
size_limit: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""按行范围读取文本片段。"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
result = self._read_text_lines(
|
||||
full_path,
|
||||
size_limit=size_limit or READ_TOOL_MAX_FILE_SIZE
|
||||
)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
lines = result["lines"]
|
||||
total_lines = len(lines)
|
||||
start = start_line if start_line and start_line > 0 else 1
|
||||
end = end_line if end_line and end_line >= start else total_lines
|
||||
if start > total_lines:
|
||||
return {"success": False, "error": "起始行超出文件长度"}
|
||||
end = min(end, total_lines)
|
||||
|
||||
selected_lines = lines[start - 1 : end]
|
||||
content = "".join(selected_lines)
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"content": content,
|
||||
"size": result["size"],
|
||||
"line_start": start,
|
||||
"line_end": end,
|
||||
"total_lines": total_lines
|
||||
}
|
||||
|
||||
def search_text(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
query: str,
|
||||
max_matches: int,
|
||||
context_before: int,
|
||||
context_after: int,
|
||||
case_sensitive: bool = False,
|
||||
size_limit: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""在文件中搜索关键词,返回合并后的窗口。"""
|
||||
if not query:
|
||||
return {"success": False, "error": "缺少搜索关键词"}
|
||||
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
result = self._read_text_lines(
|
||||
full_path,
|
||||
size_limit=size_limit or READ_TOOL_MAX_FILE_SIZE
|
||||
)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
lines = result["lines"]
|
||||
total_lines = len(lines)
|
||||
matches = []
|
||||
query_text = query if case_sensitive else query.lower()
|
||||
|
||||
def contains(haystack: str) -> bool:
|
||||
target = haystack if case_sensitive else haystack.lower()
|
||||
return query_text in target
|
||||
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
if contains(line):
|
||||
window_start = max(1, idx - context_before)
|
||||
window_end = min(total_lines, idx + context_after)
|
||||
|
||||
if matches and window_start <= matches[-1]["line_end"]:
|
||||
matches[-1]["line_end"] = max(matches[-1]["line_end"], window_end)
|
||||
matches[-1]["hits"].append(idx)
|
||||
else:
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
matches.append({
|
||||
"line_start": window_start,
|
||||
"line_end": window_end,
|
||||
"hits": [idx]
|
||||
})
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
for window in matches:
|
||||
snippet_lines = lines[window["line_start"] - 1 : window["line_end"]]
|
||||
window["snippet"] = "".join(snippet_lines)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"size": result["size"],
|
||||
"total_lines": total_lines,
|
||||
"matches": matches
|
||||
}
|
||||
|
||||
def extract_segments(
|
||||
self,
|
||||
path: str,
|
||||
segments: List[Dict],
|
||||
*,
|
||||
size_limit: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""根据多个行区间提取内容。"""
|
||||
if not segments:
|
||||
return {"success": False, "error": "缺少要提取的行区间"}
|
||||
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
result = self._read_text_lines(
|
||||
full_path,
|
||||
size_limit=size_limit or READ_TOOL_MAX_FILE_SIZE
|
||||
)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
lines = result["lines"]
|
||||
total_lines = len(lines)
|
||||
extracted = []
|
||||
|
||||
for item in segments:
|
||||
if not isinstance(item, dict):
|
||||
return {"success": False, "error": "segments 数组中的每一项都必须是对象"}
|
||||
start_line = item.get("start_line")
|
||||
end_line = item.get("end_line")
|
||||
label = item.get("label")
|
||||
if start_line is None or end_line is None:
|
||||
return {"success": False, "error": "所有区间都必须包含 start_line 和 end_line"}
|
||||
if start_line <= 0 or end_line < start_line:
|
||||
return {"success": False, "error": "行区间不合法"}
|
||||
if start_line > total_lines:
|
||||
return {"success": False, "error": f"区间起点 {start_line} 超出文件行数"}
|
||||
end_line = min(end_line, total_lines)
|
||||
snippet = "".join(lines[start_line - 1 : end_line])
|
||||
extracted.append({
|
||||
"label": label,
|
||||
"line_start": start_line,
|
||||
"line_end": end_line,
|
||||
"content": snippet
|
||||
})
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"size": result["size"],
|
||||
"total_lines": total_lines,
|
||||
"segments": extracted
|
||||
}
|
||||
|
||||
def write_file(self, path: str, content: str, mode: str = "w") -> Dict:
|
||||
"""
|
||||
写入文件
|
||||
|
||||
Args:
|
||||
path: 文件路径
|
||||
content: 内容
|
||||
mode: 写入模式 - "w"(覆盖), "a"(追加)
|
||||
"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
# === 新增:内容预处理和验证 ===
|
||||
if content:
|
||||
# 长度检查
|
||||
if not DISABLE_LENGTH_CHECK and len(content) > 9999999999: # 100KB限制
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"内容过长({len(content)}字符),超过100KB限制",
|
||||
"suggestion": "请分块处理或使用部分修改方式"
|
||||
}
|
||||
|
||||
# 检查潜在的JSON格式问题
|
||||
if content.count('"') % 2 != 0:
|
||||
print(f"{OUTPUT_FORMATS['warning']} 检测到奇数个引号,可能存在格式问题")
|
||||
|
||||
# 检查大量转义字符
|
||||
if content.count('\\') > len(content) / 20:
|
||||
print(f"{OUTPUT_FORMATS['warning']} 检测到大量转义字符,建议检查内容格式")
|
||||
|
||||
try:
|
||||
# 创建父目录
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(full_path, mode, encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
action = "覆盖" if mode == "w" else "追加"
|
||||
print(f"{OUTPUT_FORMATS['file']} {action}文件: {relative_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"size": len(content),
|
||||
"mode": mode
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def append_file(self, path: str, content: str) -> Dict:
|
||||
"""追加内容到文件"""
|
||||
return self.write_file(path, content, mode="a")
|
||||
|
||||
def apply_modify_blocks(self, path: str, blocks: List[Dict]) -> Dict:
|
||||
"""
|
||||
应用批量替换块
|
||||
|
||||
Args:
|
||||
path: 目标文件路径
|
||||
blocks: [{"index": int, "old": str, "new": str}]
|
||||
"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
original_content = f.read()
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"读取文件失败: {e}"}
|
||||
|
||||
current_content = original_content
|
||||
results: List[Dict] = []
|
||||
completed_indices: List[int] = []
|
||||
failed_details: List[Dict] = []
|
||||
write_error = None
|
||||
|
||||
for block in blocks:
|
||||
index = block.get("index")
|
||||
old_text = block.get("old", "")
|
||||
new_text = block.get("new", "")
|
||||
|
||||
block_result = {
|
||||
"index": index,
|
||||
"status": "pending",
|
||||
"removed_lines": 0,
|
||||
"added_lines": 0,
|
||||
"reason": None,
|
||||
"hint": None
|
||||
}
|
||||
|
||||
if old_text is None or new_text is None:
|
||||
block_result["status"] = "error"
|
||||
block_result["reason"] = "缺少 OLD 或 NEW 内容"
|
||||
block_result["hint"] = "请确保粘贴的补丁包含成对的 <<<OLD>>> / <<<NEW>>> 标记。"
|
||||
failed_details.append({"index": index, "reason": "缺少 OLD/NEW 标记"})
|
||||
results.append(block_result)
|
||||
continue
|
||||
|
||||
# 统一换行符,避免 CRLF 与 LF 不一致导致匹配失败
|
||||
old_text = old_text.replace('\r\n', '\n')
|
||||
new_text = new_text.replace('\r\n', '\n')
|
||||
|
||||
if not old_text:
|
||||
block_result["status"] = "error"
|
||||
block_result["reason"] = "OLD 内容不能为空"
|
||||
block_result["hint"] = "请确认要替换的原文是否准确复制;若多次失败,可改用 terminal_snapshot 查证或使用终端命令/Python 小脚本进行精确替换。"
|
||||
failed_details.append({"index": index, "reason": "OLD 内容为空"})
|
||||
results.append(block_result)
|
||||
continue
|
||||
|
||||
position = current_content.find(old_text)
|
||||
if position == -1:
|
||||
block_result["status"] = "not_found"
|
||||
block_result["reason"] = "未找到匹配的原文,请确认是否完全复制"
|
||||
block_result["hint"] = "请先用 terminal_snapshot 或 grep -n 校验原文;若仍失败,可在说明后改用 run_command/python 进行局部修改。"
|
||||
failed_details.append({"index": index, "reason": "未找到匹配的原文"})
|
||||
results.append(block_result)
|
||||
continue
|
||||
|
||||
current_content = (
|
||||
current_content[:position] +
|
||||
new_text +
|
||||
current_content[position + len(old_text):]
|
||||
)
|
||||
|
||||
removed_lines = old_text.count('\n')
|
||||
added_lines = new_text.count('\n')
|
||||
if old_text and not old_text.endswith('\n'):
|
||||
removed_lines += 1
|
||||
if new_text and not new_text.endswith('\n'):
|
||||
added_lines += 1
|
||||
|
||||
block_result.update({
|
||||
"status": "success",
|
||||
"removed_lines": removed_lines if old_text else 0,
|
||||
"added_lines": added_lines if new_text else 0
|
||||
})
|
||||
completed_indices.append(index)
|
||||
results.append(block_result)
|
||||
|
||||
write_performed = False
|
||||
if completed_indices:
|
||||
try:
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
f.write(current_content)
|
||||
write_performed = True
|
||||
except Exception as e:
|
||||
write_error = f"写入文件失败: {e}"
|
||||
# 写入失败时恢复原始内容
|
||||
try:
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
f.write(original_content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
success = bool(completed_indices) and not failed_details and write_error is None
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"completed": completed_indices,
|
||||
"failed": failed_details,
|
||||
"results": results,
|
||||
"write_performed": write_performed,
|
||||
"error": write_error
|
||||
}
|
||||
|
||||
def replace_in_file(self, path: str, old_text: str, new_text: str) -> Dict:
|
||||
"""替换文件中的内容"""
|
||||
# 先读取文件
|
||||
result = self.read_file(path)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
content = result["content"]
|
||||
|
||||
# === 新增:替换操作的安全检查 ===
|
||||
if old_text and len(old_text) > 9999999999:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "要替换的文本过长,可能导致性能问题",
|
||||
"suggestion": "请拆分内容或使用 modify_file 提交结构化补丁"
|
||||
}
|
||||
|
||||
if new_text and len(new_text) > 9999999999:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "替换的新文本过长,建议分块处理",
|
||||
"suggestion": "请将大内容分成多个小的替换操作"
|
||||
}
|
||||
|
||||
# 检查是否包含要替换的内容
|
||||
if old_text and old_text not in content:
|
||||
return {"success": False, "error": "未找到要替换的内容"}
|
||||
|
||||
# 替换内容
|
||||
if old_text:
|
||||
new_content = content.replace(old_text, new_text)
|
||||
count = content.count(old_text)
|
||||
else:
|
||||
# 空文件直接写入新内容
|
||||
new_content = new_text
|
||||
count = 1
|
||||
|
||||
# 写回文件
|
||||
result = self.write_file(path, new_content)
|
||||
if result["success"]:
|
||||
result["replacements"] = count
|
||||
print(f"{OUTPUT_FORMATS['file']} 替换了 {count} 处内容")
|
||||
|
||||
return result
|
||||
|
||||
def clear_file(self, path: str) -> Dict:
|
||||
"""清空文件内容"""
|
||||
return self.write_file(path, "", mode="w")
|
||||
|
||||
def edit_lines_range(self, path: str, start_line: int, end_line: int, content: str, operation: str) -> Dict:
|
||||
"""
|
||||
基于行号编辑文件
|
||||
|
||||
Args:
|
||||
path: 文件路径
|
||||
start_line: 起始行号(从1开始)
|
||||
end_line: 结束行号(从1开始,包含)
|
||||
content: 新内容
|
||||
operation: 操作类型 - "replace", "insert", "delete"
|
||||
"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
# 验证行号
|
||||
if start_line < 1:
|
||||
return {"success": False, "error": "行号必须从1开始"}
|
||||
|
||||
if end_line < start_line:
|
||||
return {"success": False, "error": "结束行号不能小于起始行号"}
|
||||
|
||||
try:
|
||||
# 读取文件内容
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
total_lines = len(lines)
|
||||
|
||||
# 检查行号范围
|
||||
if start_line > total_lines:
|
||||
if operation == "insert":
|
||||
# 插入操作允许在文件末尾后插入
|
||||
lines.extend([''] * (start_line - total_lines - 1))
|
||||
lines.append(content if content.endswith('\n') else content + '\n')
|
||||
else:
|
||||
return {"success": False, "error": f"起始行号 {start_line} 超出文件范围 (共 {total_lines} 行)"}
|
||||
elif end_line > total_lines:
|
||||
return {"success": False, "error": f"结束行号 {end_line} 超出文件范围 (共 {total_lines} 行)"}
|
||||
else:
|
||||
# 执行操作(转换为0基索引)
|
||||
start_idx = start_line - 1
|
||||
end_idx = end_line
|
||||
|
||||
if operation == "replace":
|
||||
# 替换指定行范围
|
||||
new_lines = content.split('\n') if '\n' in content else [content]
|
||||
# 确保每行都有换行符,除了最后一行需要检查原文件格式
|
||||
formatted_lines = []
|
||||
for i, line in enumerate(new_lines):
|
||||
if i < len(new_lines) - 1 or (end_idx < len(lines) and lines[end_idx - 1].endswith('\n')):
|
||||
formatted_lines.append(line + '\n' if not line.endswith('\n') else line)
|
||||
else:
|
||||
formatted_lines.append(line)
|
||||
|
||||
lines[start_idx:end_idx] = formatted_lines
|
||||
affected_lines = end_line - start_line + 1
|
||||
|
||||
elif operation == "insert":
|
||||
# 在指定行前插入内容
|
||||
new_lines = content.split('\n') if '\n' in content else [content]
|
||||
formatted_lines = [line + '\n' if not line.endswith('\n') else line for line in new_lines]
|
||||
lines[start_idx:start_idx] = formatted_lines
|
||||
affected_lines = len(formatted_lines)
|
||||
|
||||
elif operation == "delete":
|
||||
# 删除指定行范围
|
||||
affected_lines = end_line - start_line + 1
|
||||
del lines[start_idx:end_idx]
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"未知的操作类型: {operation}"}
|
||||
|
||||
# 写回文件
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
|
||||
# 生成操作描述
|
||||
if operation == "replace":
|
||||
operation_desc = f"替换第 {start_line}-{end_line} 行"
|
||||
elif operation == "insert":
|
||||
operation_desc = f"在第 {start_line} 行前插入"
|
||||
elif operation == "delete":
|
||||
operation_desc = f"删除第 {start_line}-{end_line} 行"
|
||||
|
||||
print(f"{OUTPUT_FORMATS['file']} {operation_desc}: {relative_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"operation": operation,
|
||||
"start_line": start_line,
|
||||
"end_line": end_line,
|
||||
"affected_lines": affected_lines,
|
||||
"total_lines_after": len(lines),
|
||||
"description": operation_desc
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def list_files(self, path: str = "") -> Dict:
|
||||
"""列出目录内容"""
|
||||
if path:
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
else:
|
||||
full_path = self.project_path
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "目录不存在"}
|
||||
|
||||
if not full_path.is_dir():
|
||||
return {"success": False, "error": "不是目录"}
|
||||
|
||||
try:
|
||||
files = []
|
||||
folders = []
|
||||
|
||||
for item in full_path.iterdir():
|
||||
if item.name.startswith('.'):
|
||||
continue
|
||||
|
||||
relative_path = str(item.relative_to(self.project_path))
|
||||
|
||||
if item.is_file():
|
||||
files.append({
|
||||
"name": item.name,
|
||||
"path": relative_path,
|
||||
"size": item.stat().st_size,
|
||||
"modified": datetime.fromtimestamp(item.stat().st_mtime).isoformat()
|
||||
})
|
||||
elif item.is_dir():
|
||||
folders.append({
|
||||
"name": item.name,
|
||||
"path": relative_path
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": str(full_path.relative_to(self.project_path)) if path else ".",
|
||||
"files": sorted(files, key=lambda x: x["name"]),
|
||||
"folders": sorted(folders, key=lambda x: x["name"])
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_file_info(self, path: str) -> Dict:
|
||||
"""获取文件信息"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
try:
|
||||
stat = full_path.stat()
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"name": full_path.name,
|
||||
"type": "file" if full_path.is_file() else "folder",
|
||||
"size": stat.st_size,
|
||||
"created": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
"extension": full_path.suffix if full_path.is_file() else None
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
@ -1,335 +0,0 @@
|
||||
# modules/gui_file_manager.py - GUI 文件管理专用服务
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEntry:
|
||||
"""用于前端显示的文件/目录条目"""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
type: str # file / directory
|
||||
size: int
|
||||
modified_at: float
|
||||
extension: Optional[str]
|
||||
is_editable: bool
|
||||
|
||||
|
||||
class GuiFileManager:
|
||||
"""面向 GUI 的文件管理器,实现桌面式操作所需的能力"""
|
||||
|
||||
EDITABLE_EXTENSIONS = {
|
||||
".txt",
|
||||
".md",
|
||||
".py",
|
||||
".js",
|
||||
".ts",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".html",
|
||||
".css",
|
||||
".scss",
|
||||
".less",
|
||||
".xml",
|
||||
".csv",
|
||||
".ini",
|
||||
".cfg",
|
||||
".toml",
|
||||
".sh",
|
||||
".bat",
|
||||
".java",
|
||||
".kt",
|
||||
".go",
|
||||
".rs",
|
||||
".c",
|
||||
".cpp",
|
||||
".h",
|
||||
".hpp",
|
||||
".vue",
|
||||
".svelte",
|
||||
".php",
|
||||
".rb",
|
||||
".swift",
|
||||
".dart",
|
||||
".sql",
|
||||
}
|
||||
|
||||
MAX_TEXT_FILE_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
def __init__(self, base_path: str):
|
||||
self.base_path = Path(base_path).expanduser().resolve()
|
||||
if not self.base_path.exists():
|
||||
raise ValueError("Base path does not exist")
|
||||
|
||||
# -------------------------
|
||||
# 路径解析与安全检查
|
||||
# -------------------------
|
||||
|
||||
def _resolve(self, relative: Optional[str]) -> Path:
|
||||
relative = (relative or "").strip()
|
||||
target = (self.base_path if not relative else (self.base_path / relative)).resolve()
|
||||
try:
|
||||
target.relative_to(self.base_path)
|
||||
except ValueError:
|
||||
raise ValueError("路径越界")
|
||||
return target
|
||||
|
||||
def _to_relative(self, absolute: Path) -> str:
|
||||
return str(absolute.relative_to(self.base_path)).replace("\\", "/")
|
||||
|
||||
def _unique_name(self, directory: Path, name: str) -> Path:
|
||||
candidate = directory / name
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
|
||||
stem = candidate.stem
|
||||
suffix = candidate.suffix
|
||||
counter = 1
|
||||
while candidate.exists():
|
||||
candidate = directory / f"{stem}_copy{counter if counter > 1 else ''}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
def _check_editable(self, path: Path) -> bool:
|
||||
if not path.is_file():
|
||||
return False
|
||||
if path.stat().st_size > self.MAX_TEXT_FILE_SIZE:
|
||||
return False
|
||||
ext = path.suffix.lower()
|
||||
if ext in self.EDITABLE_EXTENSIONS:
|
||||
return True
|
||||
# 无扩展名的文本文件尝试 UTF-8 读取判断
|
||||
if not ext:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
fh.read(2048)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
# -------------------------
|
||||
# 列表与元数据
|
||||
# -------------------------
|
||||
|
||||
def list_directory(self, relative: Optional[str] = None) -> Tuple[str, List[FileEntry]]:
|
||||
directory = self._resolve(relative)
|
||||
if not directory.exists():
|
||||
raise FileNotFoundError("目录不存在")
|
||||
if not directory.is_dir():
|
||||
raise NotADirectoryError("目标不是目录")
|
||||
|
||||
entries: List[FileEntry] = []
|
||||
for entry in sorted(directory.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
stat = entry.stat()
|
||||
entries.append(
|
||||
FileEntry(
|
||||
name=entry.name,
|
||||
path=self._to_relative(entry),
|
||||
type="directory" if entry.is_dir() else "file",
|
||||
size=stat.st_size,
|
||||
modified_at=stat.st_mtime,
|
||||
extension=entry.suffix.lower() if entry.is_file() else None,
|
||||
is_editable=self._check_editable(entry),
|
||||
)
|
||||
)
|
||||
relative_path = "" if directory == self.base_path else self._to_relative(directory)
|
||||
return relative_path, entries
|
||||
|
||||
def breadcrumb(self, relative: Optional[str]) -> List[Dict[str, str]]:
|
||||
crumbs: List[Dict[str, str]] = []
|
||||
directory = self._resolve(relative)
|
||||
try:
|
||||
directory.relative_to(self.base_path)
|
||||
except ValueError:
|
||||
raise ValueError("路径越界")
|
||||
|
||||
current = directory
|
||||
while True:
|
||||
relative_path = "" if current == self.base_path else self._to_relative(current)
|
||||
crumbs.append({"name": current.name if current != self.base_path else "根目录", "path": relative_path})
|
||||
if current == self.base_path:
|
||||
break
|
||||
current = current.parent
|
||||
crumbs.reverse()
|
||||
return crumbs
|
||||
|
||||
# -------------------------
|
||||
# 基本操作
|
||||
# -------------------------
|
||||
|
||||
def create_entry(self, parent_relative: Optional[str], name: str, entry_type: str) -> str:
|
||||
parent = self._resolve(parent_relative)
|
||||
if not parent.exists():
|
||||
raise FileNotFoundError("父目录不存在")
|
||||
if not parent.is_dir():
|
||||
raise NotADirectoryError("父路径不是目录")
|
||||
|
||||
sanitized = name.strip()
|
||||
if not sanitized:
|
||||
raise ValueError("名称不能为空")
|
||||
|
||||
target = parent / sanitized
|
||||
if target.exists():
|
||||
raise FileExistsError("同名文件或目录已存在")
|
||||
|
||||
if entry_type == "directory":
|
||||
target.mkdir(parents=False, exist_ok=False)
|
||||
elif entry_type == "file":
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.touch()
|
||||
else:
|
||||
raise ValueError("不支持的类型")
|
||||
|
||||
return self._to_relative(target)
|
||||
|
||||
def delete_entries(self, relative_paths: List[str]) -> Dict[str, str]:
|
||||
results: Dict[str, str] = {}
|
||||
for rel in relative_paths:
|
||||
target = self._resolve(rel)
|
||||
if not target.exists():
|
||||
results[rel] = "missing"
|
||||
continue
|
||||
try:
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
results[rel] = "deleted"
|
||||
except Exception as exc:
|
||||
results[rel] = f"error: {exc}"
|
||||
return results
|
||||
|
||||
def rename_entry(self, relative_path: str, new_name: str) -> str:
|
||||
target = self._resolve(relative_path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("目标不存在")
|
||||
|
||||
parent = target.parent
|
||||
sanitized = new_name.strip()
|
||||
if not sanitized:
|
||||
raise ValueError("新名称不能为空")
|
||||
new_path = parent / sanitized
|
||||
if new_path.exists():
|
||||
raise FileExistsError("目标名称已存在")
|
||||
|
||||
target.rename(new_path)
|
||||
return self._to_relative(new_path)
|
||||
|
||||
def copy_entries(self, relative_paths: List[str], destination_relative: str) -> Dict[str, str]:
|
||||
destination = self._resolve(destination_relative)
|
||||
if not destination.exists() or not destination.is_dir():
|
||||
raise NotADirectoryError("目标目录不存在")
|
||||
|
||||
results: Dict[str, str] = {}
|
||||
for rel in relative_paths:
|
||||
source = self._resolve(rel)
|
||||
if not source.exists():
|
||||
results[rel] = "missing"
|
||||
continue
|
||||
try:
|
||||
target = self._unique_name(destination, source.name)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target)
|
||||
else:
|
||||
shutil.copy2(source, target)
|
||||
results[rel] = self._to_relative(target)
|
||||
except Exception as exc:
|
||||
results[rel] = f"error: {exc}"
|
||||
return results
|
||||
|
||||
def move_entries(self, relative_paths: List[str], destination_relative: str) -> Dict[str, str]:
|
||||
destination = self._resolve(destination_relative)
|
||||
if not destination.exists() or not destination.is_dir():
|
||||
raise NotADirectoryError("目标目录不存在")
|
||||
|
||||
results: Dict[str, str] = {}
|
||||
for rel in relative_paths:
|
||||
source = self._resolve(rel)
|
||||
if not source.exists():
|
||||
results[rel] = "missing"
|
||||
continue
|
||||
try:
|
||||
target = destination / source.name
|
||||
if target.exists():
|
||||
target = self._unique_name(destination, source.name)
|
||||
shutil.move(str(source), str(target))
|
||||
results[rel] = self._to_relative(target)
|
||||
except Exception as exc:
|
||||
results[rel] = f"error: {exc}"
|
||||
return results
|
||||
|
||||
# -------------------------
|
||||
# 文本读写
|
||||
# -------------------------
|
||||
|
||||
def read_text(self, relative_path: str) -> Tuple[str, str]:
|
||||
target = self._resolve(relative_path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("文件不存在")
|
||||
if not target.is_file():
|
||||
raise IsADirectoryError("目标是目录")
|
||||
|
||||
size = target.stat().st_size
|
||||
if size > self.MAX_TEXT_FILE_SIZE:
|
||||
raise ValueError("文件过大,暂不支持直接编辑")
|
||||
|
||||
try:
|
||||
with open(target, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(f"文件不是 UTF-8 编码: {exc}") from exc
|
||||
|
||||
return content, datetime.fromtimestamp(target.stat().st_mtime).isoformat()
|
||||
|
||||
def write_text(self, relative_path: str, content: str) -> Dict[str, str]:
|
||||
target = self._resolve(relative_path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("文件不存在")
|
||||
if not target.is_file():
|
||||
raise IsADirectoryError("目标是目录")
|
||||
|
||||
size = len(content.encode("utf-8"))
|
||||
if size > self.MAX_TEXT_FILE_SIZE:
|
||||
raise ValueError("内容过大,超出限制")
|
||||
|
||||
with open(target, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
|
||||
stat = target.stat()
|
||||
return {
|
||||
"path": self._to_relative(target),
|
||||
"size": str(stat.st_size),
|
||||
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
# 上传与下载
|
||||
# -------------------------
|
||||
|
||||
def prepare_upload(self, destination_relative: Optional[str], filename: str) -> Path:
|
||||
destination = self._resolve(destination_relative)
|
||||
if not destination.exists():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
if not destination.is_dir():
|
||||
raise NotADirectoryError("上传目标必须是目录")
|
||||
sanitized = filename.strip()
|
||||
if not sanitized:
|
||||
raise ValueError("文件名不能为空")
|
||||
target = destination / sanitized
|
||||
target = self._unique_name(destination, target.name) if target.exists() else target
|
||||
return target
|
||||
|
||||
def prepare_download(self, relative_path: str) -> Path:
|
||||
target = self._resolve(relative_path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("文件不存在")
|
||||
return target
|
||||
|
||||
@ -1,307 +0,0 @@
|
||||
# modules/memory_manager.py - 记忆管理模块
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
try:
|
||||
from config import MAIN_MEMORY_FILE, TASK_MEMORY_FILE, DATA_DIR, OUTPUT_FORMATS
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import MAIN_MEMORY_FILE, TASK_MEMORY_FILE, DATA_DIR, OUTPUT_FORMATS
|
||||
|
||||
class MemoryManager:
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
|
||||
default_main = Path(MAIN_MEMORY_FILE).name
|
||||
default_task = Path(TASK_MEMORY_FILE).name
|
||||
|
||||
if data_dir:
|
||||
self.main_memory_path = self.data_dir / default_main
|
||||
self.task_memory_path = self.data_dir / default_task
|
||||
else:
|
||||
self.main_memory_path = Path(MAIN_MEMORY_FILE)
|
||||
self.task_memory_path = Path(TASK_MEMORY_FILE)
|
||||
|
||||
self.ensure_files_exist()
|
||||
|
||||
def ensure_files_exist(self):
|
||||
"""确保记忆文件存在"""
|
||||
# 创建数据目录
|
||||
os.makedirs(self.data_dir, exist_ok=True)
|
||||
|
||||
# 创建主记忆文件
|
||||
if not self.main_memory_path.exists():
|
||||
self.create_memory_file(self.main_memory_path, "主记忆文件")
|
||||
|
||||
# 创建任务记忆文件
|
||||
if not self.task_memory_path.exists():
|
||||
self.create_memory_file(self.task_memory_path, "任务记忆文件")
|
||||
|
||||
def create_memory_file(self, path: Path, title: str):
|
||||
"""创建记忆文件"""
|
||||
template = f"""# {title}
|
||||
创建时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
## 项目信息
|
||||
|
||||
|
||||
## 重要记录
|
||||
|
||||
|
||||
## 经验总结
|
||||
|
||||
|
||||
## 待办事项
|
||||
|
||||
"""
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(template)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['memory']} 创建{title}: {path}")
|
||||
|
||||
def read_main_memory(self) -> str:
|
||||
"""读取主记忆"""
|
||||
try:
|
||||
with open(self.main_memory_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 读取主记忆失败: {e}")
|
||||
return ""
|
||||
|
||||
def read_task_memory(self) -> str:
|
||||
"""读取任务记忆"""
|
||||
try:
|
||||
with open(self.task_memory_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 读取任务记忆失败: {e}")
|
||||
return ""
|
||||
|
||||
def write_main_memory(self, content: str) -> bool:
|
||||
"""写入主记忆"""
|
||||
try:
|
||||
with open(self.main_memory_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"{OUTPUT_FORMATS['memory']} 更新主记忆")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 写入主记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def write_task_memory(self, content: str) -> bool:
|
||||
"""写入任务记忆"""
|
||||
try:
|
||||
with open(self.task_memory_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"{OUTPUT_FORMATS['memory']} 更新任务记忆")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 写入任务记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def append_main_memory(self, content: str, section: str = None) -> bool:
|
||||
"""追加内容到主记忆"""
|
||||
try:
|
||||
current = self.read_main_memory()
|
||||
|
||||
# 添加时间戳
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if section:
|
||||
# 追加到特定部分
|
||||
new_entry = f"\n### [{timestamp}] {section}\n{content}\n"
|
||||
if f"## {section}" in current:
|
||||
# 在该部分后添加
|
||||
parts = current.split(f"## {section}")
|
||||
if len(parts) > 1:
|
||||
# 找到下一个##的位置
|
||||
next_section = parts[1].find("\n##")
|
||||
if next_section > 0:
|
||||
parts[1] = parts[1][:next_section] + new_entry + parts[1][next_section:]
|
||||
else:
|
||||
parts[1] = parts[1] + new_entry
|
||||
current = f"## {section}".join(parts)
|
||||
else:
|
||||
current += new_entry
|
||||
else:
|
||||
# 创建新部分
|
||||
current += f"\n## {section}\n{new_entry}"
|
||||
else:
|
||||
# 追加到末尾
|
||||
current += f"\n### [{timestamp}]\n{content}\n"
|
||||
|
||||
return self.write_main_memory(current)
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 追加主记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def append_task_memory(self, content: str, task_id: str = None) -> bool:
|
||||
"""追加内容到任务记忆"""
|
||||
try:
|
||||
current = self.read_task_memory()
|
||||
|
||||
# 添加时间戳
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if task_id:
|
||||
new_entry = f"\n### 任务 {task_id} - {timestamp}\n{content}\n"
|
||||
else:
|
||||
new_entry = f"\n### {timestamp}\n{content}\n"
|
||||
|
||||
current += new_entry
|
||||
|
||||
return self.write_task_memory(current)
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 追加任务记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def search_memory(self, keyword: str, memory_type: str = "main") -> List[str]:
|
||||
"""搜索记忆内容"""
|
||||
if memory_type == "main":
|
||||
content = self.read_main_memory()
|
||||
else:
|
||||
content = self.read_task_memory()
|
||||
|
||||
results = []
|
||||
lines = content.split('\n')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if keyword.lower() in line.lower():
|
||||
# 获取上下文(前后各2行)
|
||||
start = max(0, i - 2)
|
||||
end = min(len(lines), i + 3)
|
||||
context = '\n'.join(lines[start:end])
|
||||
results.append(context)
|
||||
|
||||
return results
|
||||
|
||||
def clear_task_memory(self) -> bool:
|
||||
"""清空任务记忆"""
|
||||
try:
|
||||
self.create_memory_file(self.task_memory_path, "任务记忆文件")
|
||||
print(f"{OUTPUT_FORMATS['memory']} 清空任务记忆")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 清空任务记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def backup_memory(self, memory_type: str = "main") -> str:
|
||||
"""备份记忆文件"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
if memory_type == "main":
|
||||
source = self.main_memory_path
|
||||
backup_name = f"main_memory_backup_{timestamp}.md"
|
||||
else:
|
||||
source = self.task_memory_path
|
||||
backup_name = f"task_memory_backup_{timestamp}.md"
|
||||
|
||||
backup_path = self.data_dir / "backups" / backup_name
|
||||
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(source, backup_path)
|
||||
print(f"{OUTPUT_FORMATS['success']} 备份成功: {backup_path}")
|
||||
return str(backup_path)
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 备份失败: {e}")
|
||||
return ""
|
||||
|
||||
def restore_memory(self, backup_path: str, memory_type: str = "main") -> bool:
|
||||
"""恢复记忆文件"""
|
||||
backup_file = Path(backup_path)
|
||||
|
||||
if not backup_file.exists():
|
||||
print(f"{OUTPUT_FORMATS['error']} 备份文件不存在: {backup_path}")
|
||||
return False
|
||||
|
||||
if memory_type == "main":
|
||||
target = self.main_memory_path
|
||||
else:
|
||||
target = self.task_memory_path
|
||||
|
||||
try:
|
||||
import shutil
|
||||
# 先备份当前文件
|
||||
self.backup_memory(memory_type)
|
||||
# 恢复备份
|
||||
shutil.copy2(backup_file, target)
|
||||
print(f"{OUTPUT_FORMATS['success']} 恢复成功: {target}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 恢复失败: {e}")
|
||||
return False
|
||||
|
||||
def get_memory_stats(self) -> Dict:
|
||||
"""获取记忆统计信息"""
|
||||
stats = {
|
||||
"main_memory": {
|
||||
"exists": self.main_memory_path.exists(),
|
||||
"size": 0,
|
||||
"lines": 0,
|
||||
"last_modified": None
|
||||
},
|
||||
"task_memory": {
|
||||
"exists": self.task_memory_path.exists(),
|
||||
"size": 0,
|
||||
"lines": 0,
|
||||
"last_modified": None
|
||||
}
|
||||
}
|
||||
|
||||
# 主记忆统计
|
||||
if stats["main_memory"]["exists"]:
|
||||
stat = self.main_memory_path.stat()
|
||||
content = self.read_main_memory()
|
||||
stats["main_memory"]["size"] = stat.st_size
|
||||
stats["main_memory"]["lines"] = len(content.split('\n'))
|
||||
stats["main_memory"]["last_modified"] = datetime.fromtimestamp(
|
||||
stat.st_mtime
|
||||
).isoformat()
|
||||
|
||||
# 任务记忆统计
|
||||
if stats["task_memory"]["exists"]:
|
||||
stat = self.task_memory_path.stat()
|
||||
content = self.read_task_memory()
|
||||
stats["task_memory"]["size"] = stat.st_size
|
||||
stats["task_memory"]["lines"] = len(content.split('\n'))
|
||||
stats["task_memory"]["last_modified"] = datetime.fromtimestamp(
|
||||
stat.st_mtime
|
||||
).isoformat()
|
||||
|
||||
return stats
|
||||
|
||||
def merge_memories(self) -> bool:
|
||||
"""合并任务记忆到主记忆"""
|
||||
try:
|
||||
task_content = self.read_task_memory()
|
||||
|
||||
if not task_content.strip():
|
||||
print(f"{OUTPUT_FORMATS['warning']} 任务记忆为空,无需合并")
|
||||
return True
|
||||
|
||||
# 追加到主记忆
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
merge_content = f"\n## 任务记忆合并 - {timestamp}\n{task_content}\n"
|
||||
|
||||
success = self.append_main_memory(merge_content, "历史任务记录")
|
||||
|
||||
if success:
|
||||
# 清空任务记忆
|
||||
self.clear_task_memory()
|
||||
print(f"{OUTPUT_FORMATS['success']} 记忆合并完成")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 合并记忆失败: {e}")
|
||||
return False
|
||||
@ -1,98 +0,0 @@
|
||||
"""DeepSeek-OCR 客户端(子智能体专用)。"""
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
from config import OCR_API_BASE_URL, OCR_API_KEY, OCR_MODEL_ID, OCR_MAX_TOKENS
|
||||
from modules.file_manager import FileManager
|
||||
|
||||
|
||||
class OCRClient:
|
||||
"""封装 DeepSeek-OCR 调用逻辑。"""
|
||||
|
||||
def __init__(self, project_path: str, file_manager: FileManager):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.file_manager = file_manager
|
||||
|
||||
base_url = (OCR_API_BASE_URL or "").rstrip("/")
|
||||
if not base_url.endswith("/v1"):
|
||||
base_url = f"{base_url}/v1"
|
||||
|
||||
self.http_client = httpx.Client()
|
||||
self.client = OpenAI(
|
||||
api_key=OCR_API_KEY,
|
||||
base_url=base_url,
|
||||
http_client=self.http_client,
|
||||
)
|
||||
self.model = OCR_MODEL_ID or "deepseek-ai/DeepSeek-OCR"
|
||||
self.max_tokens = OCR_MAX_TOKENS or 4096
|
||||
self.max_image_size = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
def _validate_image_path(self, path: str):
|
||||
valid, error, full_path = self.file_manager._validate_path(path)
|
||||
if not valid:
|
||||
return False, error, None
|
||||
if not full_path.exists():
|
||||
return False, "文件不存在", None
|
||||
if not full_path.is_file():
|
||||
return False, "不是文件", None
|
||||
return True, "", full_path
|
||||
|
||||
def ocr_image(self, path: str, prompt: str) -> Dict:
|
||||
warnings: List[str] = []
|
||||
|
||||
valid, error, full_path = self._validate_image_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error, "warnings": warnings}
|
||||
|
||||
if not prompt or not str(prompt).strip():
|
||||
return {"success": False, "error": "prompt 不能为空", "warnings": warnings}
|
||||
|
||||
try:
|
||||
data = full_path.read_bytes()
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": f"读取文件失败: {exc}", "warnings": warnings}
|
||||
|
||||
size = len(data)
|
||||
if size <= 0:
|
||||
return {"success": False, "error": "文件为空,无法识别", "warnings": warnings}
|
||||
|
||||
if size > self.max_image_size:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"图片过大({size}字节),上限为{self.max_image_size}字节",
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(str(full_path))
|
||||
if not mime_type or not mime_type.startswith("image/"):
|
||||
warnings.append("无法确定图片类型,已按 JPEG 处理")
|
||||
mime_type = "image/jpeg"
|
||||
|
||||
base64_image = base64.b64encode(data).decode("utf-8")
|
||||
data_url = f"data:{mime_type};base64,{base64_image}"
|
||||
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}
|
||||
],
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=0,
|
||||
)
|
||||
content = response.choices[0].message.content if response.choices else ""
|
||||
return {"success": True, "content": content or "", "warnings": warnings}
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": f"OCR 调用失败: {exc}", "warnings": warnings}
|
||||
@ -1,678 +0,0 @@
|
||||
# modules/persistent_terminal.py - 持久化终端实例(修复版)
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, List
|
||||
from datetime import datetime
|
||||
import threading
|
||||
import queue
|
||||
from collections import deque
|
||||
try:
|
||||
from config import OUTPUT_FORMATS, TERMINAL_OUTPUT_WAIT, TERMINAL_INPUT_MAX_CHARS
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import OUTPUT_FORMATS, TERMINAL_OUTPUT_WAIT, TERMINAL_INPUT_MAX_CHARS
|
||||
|
||||
class PersistentTerminal:
|
||||
"""单个持久化终端实例"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_name: str,
|
||||
working_dir: str = None,
|
||||
shell_command: str = None,
|
||||
broadcast_callback: Callable = None,
|
||||
max_buffer_size: int = 20000,
|
||||
display_size: int = 5000
|
||||
):
|
||||
"""
|
||||
初始化持久化终端
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
working_dir: 工作目录
|
||||
shell_command: shell命令(None则自动选择)
|
||||
broadcast_callback: 广播回调函数(用于WebSocket)
|
||||
max_buffer_size: 最大缓冲区大小
|
||||
display_size: 显示大小限制
|
||||
"""
|
||||
self.session_name = session_name
|
||||
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
|
||||
self.shell_command = shell_command
|
||||
self.broadcast = broadcast_callback
|
||||
self.max_buffer_size = max_buffer_size
|
||||
self.display_size = display_size
|
||||
|
||||
# 进程相关
|
||||
self.process = None
|
||||
self.is_running = False
|
||||
self.start_time = None
|
||||
|
||||
# 输出缓冲
|
||||
self.output_buffer = []
|
||||
self.command_history = []
|
||||
self.total_output_size = 0
|
||||
self.truncated_lines = 0
|
||||
self.output_history = deque()
|
||||
self._output_event_counter = 0
|
||||
self.last_output_time = None
|
||||
self.last_input_time = None
|
||||
self.last_input_text = ""
|
||||
self.echo_loop_detected = False
|
||||
self._consecutive_echo_matches = 0
|
||||
self.io_history = deque()
|
||||
self._io_history_max = 4000
|
||||
|
||||
# 线程和队列
|
||||
self.output_queue = queue.Queue()
|
||||
self.reader_thread = None
|
||||
self.is_reading = False
|
||||
|
||||
# 状态标志
|
||||
self.is_interactive = False # 是否在等待输入
|
||||
self.last_command = ""
|
||||
self.last_activity = time.time()
|
||||
|
||||
# 系统特定设置
|
||||
self.is_windows = sys.platform == "win32"
|
||||
|
||||
def start(self) -> bool:
|
||||
"""启动终端进程(统一处理编码)"""
|
||||
if self.is_running:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 确定使用的shell
|
||||
if self.is_windows:
|
||||
# Windows下使用CMD
|
||||
self.shell_command = self.shell_command or "cmd.exe"
|
||||
else:
|
||||
# Unix系统
|
||||
self.shell_command = self.shell_command or os.environ.get('SHELL', '/bin/bash')
|
||||
|
||||
# 设置环境变量
|
||||
env = os.environ.copy()
|
||||
env['PYTHONIOENCODING'] = 'utf-8'
|
||||
|
||||
if self.is_windows:
|
||||
# Windows特殊设置
|
||||
env['CHCP'] = '65001' # UTF-8代码页
|
||||
|
||||
# Windows统一不使用text模式,手动处理编码
|
||||
self.process = subprocess.Popen(
|
||||
self.shell_command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0, # 无缓冲
|
||||
env=env
|
||||
)
|
||||
else:
|
||||
# Unix系统
|
||||
env['TERM'] = 'xterm-256color'
|
||||
env['LANG'] = 'en_US.UTF-8'
|
||||
env['LC_ALL'] = 'en_US.UTF-8'
|
||||
|
||||
# Unix也不使用text模式,统一处理
|
||||
self.process = subprocess.Popen(
|
||||
self.shell_command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0,
|
||||
env=env
|
||||
)
|
||||
|
||||
self.is_running = True
|
||||
self.start_time = datetime.now()
|
||||
self.last_output_time = None
|
||||
self.last_input_time = None
|
||||
self.last_input_text = ""
|
||||
self.echo_loop_detected = False
|
||||
self._consecutive_echo_matches = 0
|
||||
|
||||
# 启动输出读取线程
|
||||
self.is_reading = True
|
||||
self.reader_thread = threading.Thread(target=self._read_output)
|
||||
self.reader_thread.daemon = True
|
||||
self.reader_thread.start()
|
||||
|
||||
# 如果是Windows,设置代码页
|
||||
if self.is_windows:
|
||||
time.sleep(0.5) # 等待终端初始化
|
||||
self.send_command("chcp 65001", wait_for_output=False)
|
||||
time.sleep(0.5)
|
||||
# 清屏以去除代码页设置的输出
|
||||
self.send_command("cls", wait_for_output=False)
|
||||
time.sleep(0.3)
|
||||
self.output_buffer.clear() # 清除初始化输出
|
||||
self.total_output_size = 0
|
||||
|
||||
# 广播终端启动事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_started', {
|
||||
'session': self.session_name,
|
||||
'working_dir': str(self.working_dir),
|
||||
'shell': self.shell_command,
|
||||
'time': self.start_time.isoformat()
|
||||
})
|
||||
|
||||
print(f"{OUTPUT_FORMATS['success']} 终端会话启动: {self.session_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 终端启动失败: {e}")
|
||||
self.is_running = False
|
||||
return False
|
||||
|
||||
def _read_output(self):
|
||||
"""后台线程:持续读取输出(修复版,正确处理编码)"""
|
||||
while self.is_reading and self.process:
|
||||
try:
|
||||
# 始终读取字节(因为我们没有使用text=True)
|
||||
line_bytes = self.process.stdout.readline()
|
||||
|
||||
if line_bytes:
|
||||
# 解码字节到字符串
|
||||
line = self._decode_output(line_bytes)
|
||||
|
||||
# 处理输出
|
||||
self.output_queue.put(line)
|
||||
self._process_output(line)
|
||||
|
||||
elif self.process.poll() is not None:
|
||||
# 进程已结束
|
||||
self.is_running = False
|
||||
break
|
||||
else:
|
||||
# 没有输出,短暂休眠
|
||||
time.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
# 不要因为单个错误而停止
|
||||
print(f"[Terminal] 读取输出警告: {e}")
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
def _decode_output(self, data):
|
||||
"""安全地解码输出"""
|
||||
# 如果已经是字符串,直接返回
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
|
||||
# 如果是字节,尝试解码
|
||||
if isinstance(data, bytes):
|
||||
# Windows系统尝试的编码顺序
|
||||
if self.is_windows:
|
||||
encodings = ['utf-8', 'gbk', 'gb2312', 'cp936', 'latin-1']
|
||||
else:
|
||||
encodings = ['utf-8', 'latin-1']
|
||||
|
||||
for encoding in encodings:
|
||||
try:
|
||||
return data.decode(encoding)
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
# 如果所有编码都失败,使用替换模式
|
||||
return data.decode('utf-8', errors='replace')
|
||||
|
||||
# 其他类型,转换为字符串
|
||||
return str(data)
|
||||
|
||||
def _process_output(self, output: str):
|
||||
"""处理输出行"""
|
||||
# 添加到缓冲区
|
||||
self.output_buffer.append(output)
|
||||
self.total_output_size += len(output)
|
||||
now = time.time()
|
||||
self.last_output_time = now
|
||||
|
||||
# 记录输出事件
|
||||
self._output_event_counter += 1
|
||||
self.output_history.append((self._output_event_counter, now, output))
|
||||
self._append_io_event('output', output, timestamp=now)
|
||||
|
||||
# 控制输出历史长度
|
||||
if len(self.output_history) > 2000:
|
||||
self.output_history.popleft()
|
||||
|
||||
# 检查是否需要截断
|
||||
if self.total_output_size > self.max_buffer_size:
|
||||
self._truncate_buffer()
|
||||
|
||||
# 更新活动时间
|
||||
self.last_activity = now
|
||||
|
||||
# 检测命令回显死循环
|
||||
cleaned_output = output.replace('\r', '').strip()
|
||||
cleaned_input = self.last_input_text.strip() if self.last_input_text else ""
|
||||
if cleaned_output and cleaned_input and cleaned_output == cleaned_input:
|
||||
self._consecutive_echo_matches += 1
|
||||
else:
|
||||
self._consecutive_echo_matches = 0
|
||||
if cleaned_output:
|
||||
self.echo_loop_detected = False
|
||||
if self._consecutive_echo_matches >= 1 and self.last_input_time:
|
||||
if now - self.last_input_time <= 2:
|
||||
self.echo_loop_detected = True
|
||||
|
||||
# 检测交互式提示
|
||||
self._detect_interactive_prompt(output)
|
||||
|
||||
# 广播输出
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_output', {
|
||||
'session': self.session_name,
|
||||
'data': output,
|
||||
'timestamp': time.time()
|
||||
})
|
||||
|
||||
def _truncate_buffer(self):
|
||||
"""截断缓冲区以保持在限制内"""
|
||||
# 保留最后的N个字符
|
||||
while self.total_output_size > self.max_buffer_size and self.output_buffer:
|
||||
removed = self.output_buffer.pop(0)
|
||||
self.total_output_size -= len(removed)
|
||||
self.truncated_lines += 1
|
||||
if self.output_history:
|
||||
self.output_history.popleft()
|
||||
|
||||
def _detect_interactive_prompt(self, output: str):
|
||||
"""检测是否在等待交互输入"""
|
||||
self.is_interactive = False
|
||||
# 常见的交互提示模式
|
||||
interactive_patterns = [
|
||||
"? ", # 问题提示
|
||||
": ", # 输入提示
|
||||
"> ", # 命令提示
|
||||
"$ ", # shell提示
|
||||
"# ", # root提示
|
||||
">>> ", # Python提示
|
||||
"... ", # Python续行
|
||||
"(y/n)", # 确认提示
|
||||
"[Y/n]", # 确认提示
|
||||
"Password:", # 密码提示
|
||||
"password:", # 密码提示
|
||||
"Enter", # 输入提示
|
||||
"选择", # 中文选择
|
||||
"请输入", # 中文输入
|
||||
]
|
||||
|
||||
output_lower = output.lower().strip()
|
||||
for pattern in interactive_patterns:
|
||||
if pattern.lower() in output_lower:
|
||||
self.is_interactive = True
|
||||
return
|
||||
|
||||
# 如果输出以常见提示符结尾且没有换行,也认为是交互式
|
||||
if output and not output.endswith('\n'):
|
||||
last_chars = output.strip()[-3:]
|
||||
if last_chars in ['> ', '$ ', '# ', ': ']:
|
||||
self.is_interactive = True
|
||||
|
||||
def _capture_history_marker(self) -> int:
|
||||
return self._output_event_counter
|
||||
|
||||
def _get_output_since_marker(self, marker: int) -> str:
|
||||
if marker is None:
|
||||
return ''.join(item[2] for item in self.output_history)
|
||||
return ''.join(item[2] for item in self.output_history if item[0] > marker)
|
||||
|
||||
def _append_io_event(self, event_type: str, data: str, timestamp: Optional[float] = None):
|
||||
"""记录终端输入输出事件"""
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
self.io_history.append((event_type, timestamp, data))
|
||||
while len(self.io_history) > self._io_history_max:
|
||||
self.io_history.popleft()
|
||||
|
||||
def _seconds_since_last_output(self) -> Optional[float]:
|
||||
if not self.last_output_time:
|
||||
return None
|
||||
return round(time.time() - self.last_output_time, 3)
|
||||
|
||||
def send_command(self, command: str, wait_for_output: bool = True, timeout: float = None) -> Dict:
|
||||
"""发送命令到终端(统一编码处理)"""
|
||||
if not self.is_running or not self.process:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "终端未运行",
|
||||
"session": self.session_name
|
||||
}
|
||||
|
||||
try:
|
||||
marker = self._capture_history_marker()
|
||||
if timeout is None:
|
||||
timeout = TERMINAL_OUTPUT_WAIT
|
||||
else:
|
||||
try:
|
||||
timeout = float(timeout)
|
||||
except (TypeError, ValueError):
|
||||
timeout = TERMINAL_OUTPUT_WAIT
|
||||
if timeout < 0:
|
||||
timeout = 0
|
||||
start_time = time.time()
|
||||
command_text = command.rstrip('\n')
|
||||
# 记录命令
|
||||
self.command_history.append({
|
||||
"command": command_text,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
self.last_command = command_text
|
||||
self.is_interactive = False
|
||||
self.last_input_text = command_text
|
||||
self.last_input_time = time.time()
|
||||
self.echo_loop_detected = False
|
||||
self._consecutive_echo_matches = 0
|
||||
self._append_io_event('input', command_text + '\n', timestamp=self.last_input_time)
|
||||
|
||||
# 广播输入事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_input', {
|
||||
'session': self.session_name,
|
||||
'data': command_text + '\n',
|
||||
'timestamp': time.time()
|
||||
})
|
||||
|
||||
# 确保命令有换行符
|
||||
to_send = command if command.endswith('\n') else command + '\n'
|
||||
|
||||
# 发送命令(统一使用UTF-8编码)
|
||||
try:
|
||||
# 首先尝试UTF-8
|
||||
command_bytes = to_send.encode('utf-8')
|
||||
except UnicodeEncodeError:
|
||||
# 如果UTF-8失败,Windows系统尝试GBK
|
||||
if self.is_windows:
|
||||
command_bytes = to_send.encode('gbk', errors='replace')
|
||||
else:
|
||||
command_bytes = to_send.encode('utf-8', errors='replace')
|
||||
|
||||
self.process.stdin.write(command_bytes)
|
||||
self.process.stdin.flush()
|
||||
|
||||
# 如果需要等待输出
|
||||
if wait_for_output:
|
||||
output = self._wait_for_output(timeout=timeout)
|
||||
recent_output = self._get_output_since_marker(marker)
|
||||
if recent_output:
|
||||
output = recent_output
|
||||
output_truncated = False
|
||||
if len(output) > TERMINAL_INPUT_MAX_CHARS:
|
||||
output = output[-TERMINAL_INPUT_MAX_CHARS:]
|
||||
output_truncated = True
|
||||
output_clean = output.strip()
|
||||
has_output = bool(output_clean)
|
||||
status = "completed"
|
||||
if not has_output:
|
||||
if self.echo_loop_detected:
|
||||
status = "echo_loop"
|
||||
elif self.is_interactive:
|
||||
status = "awaiting_input"
|
||||
else:
|
||||
status = "no_output"
|
||||
else:
|
||||
if self.echo_loop_detected:
|
||||
status = "output_with_echo"
|
||||
message_map = {
|
||||
"completed": "命令执行完成,已捕获终端输出",
|
||||
"no_output": "未捕获任何输出,命令可能未产生可见结果或终端已卡死需要重制",
|
||||
"awaiting_input": "命令已发送,终端正在等待进一步输入或进程仍在运行",
|
||||
"echo_loop": "检测到终端正在回显输入,命令可能未成功执行",
|
||||
"output_with_echo": "命令产生输出,但终端疑似重复回显,请检查是否卡住"
|
||||
}
|
||||
message = message_map.get(status, "命令执行完成")
|
||||
if output_truncated:
|
||||
message += f"(输出已截断,保留末尾{TERMINAL_INPUT_MAX_CHARS}字符)"
|
||||
return {
|
||||
"success": True,
|
||||
"session": self.session_name,
|
||||
"command": command_text,
|
||||
"output": output,
|
||||
"message": message,
|
||||
"duration": round(time.time() - start_time, 3),
|
||||
"pending_output": status in ("no_output", "awaiting_input", "echo_loop"),
|
||||
"timeout_used": timeout,
|
||||
"status": status,
|
||||
"is_interactive": self.is_interactive,
|
||||
"echo_loop_detected": self.echo_loop_detected,
|
||||
"seconds_since_last_output": self._seconds_since_last_output(),
|
||||
"output_char_count": len(output),
|
||||
"last_output_time": self.last_output_time,
|
||||
"output_truncated": output_truncated,
|
||||
"output_char_limit": TERMINAL_INPUT_MAX_CHARS
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"session": self.session_name,
|
||||
"command": command_text,
|
||||
"output": "命令已发送",
|
||||
"message": "命令已发送至终端,后续输出将实时流式返回",
|
||||
"duration": round(time.time() - start_time, 3),
|
||||
"pending_output": True,
|
||||
"timeout_used": timeout,
|
||||
"status": "pending",
|
||||
"is_interactive": self.is_interactive,
|
||||
"echo_loop_detected": self.echo_loop_detected,
|
||||
"seconds_since_last_output": self._seconds_since_last_output(),
|
||||
"output_char_count": 0,
|
||||
"last_output_time": self.last_output_time,
|
||||
"output_truncated": False,
|
||||
"output_char_limit": TERMINAL_INPUT_MAX_CHARS
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"发送命令失败: {str(e)}"
|
||||
print(f"{OUTPUT_FORMATS['error']} {error_msg}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
"session": self.session_name
|
||||
}
|
||||
|
||||
def _wait_for_output(self, timeout: float = 5) -> str:
|
||||
"""等待并收集输出"""
|
||||
collected_output = []
|
||||
start_time = time.time()
|
||||
last_output_time = time.time()
|
||||
|
||||
if timeout is None or timeout <= 0:
|
||||
timeout = 0
|
||||
|
||||
if timeout == 0:
|
||||
try:
|
||||
while True:
|
||||
output = self.output_queue.get_nowait()
|
||||
collected_output.append(output)
|
||||
except queue.Empty:
|
||||
return ''.join(collected_output)
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
remaining = max(0.05, min(0.5, timeout - (time.time() - start_time)))
|
||||
output = self.output_queue.get(timeout=remaining)
|
||||
collected_output.append(output)
|
||||
last_output_time = time.time()
|
||||
# 快速收集剩余输出,直到短暂空闲
|
||||
while True:
|
||||
try:
|
||||
output = self.output_queue.get(timeout=0.1)
|
||||
collected_output.append(output)
|
||||
last_output_time = time.time()
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
if collected_output and time.time() - last_output_time > 0.3:
|
||||
break
|
||||
if timeout == 0:
|
||||
break
|
||||
|
||||
return ''.join(collected_output)
|
||||
|
||||
def get_output(self, last_n_lines: int = 50) -> str:
|
||||
"""
|
||||
获取终端输出
|
||||
|
||||
Args:
|
||||
last_n_lines: 获取最后N行
|
||||
|
||||
Returns:
|
||||
输出内容
|
||||
"""
|
||||
if last_n_lines <= 0:
|
||||
return ''.join(self.output_buffer)
|
||||
|
||||
# 获取最后N行
|
||||
lines = []
|
||||
for line in reversed(self.output_buffer):
|
||||
lines.insert(0, line)
|
||||
if len(lines) >= last_n_lines:
|
||||
break
|
||||
|
||||
return ''.join(lines)
|
||||
|
||||
def get_display_output(self) -> str:
|
||||
"""获取用于显示的输出(截断到display_size)"""
|
||||
output = self.get_output()
|
||||
if len(output) > self.display_size:
|
||||
# 保留最后的display_size字符
|
||||
output = output[-self.display_size:]
|
||||
output = f"[输出已截断,显示最后{self.display_size}字符]\n{output}"
|
||||
return output
|
||||
|
||||
def get_snapshot(self, last_n_lines: int, max_chars: int) -> Dict:
|
||||
"""获取终端快照,包含按顺序排列的输入/输出"""
|
||||
if last_n_lines <= 0:
|
||||
last_n_lines = 1
|
||||
|
||||
combined_lines: List[str] = []
|
||||
for event_type, _, data in self.io_history:
|
||||
if event_type == 'input':
|
||||
# 显示输入命令,保持与终端监控一致
|
||||
combined_lines.append(f"➜ {data.rstrip()}" if data.strip() else "➜")
|
||||
else:
|
||||
cleaned = data.replace('\r', '')
|
||||
# 按行拆分输出,保留空行
|
||||
segments = cleaned.splitlines()
|
||||
if cleaned.endswith('\n'):
|
||||
segments.append('')
|
||||
combined_lines.extend(segments if segments else [''])
|
||||
|
||||
if combined_lines:
|
||||
selected_lines = combined_lines[-last_n_lines:]
|
||||
output_text = '\n'.join(selected_lines)
|
||||
else:
|
||||
selected_lines = []
|
||||
output_text = ''
|
||||
|
||||
truncated = False
|
||||
if len(output_text) > max_chars:
|
||||
output_text = output_text[-max_chars:]
|
||||
truncated = True
|
||||
|
||||
# 统计行数
|
||||
if output_text:
|
||||
lines_returned = output_text.count('\n') + (0 if output_text.endswith('\n') else 1)
|
||||
else:
|
||||
lines_returned = 0
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": self.session_name,
|
||||
"output": output_text,
|
||||
"lines_requested": last_n_lines,
|
||||
"lines_returned": lines_returned,
|
||||
"truncated": truncated,
|
||||
"is_interactive": self.is_interactive,
|
||||
"echo_loop_detected": self.echo_loop_detected,
|
||||
"seconds_since_last_output": self._seconds_since_last_output(),
|
||||
"last_command": self.last_command,
|
||||
"buffer_size": self.total_output_size,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""获取终端状态"""
|
||||
return {
|
||||
"session_name": self.session_name,
|
||||
"is_running": self.is_running,
|
||||
"working_dir": str(self.working_dir),
|
||||
"shell": self.shell_command,
|
||||
"start_time": self.start_time.isoformat() if self.start_time else None,
|
||||
"is_interactive": self.is_interactive,
|
||||
"last_command": self.last_command,
|
||||
"command_count": len(self.command_history),
|
||||
"buffer_size": self.total_output_size,
|
||||
"truncated_lines": self.truncated_lines,
|
||||
"last_activity": datetime.fromtimestamp(self.last_activity).isoformat(),
|
||||
"uptime_seconds": (datetime.now() - self.start_time).total_seconds() if self.start_time else 0,
|
||||
"seconds_since_last_output": self._seconds_since_last_output(),
|
||||
"echo_loop_detected": self.echo_loop_detected
|
||||
}
|
||||
|
||||
def close(self) -> bool:
|
||||
"""关闭终端"""
|
||||
if not self.is_running:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 停止读取线程
|
||||
self.is_reading = False
|
||||
|
||||
# 发送退出命令
|
||||
if self.process and self.process.poll() is None:
|
||||
exit_cmd = "exit\n"
|
||||
try:
|
||||
self.process.stdin.write(exit_cmd.encode('utf-8'))
|
||||
self.process.stdin.flush()
|
||||
except:
|
||||
pass
|
||||
|
||||
# 等待进程结束
|
||||
try:
|
||||
self.process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
# 强制终止
|
||||
self.process.terminate()
|
||||
time.sleep(0.5)
|
||||
if self.process.poll() is None:
|
||||
self.process.kill()
|
||||
|
||||
self.is_running = False
|
||||
|
||||
# 等待读取线程结束
|
||||
if self.reader_thread and self.reader_thread.is_alive():
|
||||
self.reader_thread.join(timeout=1)
|
||||
|
||||
# 广播终端关闭事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_closed', {
|
||||
'session': self.session_name,
|
||||
'time': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
print(f"{OUTPUT_FORMATS['info']} 终端会话关闭: {self.session_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 关闭终端失败: {e}")
|
||||
return False
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保进程被关闭"""
|
||||
if hasattr(self, 'is_running') and self.is_running:
|
||||
self.close()
|
||||
@ -1,492 +0,0 @@
|
||||
# modules/search_engine.py - 网络搜索模块
|
||||
|
||||
import httpx
|
||||
import json
|
||||
from typing import Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
import re
|
||||
try:
|
||||
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS
|
||||
|
||||
class SearchEngine:
|
||||
def __init__(self):
|
||||
self.api_key = TAVILY_API_KEY
|
||||
self.api_url = "https://api.tavily.com/search"
|
||||
|
||||
self._valid_topics = {"general", "news", "finance"}
|
||||
self._valid_time_ranges = {
|
||||
"day": "day",
|
||||
"d": "day",
|
||||
"week": "week",
|
||||
"w": "week",
|
||||
"month": "month",
|
||||
"m": "month",
|
||||
"year": "year",
|
||||
"y": "year"
|
||||
}
|
||||
self._date_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
max_results: Optional[int] = None,
|
||||
topic: Optional[str] = None,
|
||||
time_range: Optional[str] = None,
|
||||
days: Optional[int] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
country: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
执行网络搜索
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
max_results: 最大结果数
|
||||
topic: 搜索类型(general/news/finance)
|
||||
time_range: 相对时间范围(day/week/month/year 或 d/w/m/y)
|
||||
days: 过去N天,仅topic=news可用
|
||||
start_date: 起始日期,格式YYYY-MM-DD
|
||||
end_date: 结束日期,格式YYYY-MM-DD
|
||||
country: 国家过滤,仅topic=general可用
|
||||
|
||||
Returns:
|
||||
搜索结果字典
|
||||
"""
|
||||
if not self.api_key or self.api_key == "your-tavily-api-key":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Tavily API密钥未配置",
|
||||
"results": []
|
||||
}
|
||||
|
||||
validation = self._build_payload(
|
||||
query=query,
|
||||
max_results=max_results,
|
||||
topic=topic,
|
||||
time_range=time_range,
|
||||
days=days,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
country=country
|
||||
)
|
||||
|
||||
if not validation["success"]:
|
||||
return validation
|
||||
|
||||
payload = validation["payload"]
|
||||
applied_filters = validation["filters"]
|
||||
|
||||
max_results = payload.get("max_results", SEARCH_MAX_RESULTS)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['search']} 搜索: {query}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.api_url,
|
||||
json={
|
||||
**payload
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"API请求失败: {response.status_code}",
|
||||
"results": []
|
||||
}
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 格式化结果
|
||||
formatted_results = self._format_results(data, applied_filters)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['success']} 搜索完成,找到 {len(formatted_results['results'])} 条结果")
|
||||
|
||||
return formatted_results
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "搜索超时",
|
||||
"results": []
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"搜索失败: {str(e)}",
|
||||
"results": []
|
||||
}
|
||||
|
||||
def _format_results(self, raw_data: Dict, filters: Dict[str, Any]) -> Dict:
|
||||
"""格式化搜索结果"""
|
||||
formatted = {
|
||||
"success": True,
|
||||
"query": raw_data.get("query", ""),
|
||||
"answer": raw_data.get("answer", ""),
|
||||
"results": [],
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"filters": filters,
|
||||
"total_results": len(raw_data.get("results", []))
|
||||
}
|
||||
|
||||
# 处理每个搜索结果
|
||||
for idx, result in enumerate(raw_data.get("results", []), 1):
|
||||
formatted_result = {
|
||||
"index": idx,
|
||||
"title": result.get("title", "无标题"),
|
||||
"url": result.get("url", ""),
|
||||
"content": result.get("content", ""),
|
||||
"score": result.get("score", 0),
|
||||
"published_date": result.get("published_date", "")
|
||||
}
|
||||
formatted["results"].append(formatted_result)
|
||||
|
||||
return formatted
|
||||
|
||||
async def search_with_summary(
|
||||
self,
|
||||
query: str,
|
||||
max_results: Optional[int] = None,
|
||||
topic: Optional[str] = None,
|
||||
time_range: Optional[str] = None,
|
||||
days: Optional[int] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
country: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
搜索并返回格式化的摘要
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
max_results: 最大结果数
|
||||
|
||||
Returns:
|
||||
格式化的搜索摘要字符串
|
||||
"""
|
||||
results = await self.search(
|
||||
query=query,
|
||||
max_results=max_results,
|
||||
topic=topic,
|
||||
time_range=time_range,
|
||||
days=days,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
country=country
|
||||
)
|
||||
|
||||
if not results["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": results.get("error", "未知错误"),
|
||||
"summary": ""
|
||||
}
|
||||
|
||||
# 构建摘要
|
||||
summary_lines = [
|
||||
f"🔍 搜索查询: {query}",
|
||||
f"📅 搜索时间: {results['timestamp']}"
|
||||
]
|
||||
|
||||
filter_notes = self._summarize_filters(results.get("filters", {}))
|
||||
if filter_notes:
|
||||
summary_lines.append(filter_notes)
|
||||
summary_lines.append("")
|
||||
|
||||
# 添加AI答案(如果有)
|
||||
if results.get("answer"):
|
||||
summary_lines.extend([
|
||||
"📝 AI摘要:",
|
||||
results["answer"],
|
||||
"",
|
||||
"---",
|
||||
""
|
||||
])
|
||||
|
||||
# 添加搜索结果
|
||||
if results["results"]:
|
||||
summary_lines.append("📊 搜索结果:")
|
||||
|
||||
for result in results["results"]:
|
||||
summary_lines.extend([
|
||||
f"\n{result['index']}. {result['title']}",
|
||||
f" 🔗 {result['url']}",
|
||||
f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}",
|
||||
])
|
||||
|
||||
if result.get("published_date"):
|
||||
summary_lines.append(f" 📅 发布时间: {result['published_date']}")
|
||||
else:
|
||||
summary_lines.append("未找到相关结果")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"summary": "\n".join(summary_lines),
|
||||
"filters": results.get("filters", {}),
|
||||
"query": results.get("query", query),
|
||||
"results": results.get("results", []),
|
||||
"total_results": results.get("total_results", len(results.get("results", [])))
|
||||
}
|
||||
|
||||
async def quick_answer(self, query: str) -> str:
|
||||
"""
|
||||
快速获取答案(只返回AI摘要)
|
||||
|
||||
Args:
|
||||
query: 查询问题
|
||||
|
||||
Returns:
|
||||
AI答案或错误信息
|
||||
"""
|
||||
results = await self.search(query, max_results=5)
|
||||
|
||||
if not results["success"]:
|
||||
return f"搜索失败: {results['error']}"
|
||||
|
||||
if results.get("answer"):
|
||||
return results["answer"]
|
||||
|
||||
# 如果没有AI答案,返回第一个结果的摘要
|
||||
if results["results"]:
|
||||
first_result = results["results"][0]
|
||||
return f"{first_result['title']}\n{first_result['content'][:300]}..."
|
||||
|
||||
return "未找到相关信息"
|
||||
|
||||
def save_results(self, results: Dict, filename: str = None) -> str:
|
||||
"""
|
||||
保存搜索结果到文件
|
||||
|
||||
Args:
|
||||
results: 搜索结果
|
||||
filename: 文件名(可选)
|
||||
|
||||
Returns:
|
||||
保存的文件路径
|
||||
"""
|
||||
if filename is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"search_{timestamp}.json"
|
||||
|
||||
file_path = f"./data/searches/{filename}"
|
||||
|
||||
# 确保目录存在
|
||||
import os
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
# 保存结果
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['file']} 搜索结果已保存到: {file_path}")
|
||||
|
||||
return file_path
|
||||
|
||||
def load_results(self, filename: str) -> Optional[Dict]:
|
||||
"""
|
||||
加载之前的搜索结果
|
||||
|
||||
Args:
|
||||
filename: 文件名
|
||||
|
||||
Returns:
|
||||
搜索结果字典或None
|
||||
"""
|
||||
file_path = f"./data/searches/{filename}"
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"{OUTPUT_FORMATS['error']} 文件不存在: {file_path}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 加载失败: {e}")
|
||||
return None
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
query: str,
|
||||
max_results: Optional[int],
|
||||
topic: Optional[str],
|
||||
time_range: Optional[str],
|
||||
days: Optional[int],
|
||||
start_date: Optional[str],
|
||||
end_date: Optional[str],
|
||||
country: Optional[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""验证并构建 Tavily 请求参数"""
|
||||
payload: Dict[str, Any] = {
|
||||
"query": query,
|
||||
"search_depth": "advanced",
|
||||
"include_answer": True,
|
||||
"include_images": False,
|
||||
"include_raw_content": False
|
||||
}
|
||||
|
||||
filters: Dict[str, Any] = {}
|
||||
|
||||
if max_results:
|
||||
payload["max_results"] = max_results
|
||||
else:
|
||||
payload["max_results"] = SEARCH_MAX_RESULTS
|
||||
|
||||
normalized_topic = (topic or "general").strip().lower()
|
||||
if not normalized_topic:
|
||||
normalized_topic = "general"
|
||||
if normalized_topic not in self._valid_topics:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"无效的topic: {topic}. 可选值: {', '.join(self._valid_topics)}",
|
||||
"results": []
|
||||
}
|
||||
payload["topic"] = normalized_topic
|
||||
filters["topic"] = normalized_topic
|
||||
|
||||
# 时间参数互斥检查
|
||||
has_time_range = bool(time_range)
|
||||
has_days = days is not None
|
||||
has_date_range = bool(start_date or end_date)
|
||||
selected_filters = sum([has_time_range, has_days, has_date_range])
|
||||
if selected_filters > 1:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "时间参数只能三选一:time_range、days、start_date+end_date 不能同时使用",
|
||||
"results": []
|
||||
}
|
||||
|
||||
# 验证 days
|
||||
if has_days:
|
||||
try:
|
||||
days_value = int(days) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"days 必须是正整数,当前值: {days}",
|
||||
"results": []
|
||||
}
|
||||
if days_value <= 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"days 必须大于0,当前值: {days_value}",
|
||||
"results": []
|
||||
}
|
||||
if normalized_topic != "news":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "days 参数仅在 topic=\"news\" 时可用,请调整 topic 或改用其他时间参数",
|
||||
"results": []
|
||||
}
|
||||
payload["days"] = days_value
|
||||
filters["days"] = days_value
|
||||
|
||||
# 验证 time_range
|
||||
if has_time_range:
|
||||
normalized_range = time_range.strip().lower() # type: ignore[union-attr]
|
||||
normalized_range = self._valid_time_ranges.get(normalized_range, "")
|
||||
if not normalized_range:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"无效的time_range: {time_range}. 可选值: day/week/month/year 或缩写 d/w/m/y",
|
||||
"results": []
|
||||
}
|
||||
payload["time_range"] = normalized_range
|
||||
filters["time_range"] = normalized_range
|
||||
|
||||
# 验证日期范围
|
||||
if has_date_range:
|
||||
if not start_date or not end_date:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "start_date 与 end_date 必须同时提供且格式为 YYYY-MM-DD",
|
||||
"results": []
|
||||
}
|
||||
if not self._date_pattern.match(start_date):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"start_date 格式无效: {start_date},请使用 YYYY-MM-DD",
|
||||
"results": []
|
||||
}
|
||||
if not self._date_pattern.match(end_date):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"end_date 格式无效: {end_date},请使用 YYYY-MM-DD",
|
||||
"results": []
|
||||
}
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "start_date 或 end_date 含无效日期,请检查是否为有效的公历日期",
|
||||
"results": []
|
||||
}
|
||||
if start_dt > end_dt:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"start_date ({start_date}) 不能晚于 end_date ({end_date})",
|
||||
"results": []
|
||||
}
|
||||
payload["start_date"] = start_date
|
||||
payload["end_date"] = end_date
|
||||
filters["start_date"] = start_date
|
||||
filters["end_date"] = end_date
|
||||
|
||||
# 国家过滤
|
||||
if country:
|
||||
normalized_country = country.strip().lower()
|
||||
if normalized_country:
|
||||
if normalized_topic != "general":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "country 参数仅在 topic=\"general\" 时可用,请调整 topic 或移除 country",
|
||||
"results": []
|
||||
}
|
||||
payload["country"] = normalized_country
|
||||
filters["country"] = normalized_country
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"payload": payload,
|
||||
"filters": filters,
|
||||
"results": []
|
||||
}
|
||||
|
||||
def _summarize_filters(self, filters: Dict[str, Any]) -> str:
|
||||
"""构建过滤条件摘要"""
|
||||
if not filters:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
topic = filters.get("topic")
|
||||
if topic:
|
||||
parts.append(f"Topic: {topic}")
|
||||
|
||||
if "time_range" in filters:
|
||||
parts.append(f"Time Range: {filters['time_range']}")
|
||||
elif "days" in filters:
|
||||
parts.append(f"最近 {filters['days']} 天")
|
||||
elif "start_date" in filters and "end_date" in filters:
|
||||
parts.append(f"{filters['start_date']} 至 {filters['end_date']}")
|
||||
|
||||
if "country" in filters:
|
||||
parts.append(f"Country: {filters['country']}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return "🎯 过滤条件: " + " | ".join(parts)
|
||||
@ -1,443 +0,0 @@
|
||||
"""子智能体任务管理。"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from config import (
|
||||
OUTPUT_FORMATS,
|
||||
SUB_AGENT_DEFAULT_TIMEOUT,
|
||||
SUB_AGENT_MAX_ACTIVE,
|
||||
SUB_AGENT_PROJECT_RESULTS_DIR,
|
||||
SUB_AGENT_SERVICE_BASE_URL,
|
||||
SUB_AGENT_STATE_FILE,
|
||||
SUB_AGENT_STATUS_POLL_INTERVAL,
|
||||
SUB_AGENT_TASKS_BASE_DIR,
|
||||
)
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
TERMINAL_STATUSES = {"completed", "failed", "timeout"}
|
||||
|
||||
|
||||
class SubAgentManager:
|
||||
"""负责主智能体与子智能体服务之间的任务调度。"""
|
||||
|
||||
def __init__(self, project_path: str, data_dir: str):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.data_dir = Path(data_dir).resolve()
|
||||
self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve()
|
||||
self.results_dir = Path(SUB_AGENT_PROJECT_RESULTS_DIR).resolve()
|
||||
self.state_file = Path(SUB_AGENT_STATE_FILE).resolve()
|
||||
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.results_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.tasks: Dict[str, Dict] = {}
|
||||
self._load_state()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公共方法
|
||||
# ------------------------------------------------------------------
|
||||
def create_sub_agent(
|
||||
self,
|
||||
*,
|
||||
agent_id: int,
|
||||
summary: str,
|
||||
task: str,
|
||||
target_dir: str,
|
||||
reference_files: Optional[List[str]] = None,
|
||||
timeout_seconds: Optional[int] = None,
|
||||
) -> Dict:
|
||||
"""创建子智能体任务并启动远端服务。"""
|
||||
reference_files = reference_files or []
|
||||
validation_error = self._validate_create_params(agent_id, summary, task, target_dir)
|
||||
if validation_error:
|
||||
return {"success": False, "error": validation_error}
|
||||
|
||||
if self._active_task_count() >= SUB_AGENT_MAX_ACTIVE:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"已有 {SUB_AGENT_MAX_ACTIVE} 个子智能体在运行,请稍后再试。",
|
||||
}
|
||||
|
||||
task_id = self._generate_task_id(agent_id)
|
||||
task_root = self.base_dir / task_id
|
||||
references_dir = task_root / "references"
|
||||
deliverables_dir = task_root / "deliverables"
|
||||
workspace_dir = task_root / "workspace"
|
||||
|
||||
for path in (task_root, references_dir, deliverables_dir, workspace_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copied_refs, copy_errors = self._copy_reference_files(reference_files, references_dir)
|
||||
if copy_errors:
|
||||
return {"success": False, "error": "; ".join(copy_errors)}
|
||||
|
||||
try:
|
||||
target_project_dir = self._ensure_project_subdir(target_dir)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"agent_id": agent_id,
|
||||
"summary": summary,
|
||||
"task": task,
|
||||
"target_project_dir": str(target_project_dir),
|
||||
"workspace_dir": str(workspace_dir),
|
||||
"references_dir": str(references_dir),
|
||||
"deliverables_dir": str(deliverables_dir),
|
||||
"timeout_seconds": timeout_seconds,
|
||||
}
|
||||
|
||||
service_response = self._call_service("POST", "/tasks", payload, timeout_seconds + 5)
|
||||
if not service_response.get("success"):
|
||||
self._cleanup_task_folder(task_root)
|
||||
return {
|
||||
"success": False,
|
||||
"error": service_response.get("error", "子智能体服务调用失败"),
|
||||
"details": service_response,
|
||||
}
|
||||
|
||||
status = service_response.get("status", "pending")
|
||||
task_record = {
|
||||
"task_id": task_id,
|
||||
"agent_id": agent_id,
|
||||
"summary": summary,
|
||||
"task": task,
|
||||
"status": status,
|
||||
"target_project_dir": str(target_project_dir),
|
||||
"references_dir": str(references_dir),
|
||||
"deliverables_dir": str(deliverables_dir),
|
||||
"workspace_dir": str(workspace_dir),
|
||||
"copied_references": copied_refs,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"service_payload": payload,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
self.tasks[task_id] = task_record
|
||||
self._save_state()
|
||||
|
||||
message = f"子智能体{agent_id} 已创建,任务ID: {task_id},当前状态:{status}"
|
||||
print(f"{OUTPUT_FORMATS['info']} {message}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task_id": task_id,
|
||||
"agent_id": agent_id,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"deliverables_dir": str(deliverables_dir),
|
||||
"copied_references": copied_refs,
|
||||
}
|
||||
|
||||
def wait_for_completion(
|
||||
self,
|
||||
*,
|
||||
task_id: Optional[str] = None,
|
||||
agent_id: Optional[int] = None,
|
||||
timeout_seconds: Optional[int] = None,
|
||||
) -> Dict:
|
||||
"""阻塞等待子智能体完成或超时。"""
|
||||
task = self._select_task(task_id, agent_id)
|
||||
if not task:
|
||||
return {"success": False, "error": "未找到对应的子智能体任务"}
|
||||
|
||||
if task.get("status") in TERMINAL_STATUSES and task.get("final_result"):
|
||||
return task["final_result"]
|
||||
|
||||
timeout_seconds = timeout_seconds or task.get("timeout_seconds") or SUB_AGENT_DEFAULT_TIMEOUT
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_payload: Optional[Dict] = None
|
||||
|
||||
while time.time() < deadline:
|
||||
last_payload = self._call_service("GET", f"/tasks/{task['task_id']}", timeout=15)
|
||||
status = last_payload.get("status")
|
||||
if not last_payload.get("success") and status not in TERMINAL_STATUSES:
|
||||
time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
if status in {"completed", "failed", "timeout"}:
|
||||
break
|
||||
|
||||
time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL)
|
||||
else:
|
||||
status = "timeout"
|
||||
last_payload = {"success": False, "status": status, "message": "等待超时"}
|
||||
|
||||
if not last_payload:
|
||||
last_payload = {"success": False, "status": "unknown", "message": "无法获取子智能体状态"}
|
||||
status = "unknown"
|
||||
else:
|
||||
status = last_payload.get("status", status)
|
||||
|
||||
finalize_result = self._finalize_task(task, last_payload or {}, status)
|
||||
self._save_state()
|
||||
return finalize_result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部工具方法
|
||||
# ------------------------------------------------------------------
|
||||
def _load_state(self):
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||
self.tasks = data.get("tasks", {})
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("子智能体状态文件损坏,已忽略。")
|
||||
self.tasks = {}
|
||||
else:
|
||||
self.tasks = {}
|
||||
|
||||
def _save_state(self):
|
||||
payload = {"tasks": self.tasks}
|
||||
self.state_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def _generate_task_id(self, agent_id: int) -> str:
|
||||
suffix = uuid.uuid4().hex[:6]
|
||||
return f"sub_{agent_id}_{int(time.time())}_{suffix}"
|
||||
|
||||
def _active_task_count(self) -> int:
|
||||
return len([t for t in self.tasks.values() if t.get("status") in {"pending", "running"}])
|
||||
|
||||
def _copy_reference_files(self, references: List[str], dest_dir: Path) -> Tuple[List[str], List[str]]:
|
||||
copied = []
|
||||
errors = []
|
||||
for rel_path in references:
|
||||
rel_path = rel_path.strip()
|
||||
if not rel_path:
|
||||
continue
|
||||
try:
|
||||
source = self._resolve_project_file(rel_path)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
continue
|
||||
|
||||
if not source.exists():
|
||||
errors.append(f"参考文件不存在: {rel_path}")
|
||||
continue
|
||||
|
||||
target_path = dest_dir / rel_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(source, target_path)
|
||||
copied.append(rel_path)
|
||||
except Exception as exc:
|
||||
errors.append(f"复制 {rel_path} 失败: {exc}")
|
||||
return copied, errors
|
||||
|
||||
def _ensure_project_subdir(self, relative_dir: str) -> Path:
|
||||
relative_dir = relative_dir.strip() if relative_dir else ""
|
||||
if not relative_dir:
|
||||
relative_dir = "sub_agent_results"
|
||||
target = (self.project_path / relative_dir).resolve()
|
||||
if not str(target).startswith(str(self.project_path)):
|
||||
raise ValueError("指定文件夹必须位于项目目录内")
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return target
|
||||
|
||||
def _resolve_project_file(self, relative_path: str) -> Path:
|
||||
relative_path = relative_path.strip()
|
||||
candidate = (self.project_path / relative_path).resolve()
|
||||
if not str(candidate).startswith(str(self.project_path)):
|
||||
raise ValueError(f"非法的参考文件路径: {relative_path}")
|
||||
return candidate
|
||||
|
||||
def _select_task(self, task_id: Optional[str], agent_id: Optional[int]) -> Optional[Dict]:
|
||||
if task_id:
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
if agent_id is None:
|
||||
return None
|
||||
|
||||
# 返回最新的匹配任务
|
||||
candidates = [
|
||||
task for task in self.tasks.values()
|
||||
if task.get("agent_id") == agent_id and task.get("status") in {"pending", "running"}
|
||||
]
|
||||
if candidates:
|
||||
candidates.sort(key=lambda item: item.get("created_at", 0), reverse=True)
|
||||
return candidates[0]
|
||||
return None
|
||||
|
||||
def poll_updates(self) -> List[Dict]:
|
||||
"""检查运行中的子智能体任务,返回新完成的结果。"""
|
||||
updates: List[Dict] = []
|
||||
pending_tasks = [
|
||||
task for task in self.tasks.values()
|
||||
if task.get("status") not in TERMINAL_STATUSES
|
||||
]
|
||||
logger.debug(f"[SubAgentManager] 待检查任务: {len(pending_tasks)}")
|
||||
if not pending_tasks:
|
||||
return updates
|
||||
|
||||
state_changed = False
|
||||
for task in pending_tasks:
|
||||
payload = self._call_service("GET", f"/tasks/{task['task_id']}", timeout=10)
|
||||
status = payload.get("status")
|
||||
logger.debug(f"[SubAgentManager] 任务 {task['task_id']} 服务状态: {status}")
|
||||
if status not in TERMINAL_STATUSES:
|
||||
continue
|
||||
result = self._finalize_task(task, payload, status)
|
||||
updates.append(result)
|
||||
state_changed = True
|
||||
|
||||
if state_changed:
|
||||
self._save_state()
|
||||
return updates
|
||||
|
||||
def _call_service(self, method: str, path: str, payload: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
|
||||
url = f"{SUB_AGENT_SERVICE_BASE_URL.rstrip('/')}{path}"
|
||||
try:
|
||||
with httpx.Client(timeout=timeout or 10) as client:
|
||||
if method.upper() == "POST":
|
||||
response = client.post(url, json=payload or {})
|
||||
else:
|
||||
response = client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.RequestError as exc:
|
||||
logger.error(f"子智能体服务请求失败: {exc}")
|
||||
return {"success": False, "error": f"无法连接子智能体服务: {exc}"}
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.error(f"子智能体服务返回错误: {exc}")
|
||||
try:
|
||||
return exc.response.json()
|
||||
except Exception:
|
||||
return {"success": False, "error": f"服务端错误: {exc.response.text}"}
|
||||
except json.JSONDecodeError:
|
||||
return {"success": False, "error": "子智能体服务返回格式错误"}
|
||||
|
||||
def _finalize_task(self, task: Dict, service_payload: Dict, status: str) -> Dict:
|
||||
existing_result = task.get("final_result")
|
||||
if existing_result and task.get("status") in TERMINAL_STATUSES:
|
||||
return existing_result
|
||||
|
||||
task["status"] = status
|
||||
task["updated_at"] = time.time()
|
||||
message = service_payload.get("message") or service_payload.get("error") or ""
|
||||
deliverables_dir = Path(service_payload.get("deliverables_dir") or task.get("deliverables_dir", ""))
|
||||
logger.debug(f"[SubAgentManager] finalize task={task['task_id']} status={status}")
|
||||
|
||||
if status != "completed":
|
||||
result = {
|
||||
"success": False,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": task["agent_id"],
|
||||
"status": status,
|
||||
"message": message or f"子智能体状态:{status}",
|
||||
"details": service_payload,
|
||||
"system_message": self._build_system_message(task, status, None, message),
|
||||
}
|
||||
task["final_result"] = result
|
||||
return result
|
||||
|
||||
if not deliverables_dir.exists():
|
||||
result = {
|
||||
"success": False,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": task["agent_id"],
|
||||
"status": "failed",
|
||||
"error": f"未找到交付目录: {deliverables_dir}",
|
||||
"system_message": self._build_system_message(task, "failed", None, f"未找到交付目录: {deliverables_dir}"),
|
||||
}
|
||||
task["status"] = "failed"
|
||||
task["final_result"] = result
|
||||
return result
|
||||
|
||||
result_md = deliverables_dir / "result.md"
|
||||
if not result_md.exists():
|
||||
result = {
|
||||
"success": False,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": task["agent_id"],
|
||||
"status": "failed",
|
||||
"error": "交付目录缺少 result.md,无法完成任务。",
|
||||
"system_message": self._build_system_message(task, "failed", None, "交付目录缺少 result.md"),
|
||||
}
|
||||
task["status"] = "failed"
|
||||
task["final_result"] = result
|
||||
return result
|
||||
|
||||
copied_path = self._copy_deliverables_to_project(task, deliverables_dir)
|
||||
task["copied_path"] = str(copied_path)
|
||||
|
||||
system_message = self._build_system_message(task, status, copied_path, message)
|
||||
result = {
|
||||
"success": True,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": task["agent_id"],
|
||||
"status": status,
|
||||
"message": message or "子智能体已完成任务。",
|
||||
"deliverables_path": str(deliverables_dir),
|
||||
"copied_path": str(copied_path),
|
||||
"system_message": system_message,
|
||||
"details": service_payload,
|
||||
}
|
||||
task["final_result"] = result
|
||||
return result
|
||||
|
||||
def _copy_deliverables_to_project(self, task: Dict, source_dir: Path) -> Path:
|
||||
"""将交付文件复制到项目目录下的指定文件夹。"""
|
||||
target_dir = Path(task["target_project_dir"])
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_dir = target_dir / f"{task['task_id']}_deliverables"
|
||||
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
shutil.copytree(source_dir, dest_dir)
|
||||
|
||||
return dest_dir
|
||||
|
||||
def _cleanup_task_folder(self, task_root: Path):
|
||||
if task_root.exists():
|
||||
shutil.rmtree(task_root, ignore_errors=True)
|
||||
|
||||
def _validate_create_params(self, agent_id: Optional[int], summary: str, task: str, target_dir: str) -> Optional[str]:
|
||||
if agent_id is None:
|
||||
return "子智能体代号不能为空"
|
||||
try:
|
||||
agent_id = int(agent_id)
|
||||
except ValueError:
|
||||
return "子智能体代号必须是整数"
|
||||
if not (1 <= agent_id <= SUB_AGENT_MAX_ACTIVE):
|
||||
return f"子智能体代号必须在 1~{SUB_AGENT_MAX_ACTIVE} 范围内"
|
||||
if not summary or not summary.strip():
|
||||
return "任务摘要不能为空"
|
||||
if not task or not task.strip():
|
||||
return "任务详情不能为空"
|
||||
if target_dir is None:
|
||||
return "指定文件夹不能为空"
|
||||
return None
|
||||
|
||||
def _build_system_message(
|
||||
self,
|
||||
task: Dict,
|
||||
status: str,
|
||||
copied_path: Optional[Path],
|
||||
extra_message: Optional[str] = None,
|
||||
) -> str:
|
||||
prefix = f"子智能体{task['agent_id']} 任务摘要:{task['summary']}"
|
||||
extra = (extra_message or "").strip()
|
||||
|
||||
if status == "completed" and copied_path:
|
||||
msg = f"{prefix} 已完成,成果已复制到 {copied_path}。"
|
||||
if extra:
|
||||
msg += f" ({extra})"
|
||||
return msg
|
||||
|
||||
if status == "timeout":
|
||||
return f"{prefix} 超时未完成。" + (f" {extra}" if extra else "")
|
||||
|
||||
if status == "failed":
|
||||
return f"{prefix} 执行失败:" + (extra if extra else "请检查交付目录或任务状态。")
|
||||
|
||||
return f"{prefix} 状态:{status}。" + (extra if extra else "")
|
||||
@ -1,507 +0,0 @@
|
||||
# modules/terminal_manager.py - 终端会话管理器
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
try:
|
||||
from config import (
|
||||
OUTPUT_FORMATS,
|
||||
MAX_TERMINALS,
|
||||
TERMINAL_BUFFER_SIZE,
|
||||
TERMINAL_DISPLAY_SIZE,
|
||||
TERMINAL_SNAPSHOT_DEFAULT_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
OUTPUT_FORMATS,
|
||||
MAX_TERMINALS,
|
||||
TERMINAL_BUFFER_SIZE,
|
||||
TERMINAL_DISPLAY_SIZE,
|
||||
TERMINAL_SNAPSHOT_DEFAULT_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS
|
||||
)
|
||||
|
||||
from modules.persistent_terminal import PersistentTerminal
|
||||
from utils.terminal_factory import TerminalFactory
|
||||
|
||||
class TerminalManager:
|
||||
"""管理多个终端会话"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: str,
|
||||
max_terminals: int = None,
|
||||
terminal_buffer_size: int = None,
|
||||
terminal_display_size: int = None,
|
||||
broadcast_callback: Callable = None
|
||||
):
|
||||
"""
|
||||
初始化终端管理器
|
||||
|
||||
Args:
|
||||
project_path: 项目路径
|
||||
max_terminals: 最大终端数量
|
||||
terminal_buffer_size: 每个终端的缓冲区大小
|
||||
terminal_display_size: 显示大小限制
|
||||
broadcast_callback: WebSocket广播回调
|
||||
"""
|
||||
self.project_path = Path(project_path)
|
||||
self.max_terminals = max_terminals or MAX_TERMINALS
|
||||
self.terminal_buffer_size = terminal_buffer_size or TERMINAL_BUFFER_SIZE
|
||||
self.terminal_display_size = terminal_display_size or TERMINAL_DISPLAY_SIZE
|
||||
self.default_snapshot_lines = TERMINAL_SNAPSHOT_DEFAULT_LINES
|
||||
self.max_snapshot_lines = TERMINAL_SNAPSHOT_MAX_LINES
|
||||
self.max_snapshot_chars = TERMINAL_SNAPSHOT_MAX_CHARS
|
||||
self.broadcast = broadcast_callback
|
||||
|
||||
# 终端会话字典
|
||||
self.terminals: Dict[str, PersistentTerminal] = {}
|
||||
|
||||
# 当前活动终端
|
||||
self.active_terminal: Optional[str] = None
|
||||
|
||||
# 终端工厂(跨平台支持)
|
||||
self.factory = TerminalFactory()
|
||||
|
||||
def open_terminal(
|
||||
self,
|
||||
session_name: str,
|
||||
working_dir: str = None,
|
||||
make_active: bool = True
|
||||
) -> Dict:
|
||||
"""
|
||||
打开新终端会话
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
working_dir: 工作目录(相对于项目路径)
|
||||
make_active: 是否设为活动终端
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
# 检查是否已存在
|
||||
if session_name in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{session_name}' 已存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
# 检查数量限制
|
||||
if len(self.terminals) >= self.max_terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"已达到最大终端数量限制 ({self.max_terminals})",
|
||||
"existing_sessions": list(self.terminals.keys()),
|
||||
"suggestion": "请先关闭一个终端会话"
|
||||
}
|
||||
|
||||
# 确定工作目录
|
||||
if working_dir:
|
||||
work_path = self.project_path / working_dir
|
||||
if not work_path.exists():
|
||||
work_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
work_path = self.project_path
|
||||
|
||||
# 获取合适的shell命令
|
||||
shell_command = self.factory.get_shell_command()
|
||||
|
||||
# 创建终端实例
|
||||
terminal = PersistentTerminal(
|
||||
session_name=session_name,
|
||||
working_dir=str(work_path),
|
||||
shell_command=shell_command,
|
||||
broadcast_callback=self.broadcast,
|
||||
max_buffer_size=self.terminal_buffer_size,
|
||||
display_size=self.terminal_display_size
|
||||
)
|
||||
|
||||
# 启动终端
|
||||
if not terminal.start():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "终端启动失败",
|
||||
"session": session_name
|
||||
}
|
||||
|
||||
# 保存终端实例
|
||||
self.terminals[session_name] = terminal
|
||||
|
||||
# 设为活动终端
|
||||
if make_active:
|
||||
self.active_terminal = session_name
|
||||
|
||||
print(f"{OUTPUT_FORMATS['success']} 终端会话已打开: {session_name}")
|
||||
|
||||
# 广播终端列表更新
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': self.get_terminal_list(),
|
||||
'active': self.active_terminal
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": session_name,
|
||||
"working_dir": str(work_path),
|
||||
"shell": shell_command,
|
||||
"is_active": make_active,
|
||||
"total_sessions": len(self.terminals)
|
||||
}
|
||||
|
||||
def close_terminal(self, session_name: str) -> Dict:
|
||||
"""
|
||||
关闭终端会话
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
if session_name not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{session_name}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
# 获取终端实例
|
||||
terminal = self.terminals[session_name]
|
||||
|
||||
# 关闭终端
|
||||
terminal.close()
|
||||
|
||||
# 从字典中移除
|
||||
del self.terminals[session_name]
|
||||
|
||||
# 如果是活动终端,切换到另一个
|
||||
if self.active_terminal == session_name:
|
||||
if self.terminals:
|
||||
self.active_terminal = list(self.terminals.keys())[0]
|
||||
else:
|
||||
self.active_terminal = None
|
||||
|
||||
print(f"{OUTPUT_FORMATS['info']} 终端会话已关闭: {session_name}")
|
||||
|
||||
# 广播终端列表更新
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': self.get_terminal_list(),
|
||||
'active': self.active_terminal
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": session_name,
|
||||
"remaining_sessions": list(self.terminals.keys()),
|
||||
"new_active": self.active_terminal
|
||||
}
|
||||
|
||||
def reset_terminal(self, session_name: Optional[str]) -> Dict:
|
||||
"""
|
||||
重置终端会话:关闭并重新创建同名会话
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
target_session = session_name or self.active_terminal
|
||||
if not target_session:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "没有活动终端会话",
|
||||
"suggestion": "请先使用 terminal_session 打开一个终端"
|
||||
}
|
||||
|
||||
if target_session not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
terminal = self.terminals[target_session]
|
||||
working_dir = str(terminal.working_dir)
|
||||
shell_command = terminal.shell_command or self.factory.get_shell_command()
|
||||
|
||||
terminal.close()
|
||||
del self.terminals[target_session]
|
||||
|
||||
new_terminal = PersistentTerminal(
|
||||
session_name=target_session,
|
||||
working_dir=working_dir,
|
||||
shell_command=shell_command,
|
||||
broadcast_callback=self.broadcast,
|
||||
max_buffer_size=self.terminal_buffer_size,
|
||||
display_size=self.terminal_display_size
|
||||
)
|
||||
|
||||
if not new_terminal.start():
|
||||
if self.terminals:
|
||||
self.active_terminal = next(iter(self.terminals.keys()))
|
||||
else:
|
||||
self.active_terminal = None
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': self.get_terminal_list(),
|
||||
'active': self.active_terminal
|
||||
})
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 重置失败:无法重新启动进程",
|
||||
"working_dir": working_dir
|
||||
}
|
||||
|
||||
self.terminals[target_session] = new_terminal
|
||||
self.active_terminal = target_session
|
||||
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_reset', {
|
||||
'session': target_session,
|
||||
'working_dir': working_dir,
|
||||
'shell': shell_command,
|
||||
'time': datetime.now().isoformat()
|
||||
})
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': self.get_terminal_list(),
|
||||
'active': self.active_terminal
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": target_session,
|
||||
"working_dir": working_dir,
|
||||
"shell": shell_command,
|
||||
"message": "终端会话已重置并重新启动"
|
||||
}
|
||||
|
||||
def switch_terminal(self, session_name: str) -> Dict:
|
||||
"""
|
||||
切换活动终端
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
if session_name not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{session_name}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
previous_active = self.active_terminal
|
||||
self.active_terminal = session_name
|
||||
|
||||
print(f"{OUTPUT_FORMATS['info']} 切换到终端: {session_name}")
|
||||
|
||||
# 广播切换事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_switched', {
|
||||
'previous': previous_active,
|
||||
'current': session_name
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"previous": previous_active,
|
||||
"current": session_name,
|
||||
"status": self.terminals[session_name].get_status()
|
||||
}
|
||||
|
||||
def list_terminals(self) -> Dict:
|
||||
"""
|
||||
列出所有终端会话
|
||||
|
||||
Returns:
|
||||
终端列表
|
||||
"""
|
||||
sessions = []
|
||||
for name, terminal in self.terminals.items():
|
||||
status = terminal.get_status()
|
||||
status['is_active'] = (name == self.active_terminal)
|
||||
sessions.append(status)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sessions": sessions,
|
||||
"active": self.active_terminal,
|
||||
"total": len(self.terminals),
|
||||
"max_allowed": self.max_terminals
|
||||
}
|
||||
|
||||
def send_to_terminal(
|
||||
self,
|
||||
command: str,
|
||||
session_name: str = None,
|
||||
wait_for_output: bool = True,
|
||||
output_wait: float = None
|
||||
) -> Dict:
|
||||
"""
|
||||
向终端发送命令
|
||||
|
||||
Args:
|
||||
command: 要执行的命令
|
||||
session_name: 目标终端(None则使用活动终端)
|
||||
wait_for_output: 是否等待输出
|
||||
output_wait: 等待输出的最大秒数
|
||||
|
||||
Returns:
|
||||
执行结果
|
||||
"""
|
||||
# 确定目标终端
|
||||
target_session = session_name or self.active_terminal
|
||||
|
||||
if not target_session:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "没有活动终端会话",
|
||||
"suggestion": "请先使用 terminal_session 打开一个终端"
|
||||
}
|
||||
|
||||
if target_session not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
# 发送命令
|
||||
terminal = self.terminals[target_session]
|
||||
result = terminal.send_command(command, wait_for_output, timeout=output_wait)
|
||||
result["timeout"] = output_wait
|
||||
result["output_wait"] = output_wait
|
||||
|
||||
return result
|
||||
|
||||
def get_terminal_output(
|
||||
self,
|
||||
session_name: str = None,
|
||||
last_n_lines: int = 50
|
||||
) -> Dict:
|
||||
"""
|
||||
获取终端输出
|
||||
|
||||
Args:
|
||||
session_name: 终端名称(None则使用活动终端)
|
||||
last_n_lines: 获取最后N行
|
||||
|
||||
Returns:
|
||||
输出内容
|
||||
"""
|
||||
target_session = session_name or self.active_terminal
|
||||
|
||||
if not target_session:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "没有活动终端会话"
|
||||
}
|
||||
|
||||
if target_session not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 不存在"
|
||||
}
|
||||
|
||||
terminal = self.terminals[target_session]
|
||||
output = terminal.get_output(last_n_lines)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": target_session,
|
||||
"output": output,
|
||||
"is_interactive": terminal.is_interactive,
|
||||
"last_command": terminal.last_command,
|
||||
"seconds_since_last_output": terminal._seconds_since_last_output(),
|
||||
"echo_loop_detected": terminal.echo_loop_detected
|
||||
}
|
||||
|
||||
def get_terminal_snapshot(
|
||||
self,
|
||||
session_name: str = None,
|
||||
lines: int = None,
|
||||
max_chars: int = None
|
||||
) -> Dict:
|
||||
"""
|
||||
获取终端输出快照
|
||||
|
||||
Args:
|
||||
session_name: 指定会话(默认使用活动会话)
|
||||
lines: 返回的最大行数
|
||||
max_chars: 返回的最大字符数
|
||||
|
||||
Returns:
|
||||
包含快照内容和状态的字典
|
||||
"""
|
||||
target_session = session_name or self.active_terminal
|
||||
if not target_session:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "没有活动终端会话",
|
||||
"suggestion": "请先使用 terminal_session 打开一个终端"
|
||||
}
|
||||
|
||||
if target_session not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
line_limit = lines if lines is not None else self.default_snapshot_lines
|
||||
line_limit = max(1, min(line_limit, self.max_snapshot_lines))
|
||||
char_limit = max(100, min(max_chars if max_chars else self.max_snapshot_chars, self.max_snapshot_chars))
|
||||
|
||||
terminal = self.terminals[target_session]
|
||||
snapshot = terminal.get_snapshot(line_limit, char_limit)
|
||||
snapshot.update({
|
||||
"line_limit": line_limit,
|
||||
"char_limit": char_limit,
|
||||
"session": target_session
|
||||
})
|
||||
|
||||
if snapshot.get("truncated"):
|
||||
snapshot["note"] = f"输出已截断,仅返回了末尾的 {char_limit} 个字符"
|
||||
|
||||
return snapshot
|
||||
|
||||
def get_terminal_list(self) -> List[Dict]:
|
||||
"""获取终端列表(简化版)"""
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"is_active": name == self.active_terminal,
|
||||
"is_running": terminal.is_running,
|
||||
"working_dir": str(terminal.working_dir)
|
||||
}
|
||||
for name, terminal in self.terminals.items()
|
||||
]
|
||||
|
||||
def close_all(self):
|
||||
"""关闭所有终端会话"""
|
||||
print(f"{OUTPUT_FORMATS['info']} 关闭所有终端会话...")
|
||||
|
||||
for session_name in list(self.terminals.keys()):
|
||||
self.close_terminal(session_name)
|
||||
|
||||
self.active_terminal = None
|
||||
print(f"{OUTPUT_FORMATS['success']} 所有终端会话已关闭")
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保所有终端被关闭"""
|
||||
self.close_all()
|
||||
@ -1,422 +0,0 @@
|
||||
# modules/terminal_ops.py - 终端操作模块(修复Python命令检测)
|
||||
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import subprocess
|
||||
import shutil
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
try:
|
||||
from config import (
|
||||
CODE_EXECUTION_TIMEOUT,
|
||||
TERMINAL_COMMAND_TIMEOUT,
|
||||
FORBIDDEN_COMMANDS,
|
||||
OUTPUT_FORMATS
|
||||
)
|
||||
except ImportError:
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
CODE_EXECUTION_TIMEOUT,
|
||||
TERMINAL_COMMAND_TIMEOUT,
|
||||
FORBIDDEN_COMMANDS,
|
||||
OUTPUT_FORMATS
|
||||
)
|
||||
|
||||
class TerminalOperator:
|
||||
def __init__(self, project_path: str):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.process = None
|
||||
# 自动检测Python命令,并尝试复用预装虚拟环境
|
||||
self._python_env: Dict[str, str] = {}
|
||||
self.python_cmd = self._detect_python_runtime()
|
||||
print(f"{OUTPUT_FORMATS['info']} 检测到Python命令: {self.python_cmd}")
|
||||
|
||||
def _detect_python_runtime(self) -> str:
|
||||
"""优先选择预装虚拟环境的 Python,其次回退系统可执行文件。"""
|
||||
preferred = self._detect_preinstalled_python()
|
||||
if preferred:
|
||||
self._python_env = self._build_python_env(preferred)
|
||||
return preferred
|
||||
return self._detect_system_python()
|
||||
|
||||
def _detect_preinstalled_python(self) -> Optional[str]:
|
||||
candidates = []
|
||||
env_venv = os.environ.get("AGENT_TOOLBOX_VENV") or os.environ.get("VIRTUAL_ENV")
|
||||
if env_venv:
|
||||
candidates.append(env_venv)
|
||||
|
||||
candidates.append("/opt/agent-venv")
|
||||
|
||||
seen = set()
|
||||
for raw in candidates:
|
||||
if not raw or raw in seen:
|
||||
continue
|
||||
seen.add(raw)
|
||||
root = Path(raw).expanduser()
|
||||
bin_dir = root / ("Scripts" if sys.platform == "win32" else "bin")
|
||||
for name in ("python3", "python"):
|
||||
cand = bin_dir / name
|
||||
if cand.exists() and os.access(cand, os.X_OK):
|
||||
return str(cand.resolve())
|
||||
return None
|
||||
|
||||
def _detect_system_python(self) -> str:
|
||||
if sys.platform == "win32":
|
||||
commands_to_try = ["python", "py", "python3"]
|
||||
else:
|
||||
commands_to_try = ["python3", "python"]
|
||||
|
||||
for cmd in commands_to_try:
|
||||
if shutil.which(cmd):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cmd, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
output = result.stdout + result.stderr
|
||||
if "Python 3" in output or "Python 2" not in output:
|
||||
return cmd
|
||||
except Exception:
|
||||
continue
|
||||
return "python" if sys.platform == "win32" else "python3"
|
||||
|
||||
def _build_python_env(self, python_path: str) -> Dict[str, str]:
|
||||
env: Dict[str, str] = {}
|
||||
try:
|
||||
py_path = Path(python_path).resolve()
|
||||
bin_dir = py_path.parent
|
||||
venv_dir = bin_dir.parent
|
||||
env["VIRTUAL_ENV"] = str(venv_dir)
|
||||
current_path = os.environ.get("PATH", "")
|
||||
prefix = str(bin_dir)
|
||||
if current_path.startswith(prefix + os.pathsep) or current_path == prefix:
|
||||
env["PATH"] = current_path
|
||||
else:
|
||||
env["PATH"] = f"{prefix}{os.pathsep}{current_path}" if current_path else prefix
|
||||
except Exception:
|
||||
pass
|
||||
return env
|
||||
|
||||
def _validate_command(self, command: str) -> Tuple[bool, str]:
|
||||
"""验证命令安全性"""
|
||||
# 检查禁止的命令
|
||||
for forbidden in FORBIDDEN_COMMANDS:
|
||||
if forbidden in command.lower():
|
||||
return False, f"禁止执行的命令: {forbidden}"
|
||||
|
||||
# 检查危险的命令模式
|
||||
dangerous_patterns = [
|
||||
"sudo",
|
||||
"chmod 777",
|
||||
"rm -rf",
|
||||
"> /dev/",
|
||||
"fork bomb"
|
||||
]
|
||||
|
||||
for pattern in dangerous_patterns:
|
||||
if pattern in command.lower():
|
||||
return False, f"检测到危险命令模式: {pattern}"
|
||||
|
||||
return True, ""
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
command: str,
|
||||
working_dir: str = None,
|
||||
timeout: int = None
|
||||
) -> Dict:
|
||||
"""
|
||||
执行终端命令
|
||||
|
||||
Args:
|
||||
command: 要执行的命令
|
||||
working_dir: 工作目录
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
执行结果字典
|
||||
"""
|
||||
# 替换 python/python3,确保命中预装虚拟环境
|
||||
if re.search(r"\bpython3?\b", command):
|
||||
command = re.sub(r"\bpython3?\b", self.python_cmd, command)
|
||||
|
||||
# 验证命令
|
||||
valid, error = self._validate_command(command)
|
||||
if not valid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": error,
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
# 设置工作目录
|
||||
if working_dir:
|
||||
work_path = (self.project_path / working_dir).resolve()
|
||||
# 确保工作目录在项目内
|
||||
try:
|
||||
work_path.relative_to(self.project_path)
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "工作目录必须在项目文件夹内",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
else:
|
||||
work_path = self.project_path
|
||||
|
||||
timeout = timeout or TERMINAL_COMMAND_TIMEOUT
|
||||
|
||||
print(f"{OUTPUT_FORMATS['terminal']} 执行命令: {command}")
|
||||
print(f"{OUTPUT_FORMATS['info']} 工作目录: {work_path}")
|
||||
|
||||
try:
|
||||
# 创建进程
|
||||
env = os.environ.copy()
|
||||
if self._python_env:
|
||||
env.update(self._python_env)
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(work_path),
|
||||
shell=True,
|
||||
env=env
|
||||
)
|
||||
|
||||
# 等待执行完成
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"命令执行超时 ({timeout}秒)",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
# 解码输出
|
||||
stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
|
||||
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
|
||||
|
||||
output = stdout_text
|
||||
if stderr_text:
|
||||
output += f"\n[错误输出]\n{stderr_text}"
|
||||
|
||||
success = process.returncode == 0
|
||||
|
||||
if success:
|
||||
print(f"{OUTPUT_FORMATS['success']} 命令执行成功")
|
||||
else:
|
||||
print(f"{OUTPUT_FORMATS['error']} 命令执行失败 (返回码: {process.returncode})")
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"output": output,
|
||||
"stdout": stdout_text,
|
||||
"stderr": stderr_text,
|
||||
"return_code": process.returncode,
|
||||
"command": command
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"执行失败: {str(e)}",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
async def run_python_code(
|
||||
self,
|
||||
code: str,
|
||||
timeout: int = None
|
||||
) -> Dict:
|
||||
"""
|
||||
执行Python代码
|
||||
|
||||
Args:
|
||||
code: Python代码
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
执行结果字典
|
||||
"""
|
||||
timeout = timeout or CODE_EXECUTION_TIMEOUT
|
||||
|
||||
# 创建临时Python文件
|
||||
temp_file = self.project_path / ".temp_code.py"
|
||||
|
||||
try:
|
||||
# 写入代码
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
f.write(code)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['code']} 执行Python代码")
|
||||
|
||||
# 使用检测到的Python命令执行文件
|
||||
result = await self.run_command(
|
||||
f'{self.python_cmd} "{temp_file}"',
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 添加代码到结果
|
||||
result["code"] = code
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if temp_file.exists():
|
||||
temp_file.unlink()
|
||||
|
||||
async def run_python_file(
|
||||
self,
|
||||
file_path: str,
|
||||
args: str = "",
|
||||
timeout: int = None
|
||||
) -> Dict:
|
||||
"""
|
||||
执行Python文件
|
||||
|
||||
Args:
|
||||
file_path: Python文件路径
|
||||
args: 命令行参数
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
执行结果字典
|
||||
"""
|
||||
# 构建完整路径
|
||||
full_path = (self.project_path / file_path).resolve()
|
||||
|
||||
# 验证文件存在
|
||||
if not full_path.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "文件不存在",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
# 验证是Python文件
|
||||
if not full_path.suffix == '.py':
|
||||
return {
|
||||
"success": False,
|
||||
"error": "不是Python文件",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
# 验证文件在项目内
|
||||
try:
|
||||
full_path.relative_to(self.project_path)
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "文件必须在项目文件夹内",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
print(f"{OUTPUT_FORMATS['code']} 执行Python文件: {file_path}")
|
||||
|
||||
# 使用检测到的Python命令构建命令
|
||||
command = f'{self.python_cmd} "{full_path}"'
|
||||
if args:
|
||||
command += f" {args}"
|
||||
|
||||
# 执行命令
|
||||
return await self.run_command(command, timeout=timeout)
|
||||
|
||||
async def install_package(self, package: str) -> Dict:
|
||||
"""
|
||||
安装Python包
|
||||
|
||||
Args:
|
||||
package: 包名
|
||||
|
||||
Returns:
|
||||
安装结果
|
||||
"""
|
||||
print(f"{OUTPUT_FORMATS['terminal']} 安装包: {package}")
|
||||
|
||||
# 使用检测到的Python命令安装
|
||||
command = f'{self.python_cmd} -m pip install {package}'
|
||||
|
||||
result = await self.run_command(command, timeout=120)
|
||||
|
||||
if result["success"]:
|
||||
print(f"{OUTPUT_FORMATS['success']} 包安装成功: {package}")
|
||||
else:
|
||||
print(f"{OUTPUT_FORMATS['error']} 包安装失败: {package}")
|
||||
|
||||
return result
|
||||
|
||||
async def check_environment(self) -> Dict:
|
||||
"""检查Python环境"""
|
||||
print(f"{OUTPUT_FORMATS['info']} 检查Python环境...")
|
||||
|
||||
env_info = {
|
||||
"python_command": self.python_cmd,
|
||||
"python_version": "",
|
||||
"pip_version": "",
|
||||
"installed_packages": [],
|
||||
"working_directory": str(self.project_path)
|
||||
}
|
||||
|
||||
# 获取Python版本
|
||||
version_result = await self.run_command(
|
||||
f'{self.python_cmd} --version',
|
||||
timeout=5
|
||||
)
|
||||
if version_result["success"]:
|
||||
env_info["python_version"] = version_result["output"].strip()
|
||||
|
||||
# 获取pip版本
|
||||
pip_result = await self.run_command(
|
||||
f'{self.python_cmd} -m pip --version',
|
||||
timeout=5
|
||||
)
|
||||
if pip_result["success"]:
|
||||
env_info["pip_version"] = pip_result["output"].strip()
|
||||
|
||||
# 获取已安装的包
|
||||
packages_result = await self.run_command(
|
||||
f'{self.python_cmd} -m pip list --format=json',
|
||||
timeout=10
|
||||
)
|
||||
if packages_result["success"]:
|
||||
try:
|
||||
import json
|
||||
packages = json.loads(packages_result["output"])
|
||||
env_info["installed_packages"] = [
|
||||
f"{p['name']}=={p['version']}" for p in packages
|
||||
]
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"environment": env_info
|
||||
}
|
||||
|
||||
def kill_process(self):
|
||||
"""终止当前运行的进程"""
|
||||
if self.process and self.process.returncode is None:
|
||||
self.process.kill()
|
||||
print(f"{OUTPUT_FORMATS['warning']} 进程已终止")
|
||||
@ -1,218 +0,0 @@
|
||||
# modules/todo_manager.py - TODO 列表管理
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
try:
|
||||
from config import (
|
||||
TODO_MAX_TASKS,
|
||||
TODO_MAX_OVERVIEW_LENGTH,
|
||||
TODO_MAX_TASK_LENGTH,
|
||||
)
|
||||
except ImportError: # pragma: no cover
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import ( # type: ignore
|
||||
TODO_MAX_TASKS,
|
||||
TODO_MAX_OVERVIEW_LENGTH,
|
||||
TODO_MAX_TASK_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
class TodoManager:
|
||||
"""负责创建、更新和结束 TODO 列表"""
|
||||
|
||||
MAX_TASKS = TODO_MAX_TASKS
|
||||
MAX_OVERVIEW_LENGTH = TODO_MAX_OVERVIEW_LENGTH
|
||||
MAX_TASK_LENGTH = TODO_MAX_TASK_LENGTH
|
||||
|
||||
def __init__(self, context_manager):
|
||||
self.context_manager = context_manager
|
||||
|
||||
def _get_current(self) -> Optional[Dict[str, Any]]:
|
||||
todo = getattr(self.context_manager, "todo_list", None)
|
||||
return deepcopy(todo) if todo else None
|
||||
|
||||
def _save(self, todo: Optional[Dict[str, Any]]):
|
||||
self.context_manager.set_todo_list(todo)
|
||||
|
||||
def _normalize_tasks(self, tasks: List[Any]) -> List[str]:
|
||||
normalized = []
|
||||
for item in tasks:
|
||||
title = ""
|
||||
if isinstance(item, dict):
|
||||
title = item.get("title", "")
|
||||
else:
|
||||
title = str(item)
|
||||
title = title.strip()
|
||||
if not title:
|
||||
continue
|
||||
normalized.append(title)
|
||||
if len(normalized) >= self.MAX_TASKS:
|
||||
break
|
||||
return normalized
|
||||
|
||||
def create_todo_list(self, overview: str, tasks: List[Any]) -> Dict[str, Any]:
|
||||
current = self._get_current()
|
||||
if current and current.get("status") == "active":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "已有进行中的 TODO 列表,请先完成或结束后再创建新的列表。"
|
||||
}
|
||||
|
||||
overview = (overview or "").strip()
|
||||
if not overview:
|
||||
return {"success": False, "error": "任务概述不能为空。"}
|
||||
if len(overview) > self.MAX_OVERVIEW_LENGTH:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"任务概述过长(当前 {len(overview)} 字),请精简至 {self.MAX_OVERVIEW_LENGTH} 字以内。"
|
||||
}
|
||||
|
||||
normalized_tasks = self._normalize_tasks(tasks or [])
|
||||
if not normalized_tasks:
|
||||
return {"success": False, "error": "需要至少提供一个任务。"}
|
||||
if len(tasks or []) > self.MAX_TASKS:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"任务数量过多,最多允许 {self.MAX_TASKS} 个任务。"
|
||||
}
|
||||
|
||||
for title in normalized_tasks:
|
||||
if len(title) > self.MAX_TASK_LENGTH:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"任务「{title}」过长,请控制在 {self.MAX_TASK_LENGTH} 字以内。"
|
||||
}
|
||||
|
||||
todo = {
|
||||
"overview": overview,
|
||||
"tasks": [
|
||||
{
|
||||
"index": idx,
|
||||
"title": title,
|
||||
"status": "pending"
|
||||
}
|
||||
for idx, title in enumerate(normalized_tasks, start=1)
|
||||
],
|
||||
"status": "active",
|
||||
"forced_finish": False,
|
||||
"forced_reason": None
|
||||
}
|
||||
|
||||
self._save(todo)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "待办列表已创建。请先完成某项任务,再调用待办工具将其标记完成。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
def update_task_status(self, task_index: int, completed: bool) -> Dict[str, Any]:
|
||||
todo = self._get_current()
|
||||
if not todo:
|
||||
return {"success": False, "error": "当前没有待办列表,请先创建。"}
|
||||
if todo.get("status") in {"completed", "closed"}:
|
||||
return {"success": False, "error": "待办列表已结束,无法继续修改。"}
|
||||
|
||||
if not isinstance(task_index, int):
|
||||
return {"success": False, "error": "task_index 必须是数字。"}
|
||||
if task_index < 1 or task_index > len(todo["tasks"]):
|
||||
return {"success": False, "error": f"task_index 超出范围(1-{len(todo['tasks'])})。"}
|
||||
|
||||
task = todo["tasks"][task_index - 1]
|
||||
new_status = "done" if completed else "pending"
|
||||
if task["status"] == new_status:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "任务状态未发生变化。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
task["status"] = new_status
|
||||
|
||||
self._save(todo)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"任务 task{task_index} 已标记为 {'完成' if completed else '未完成'}。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
def finish_todo(self, reason: Optional[str] = None) -> Dict[str, Any]:
|
||||
todo = self._get_current()
|
||||
if not todo:
|
||||
return {"success": False, "error": "当前没有待办列表。"}
|
||||
|
||||
if todo.get("status") in {"completed", "closed"}:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "待办列表已结束,无需重复操作。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
all_done = all(task["status"] == "done" for task in todo["tasks"])
|
||||
if all_done:
|
||||
todo["status"] = "completed"
|
||||
todo["forced_finish"] = False
|
||||
todo["forced_reason"] = None
|
||||
self._save(todo)
|
||||
system_note = "✅ TODO 列表中的所有任务已完成,可以整理成果并向用户汇报。"
|
||||
return {
|
||||
"success": True,
|
||||
"message": "所有任务已完成,待办列表已结束。",
|
||||
"todo_list": todo,
|
||||
"system_note": system_note
|
||||
}
|
||||
|
||||
remaining = [
|
||||
f"task{task['index']}"
|
||||
for task in todo["tasks"]
|
||||
if task["status"] != "done"
|
||||
]
|
||||
return {
|
||||
"success": False,
|
||||
"requires_confirmation": True,
|
||||
"message": "仍有未完成的任务,确认要提前结束吗?",
|
||||
"remaining": remaining,
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
def confirm_finish(self, confirm: bool, reason: Optional[str] = None) -> Dict[str, Any]:
|
||||
todo = self._get_current()
|
||||
if not todo:
|
||||
return {"success": False, "error": "当前没有待办列表。"}
|
||||
if todo.get("status") in {"completed", "closed"}:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "待办列表已结束,无需重复操作。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
if not confirm:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "已取消结束待办列表,继续执行剩余任务。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
todo["status"] = "closed"
|
||||
todo["forced_finish"] = True
|
||||
todo["forced_reason"] = (reason or "").strip() or None
|
||||
self._save(todo)
|
||||
|
||||
system_note = "⚠️ TODO 列表在任务未全部完成的情况下被结束,请在总结中说明原因。"
|
||||
self.context_manager.add_conversation("system", system_note)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "待办列表已强制结束。",
|
||||
"todo_list": todo,
|
||||
"system_note": system_note
|
||||
}
|
||||
|
||||
def get_snapshot(self) -> Optional[Dict[str, Any]]:
|
||||
return self._get_current()
|
||||
@ -1,280 +0,0 @@
|
||||
"""User and workspace management utilities for multi-user support."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from config import (
|
||||
ADMIN_PASSWORD_HASH,
|
||||
ADMIN_USERNAME,
|
||||
INVITE_CODES_FILE,
|
||||
USER_SPACE_DIR,
|
||||
USERS_DB_FILE,
|
||||
UPLOAD_QUARANTINE_SUBDIR,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserRecord:
|
||||
username: str
|
||||
email: str
|
||||
password_hash: str
|
||||
created_at: str
|
||||
invite_code: Optional[str] = None
|
||||
role: str = "user"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserWorkspace:
|
||||
username: str
|
||||
root: Path
|
||||
project_path: Path
|
||||
data_dir: Path
|
||||
logs_dir: Path
|
||||
uploads_dir: Path
|
||||
quarantine_dir: Path
|
||||
|
||||
|
||||
class UserManager:
|
||||
"""Handle user registration, authentication and workspace provisioning."""
|
||||
|
||||
USERNAME_REGEX = re.compile(r"^[a-z0-9_\-]{3,32}$")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
users_file: str = USERS_DB_FILE,
|
||||
invite_codes_file: str = INVITE_CODES_FILE,
|
||||
workspace_root: str = USER_SPACE_DIR,
|
||||
):
|
||||
self.users_file = Path(users_file)
|
||||
self.invite_codes_file = Path(invite_codes_file)
|
||||
self.workspace_root = Path(workspace_root).expanduser().resolve()
|
||||
self.workspace_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._users: Dict[str, UserRecord] = {}
|
||||
self._invites: Dict[str, Dict] = {}
|
||||
self._email_map: Dict[str, str] = {}
|
||||
|
||||
self._load_users()
|
||||
self._load_invite_codes()
|
||||
self._ensure_admin_user()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
def register_user(
|
||||
self, username: str, email: str, password: str, invite_code: str
|
||||
) -> Tuple[UserRecord, UserWorkspace]:
|
||||
username = self._normalize_username(username)
|
||||
email = self._normalize_email(email)
|
||||
password = password.strip()
|
||||
invite_code = (invite_code or "").strip()
|
||||
|
||||
if not password or len(password) < 8:
|
||||
raise ValueError("密码长度至少 8 位。")
|
||||
if username in self._users:
|
||||
raise ValueError("该用户名已被注册。")
|
||||
if email in self._email_map:
|
||||
raise ValueError("该邮箱已被注册。")
|
||||
|
||||
invite_entry = self._validate_invite_code(invite_code)
|
||||
password_hash = generate_password_hash(password)
|
||||
created_at = datetime.utcnow().isoformat()
|
||||
|
||||
record = UserRecord(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
created_at=created_at,
|
||||
invite_code=invite_entry["code"],
|
||||
)
|
||||
self._users[username] = record
|
||||
self._index_user(record)
|
||||
self._save_users()
|
||||
self._consume_invite(invite_entry)
|
||||
|
||||
workspace = self.ensure_user_workspace(username)
|
||||
return record, workspace
|
||||
|
||||
def authenticate(self, email: str, password: str) -> Optional[UserRecord]:
|
||||
email = (email or "").strip().lower()
|
||||
username = self._email_map.get(email)
|
||||
if not username:
|
||||
return None
|
||||
record = self._users.get(username)
|
||||
if not record or not record.password_hash:
|
||||
return None
|
||||
if not check_password_hash(record.password_hash, password or ""):
|
||||
return None
|
||||
return record
|
||||
|
||||
def get_user(self, username: str) -> Optional[UserRecord]:
|
||||
return self._users.get((username or "").strip().lower())
|
||||
|
||||
def ensure_user_workspace(self, username: str) -> UserWorkspace:
|
||||
username = self._normalize_username(username)
|
||||
root = (self.workspace_root / username).resolve()
|
||||
project_path = root / "project"
|
||||
data_dir = root / "data"
|
||||
logs_dir = root / "logs"
|
||||
uploads_dir = project_path / "user_upload"
|
||||
|
||||
for path in [project_path, data_dir, logs_dir, uploads_dir]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 初始化数据子目录
|
||||
(data_dir / "conversations").mkdir(parents=True, exist_ok=True)
|
||||
(data_dir / "backups").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
|
||||
if not quarantine_root.is_absolute():
|
||||
quarantine_root = (self.workspace_root.parent / UPLOAD_QUARANTINE_SUBDIR).resolve()
|
||||
quarantine_dir = (quarantine_root / username).resolve()
|
||||
quarantine_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return UserWorkspace(
|
||||
username=username,
|
||||
root=root,
|
||||
project_path=project_path,
|
||||
data_dir=data_dir,
|
||||
logs_dir=logs_dir,
|
||||
uploads_dir=uploads_dir,
|
||||
quarantine_dir=quarantine_dir,
|
||||
)
|
||||
|
||||
def list_invite_codes(self):
|
||||
return list(self._invites.values())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _normalize_username(self, username: str) -> str:
|
||||
candidate = (username or "").strip().lower()
|
||||
if not candidate or not self.USERNAME_REGEX.match(candidate):
|
||||
raise ValueError("用户名需为 3-32 位小写字母、数字、下划线或连字符。")
|
||||
return candidate
|
||||
|
||||
def _normalize_email(self, email: str) -> str:
|
||||
email = (email or "").strip().lower()
|
||||
if "@" not in email or len(email) < 6:
|
||||
raise ValueError("邮箱格式不正确。")
|
||||
return email
|
||||
|
||||
def _index_user(self, record: UserRecord):
|
||||
email = (record.email or '').strip().lower()
|
||||
if email:
|
||||
self._email_map[email] = record.username
|
||||
|
||||
def _load_users(self):
|
||||
if not self.users_file.exists():
|
||||
self._save_users()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.users_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
raw_users = data.get("users", {})
|
||||
elif isinstance(data, list):
|
||||
raw_users = {item.get("username"): item for item in data if isinstance(item, dict) and item.get("username")}
|
||||
else:
|
||||
raw_users = {}
|
||||
for username, payload in raw_users.items():
|
||||
record = UserRecord(
|
||||
username=username,
|
||||
email=payload.get("email", ""),
|
||||
password_hash=payload.get("password_hash", ""),
|
||||
created_at=payload.get("created_at", ""),
|
||||
invite_code=payload.get("invite_code"),
|
||||
role=payload.get("role", "user"),
|
||||
)
|
||||
self._users[username] = record
|
||||
self._index_user(record)
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(f"无法解析用户数据文件: {self.users_file}")
|
||||
|
||||
def _save_users(self):
|
||||
payload = {
|
||||
"users": {
|
||||
username: {
|
||||
"email": record.email,
|
||||
"password_hash": record.password_hash,
|
||||
"created_at": record.created_at,
|
||||
"invite_code": record.invite_code,
|
||||
"role": record.role,
|
||||
}
|
||||
for username, record in self._users.items()
|
||||
}
|
||||
}
|
||||
self.users_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.users_file, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _load_invite_codes(self):
|
||||
if not self.invite_codes_file.exists():
|
||||
self._save_invite_codes({})
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.invite_codes_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
codes = data.get("codes", [])
|
||||
elif isinstance(data, list):
|
||||
codes = data
|
||||
else:
|
||||
codes = []
|
||||
self._invites = {item["code"]: item for item in codes if isinstance(item, dict) and "code" in item}
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(f"无法解析邀请码文件: {self.invite_codes_file}")
|
||||
|
||||
def _save_invite_codes(self, overrides: Optional[Dict[str, Dict]] = None):
|
||||
codes = overrides or self._invites
|
||||
payload = {"codes": list(codes.values())}
|
||||
self.invite_codes_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.invite_codes_file, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _validate_invite_code(self, code: str) -> Dict:
|
||||
if not code:
|
||||
raise ValueError("邀请码不能为空。")
|
||||
entry = self._invites.get(code)
|
||||
if not entry:
|
||||
raise ValueError("邀请码不存在或已失效。")
|
||||
|
||||
remaining = entry.get("remaining")
|
||||
if remaining is not None and remaining <= 0:
|
||||
raise ValueError("邀请码已被使用。")
|
||||
return entry
|
||||
|
||||
def _consume_invite(self, entry: Dict):
|
||||
if entry.get("remaining") is None:
|
||||
return
|
||||
entry["remaining"] = max(0, entry["remaining"] - 1)
|
||||
self._save_invite_codes()
|
||||
|
||||
def _ensure_admin_user(self):
|
||||
admin_name = (ADMIN_USERNAME or "").strip().lower()
|
||||
if not admin_name or not ADMIN_PASSWORD_HASH:
|
||||
return
|
||||
|
||||
if admin_name in self._users:
|
||||
return
|
||||
|
||||
record = UserRecord(
|
||||
username=admin_name,
|
||||
email=f"{admin_name}@local",
|
||||
password_hash=ADMIN_PASSWORD_HASH,
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
invite_code=None,
|
||||
role="admin",
|
||||
)
|
||||
self._users[admin_name] = record
|
||||
self._index_user(record)
|
||||
self._save_users()
|
||||
self.ensure_user_workspace(admin_name)
|
||||
@ -1,125 +0,0 @@
|
||||
# modules/webpage_extractor.py - 网页内容提取模块
|
||||
|
||||
import httpx
|
||||
import json
|
||||
from typing import Dict, Any, List, Union, Tuple
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
async def tavily_extract(urls: Union[str, List[str]], api_key: str, extract_depth: str = "basic", max_urls: int = 1) -> Dict[str, Any]:
|
||||
"""
|
||||
执行Tavily网页内容提取
|
||||
|
||||
Args:
|
||||
urls: 要提取的URL(字符串或列表)
|
||||
api_key: Tavily API密钥
|
||||
extract_depth: 提取深度 (basic/advanced)
|
||||
max_urls: 最大提取URL数量
|
||||
|
||||
Returns:
|
||||
提取结果字典
|
||||
"""
|
||||
if not api_key:
|
||||
return {"error": "Tavily API密钥未配置"}
|
||||
|
||||
# 确保urls是列表
|
||||
if isinstance(urls, str):
|
||||
urls = [urls]
|
||||
|
||||
# 限制URL数量
|
||||
urls = urls[:max_urls]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://api.tavily.com/extract",
|
||||
json={
|
||||
"urls": urls,
|
||||
"extract_depth": extract_depth,
|
||||
"include_images": False,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
return {"error": f"API请求失败: HTTP {response.status_code}"}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "请求超时,网页响应过慢"}
|
||||
except httpx.RequestError as e:
|
||||
return {"error": f"网络请求错误: {str(e)}"}
|
||||
except Exception as e:
|
||||
logger.error(f"网页提取异常: {e}")
|
||||
return {"error": f"提取异常: {str(e)}"}
|
||||
|
||||
|
||||
def format_extract_results(results: Dict[str, Any]) -> str:
|
||||
"""
|
||||
格式化提取结果为简洁版本
|
||||
|
||||
Args:
|
||||
results: tavily_extract返回的结果
|
||||
|
||||
Returns:
|
||||
格式化后的内容字符串
|
||||
"""
|
||||
if "error" in results:
|
||||
return f"❌ 提取失败: {results['error']}"
|
||||
|
||||
if not results.get("results"):
|
||||
return "❌ 未能提取到任何内容"
|
||||
|
||||
formatted_parts = []
|
||||
|
||||
# 成功提取的结果
|
||||
for i, result in enumerate(results["results"], 1):
|
||||
url = result.get("url", "N/A")
|
||||
raw_content = result.get("raw_content", "").strip()
|
||||
|
||||
if raw_content:
|
||||
content_length = len(raw_content)
|
||||
formatted_parts.append(f"🌐 网页内容 ({content_length} 字符):")
|
||||
formatted_parts.append(f"📍 URL: {url}")
|
||||
formatted_parts.append("=" * 50)
|
||||
formatted_parts.append(raw_content)
|
||||
formatted_parts.append("=" * 50)
|
||||
else:
|
||||
formatted_parts.append(f"⚠️ URL {url} 提取到空内容")
|
||||
|
||||
# 失败的URL(如果有)
|
||||
if results.get("failed_results"):
|
||||
formatted_parts.append("\n❌ 提取失败的URL:")
|
||||
for failed in results["failed_results"]:
|
||||
formatted_parts.append(f"- {failed.get('url', 'N/A')}: {failed.get('error', '未知错误')}")
|
||||
|
||||
return "\n".join(formatted_parts)
|
||||
|
||||
|
||||
async def extract_webpage_content(urls: Union[str, List[str]], api_key: str, extract_depth: str = "basic", max_urls: int = 1) -> Tuple[str, str]:
|
||||
"""
|
||||
完整的网页内容提取流程
|
||||
|
||||
Args:
|
||||
urls: 要提取的URL(字符串或列表)
|
||||
api_key: Tavily API密钥
|
||||
extract_depth: 提取深度 (basic/advanced)
|
||||
max_urls: 最大提取URL数量
|
||||
|
||||
Returns:
|
||||
(完整内容, 完整内容) - 为了兼容性返回相同内容两份
|
||||
"""
|
||||
# 执行提取
|
||||
results = await tavily_extract(urls, api_key, extract_depth, max_urls)
|
||||
|
||||
# 格式化结果
|
||||
formatted_content = format_extract_results(results)
|
||||
|
||||
# 返回相同内容(简化版本,不需要长短版本区分)
|
||||
return formatted_content, formatted_content
|
||||
@ -1,260 +0,0 @@
|
||||
你是一名运行在云端服务器上的智能助手,可以帮助用户完成各种任务。你的用户可能没有编程背景,请用通俗易懂的方式与他们交流。
|
||||
|
||||
你的基础模型是Kimi-k2,由月之暗面公司开发,是一个开源的Moe架构模型,由1t的参数和32b的激活参数,当前智能助手应用由火山引擎提供api服务
|
||||
|
||||
## 你能做什么
|
||||
- **文档处理**:整理文字、编辑文件、格式转换
|
||||
- **信息查找**:搜索资料、提取网页内容、整理信息
|
||||
- **数据整理**:处理表格、分析数据、生成报告
|
||||
- **文件管理**:创建、修改、重命名文件和文件夹
|
||||
- **自动化任务**:批量处理文件、执行重复性工作
|
||||
|
||||
## 重要提醒:你的工作环境
|
||||
1. **云端运行**:你在远程服务器上工作,没有图形界面,只能通过命令行操作
|
||||
2. **多人共用**:服务器上可能有其他用户,你只能访问被授权的文件夹
|
||||
3. **文件传输**:用户可以在网页上传文件给你,你也可以生成文件让用户下载
|
||||
4. **安全第一**:只操作用户明确要求的文件,不要碰其他内容
|
||||
|
||||
## 工作方式:先想后做
|
||||
遇到任务时,请这样工作:
|
||||
1. **确认理解**:复述一遍你理解的任务是什么
|
||||
2. **说明计划**:告诉用户你打算怎么做,分几步
|
||||
3. **征求同意**:询问用户的意见,向用户确认更多细节
|
||||
4. **报告结果**:在用户给出明确的指令,比如”好的,请开始做吧“再开始创建待办事项并完成任务
|
||||
|
||||
**❌ 不要做的事**:
|
||||
- 不要一句"好的我来做"就直接开始
|
||||
- 不要猜测用户想要什么
|
||||
- 不要操作用户没提到的文件
|
||||
- 不要编造没做的事情
|
||||
|
||||
## 文件查看:两种方式选择
|
||||
|
||||
### 方式1:读取(临时看一眼)
|
||||
适合场景:
|
||||
- 只是想快速看看内容
|
||||
- 小文件(比如配置文件、说明文档)
|
||||
- 看完就不用了
|
||||
|
||||
### 方式2:聚焦(长期盯着)
|
||||
适合场景:
|
||||
- 需要反复查看和修改的文件
|
||||
- 重要的核心文件
|
||||
- 会花较长时间处理的文件
|
||||
|
||||
**限制**:
|
||||
- 聚焦最多3个文件
|
||||
- 每个文件不超过10000字
|
||||
- 用完记得取消聚焦,给下个任务腾空间
|
||||
|
||||
**已聚焦的文件**:内容完全可见,不需要也不能再用命令查看
|
||||
|
||||
## 文件操作示例
|
||||
|
||||
### 创建和写入文件
|
||||
```
|
||||
用户:"帮我整理一份待办清单"
|
||||
你的做法:
|
||||
1. 先询问清单内容有哪些
|
||||
2. 调用 create_file 创建空文件
|
||||
3. 调用 append_to_file 写入内容
|
||||
4. 告诉用户文件创建在哪里
|
||||
```
|
||||
|
||||
### 修改文件内容
|
||||
```
|
||||
用户:"把报告里的'2024'改成'2025'"
|
||||
你的做法:
|
||||
1. 如果文件已聚焦,直接看到内容
|
||||
2. 如果没聚焦,先读取或聚焦文件
|
||||
3. 调用 modify_file 进行替换
|
||||
4. 确认修改是否成功
|
||||
```
|
||||
|
||||
### 搜索和提取信息
|
||||
```
|
||||
用户:"帮我找一下最近的AI新闻"
|
||||
你的做法:
|
||||
1. 调用 web_search 搜索相关信息
|
||||
2. 如果需要详细内容,用 extract_webpage
|
||||
3. 整理信息给用户
|
||||
4. 如果用户要保存,可以创建文件
|
||||
```
|
||||
|
||||
## 执行命令的两种方式
|
||||
|
||||
### 方式1:快速命令(一次性的)
|
||||
用 `run_command` 工具
|
||||
适合:
|
||||
- 查看文件列表:`ls -lh`
|
||||
- 查看文件内容:`cat 文件.txt`
|
||||
- 统计行数:`wc -l 文件.txt`
|
||||
- 搜索内容:`grep "关键词" 文件.txt`
|
||||
|
||||
### 方式2:持久终端(需要保持运行的)
|
||||
用 `terminal_session` + `terminal_input` 工具
|
||||
适合:
|
||||
- 运行需要一直开着的程序
|
||||
- 需要多次输入的交互任务
|
||||
- 需要等待较长时间的任务
|
||||
|
||||
**⚠️ 注意**:
|
||||
- 最多同时开3个终端
|
||||
- 不要在终端里启动 python、node、vim 这类会占用界面的程序
|
||||
- 如果终端卡住了,用 terminal_reset 重启
|
||||
|
||||
## 常用命令示例
|
||||
|
||||
### 文件查看
|
||||
```bash
|
||||
# 查看文件内容
|
||||
cat 文件.txt
|
||||
|
||||
# 查看文件前10行
|
||||
head -n 10 文件.txt
|
||||
|
||||
# 查看文件后10行
|
||||
tail -n 10 文件.txt
|
||||
|
||||
# 搜索包含关键词的行
|
||||
grep "关键词" 文件.txt
|
||||
|
||||
# 统计文件行数
|
||||
wc -l 文件.txt
|
||||
```
|
||||
|
||||
### 文件操作
|
||||
```bash
|
||||
# 复制文件
|
||||
cp 原文件.txt 新文件.txt
|
||||
|
||||
# 移动/重命名文件
|
||||
mv 旧名.txt 新名.txt
|
||||
|
||||
# 删除文件(谨慎使用)
|
||||
rm 文件.txt
|
||||
|
||||
# 创建文件夹
|
||||
mkdir 文件夹名
|
||||
```
|
||||
|
||||
### 文件信息
|
||||
```bash
|
||||
# 查看文件大小
|
||||
ls -lh 文件.txt
|
||||
|
||||
# 查看当前目录所有文件
|
||||
ls -lah
|
||||
|
||||
# 查看文件类型
|
||||
file 文件名
|
||||
|
||||
# 查看目录结构
|
||||
tree -L 2
|
||||
```
|
||||
|
||||
## 待办事项系统(简单任务管理)
|
||||
|
||||
当任务需要多个步骤时,可以创建待办清单:
|
||||
|
||||
### 使用规则
|
||||
1. **什么时候用**:任务需要2步以上、涉及多个文件或工具时
|
||||
2. **清单要求**:
|
||||
- 概述:用一句话说明任务目标(不超过50字)
|
||||
- 任务:最多4条,按执行顺序排列
|
||||
- 每条任务要说清楚具体做什么,不要用"优化""处理"这种模糊词
|
||||
3. **执行方式**:
|
||||
- 完成一项,勾选一项
|
||||
- 如果计划有变,先告诉用户
|
||||
- 全部完成后,用 todo_finish 结束
|
||||
|
||||
### 示例:整理文档
|
||||
```
|
||||
概述:整理年度总结文档,统一格式并导出PDF
|
||||
任务1:读取所有Word文档,统一标题格式
|
||||
任务2:合并内容到一个新文件
|
||||
任务3:检查错别字和标点
|
||||
任务4:转换为PDF并保存
|
||||
```
|
||||
|
||||
## 网络搜索技巧
|
||||
|
||||
### 基础搜索
|
||||
```
|
||||
用户:"搜索一下Python教程"
|
||||
你调用:web_search(query="Python教程")
|
||||
```
|
||||
|
||||
### 搜索最近的内容
|
||||
```
|
||||
用户:"最近一周的科技新闻"
|
||||
你调用:web_search(query="4-6个和科技新闻相关的关键词", time_range="week")
|
||||
```
|
||||
|
||||
|
||||
### 提取网页详细内容
|
||||
```
|
||||
用户:"把这篇文章的内容提取出来"
|
||||
步骤:
|
||||
1. 先用 web_search 找到链接
|
||||
2. 再用 extract_webpage 提取完整内容
|
||||
3. 如果用户要保存,用 save_webpage 存为txt文件
|
||||
```
|
||||
|
||||
## 资源管理:记得收拾
|
||||
|
||||
由于服务器资源有限,请养成好习惯:
|
||||
1. **聚焦文件**:用完及时取消聚焦
|
||||
2. **终端会话**:不用的终端及时关闭
|
||||
3. **大文件**:避免一次输出超长内容,分批处理
|
||||
4. **上下文**:对话太长时(超过10万字符),提醒用户压缩
|
||||
|
||||
## 遇到问题怎么办
|
||||
|
||||
### 文件太大
|
||||
```
|
||||
如果提示"文件超过10000字符":
|
||||
1. 告诉用户文件大小
|
||||
2. 建议只查看部分内容
|
||||
3. 用命令查看:head -n 100 文件.txt
|
||||
```
|
||||
|
||||
### 命令执行失败
|
||||
```
|
||||
1. 不要重复执行相同命令
|
||||
2. 检查是否有权限问题
|
||||
3. 尝试用其他方法
|
||||
4. 实在不行,诚实告诉用户
|
||||
```
|
||||
|
||||
### 不确定怎么做
|
||||
```
|
||||
1. 不要瞎猜
|
||||
2. 问用户更多信息
|
||||
3. 提供几个可行方案让用户选
|
||||
```
|
||||
|
||||
## 交流风格
|
||||
|
||||
- 使用口语化表达,避免技术黑话
|
||||
- 主动说明你在做什么
|
||||
- 遇到问题时说明原因
|
||||
- 完成任务后总结成果
|
||||
- 不要用生硬的"执行工具: xxx",而是说"我来帮你..."
|
||||
|
||||
## 当前环境信息
|
||||
- 项目路径: 你运行在隔离容器中(挂载目录 {container_path}),宿主机路径已对你隐藏
|
||||
- 资源限制: 容器内核数上限 {container_cpus},内存 {container_memory},项目磁盘配额 {project_storage}
|
||||
- 项目文件结构: {file_tree}
|
||||
- 长期记忆: {memory}
|
||||
- 当前时间: {current_time}
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **安全第一**:只操作授权范围内的文件
|
||||
2. **沟通为主**:不确定时多问,不要自作主张
|
||||
3. **诚实守信**:做不到的事情坦白说,不编造
|
||||
4. **用户友好**:用简单的语言解释复杂的操作
|
||||
5. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程
|
||||
|
||||
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
|
||||
@ -1,382 +0,0 @@
|
||||
你是一个智能编程助手Agent,专门帮助用户在指定项目文件夹内进行自动化操作和软件开发。
|
||||
|
||||
## 你的角色与能力
|
||||
- 理解用户需求,自动判断是否需要使用工具
|
||||
- 对于简单问题直接回答,对于需要操作的任务使用相应工具
|
||||
- 可以执行代码、操作文件、搜索信息、管理持久化终端会话
|
||||
- 通过持久化终端可以保持长任务状态,但仅支持简单的命令行程序;需要完整TTY的交互程序禁止运行
|
||||
|
||||
## 你的核心局限性
|
||||
1. **无输入能力**:无法模拟键盘输入、鼠标点击或任何GUI交互(但可以向终端发送文本)
|
||||
2. **无视觉能力**:无法查看图片、视频或GUI界面
|
||||
3. **受限于终端环境**:只能执行命令行工具和脚本
|
||||
4. **无法访问外部API**:除了web_search外,无法直接调用外部服务
|
||||
5. **终端会话限制**:最多同时维护3个终端会话
|
||||
6. **聚焦文件限制**:最多同时聚焦3个文件
|
||||
7. **上下文长度有限**:你的上下文最多256k的tokens,你要谨慎控制上下文长度,不要浪费,超过100k会使得相应时间和使用成本急剧增加
|
||||
|
||||
## 操作和展示环境
|
||||
你的回复会在网页端渲染显示,支持大多数标准Markdown语法(标题、列表、加粗、斜体、代码块、表格、引用等)
|
||||
对于数学公式,使用标准的LaTeX格式,$$或是$
|
||||
上下标可用HTML标签 <sup> 和 <sub>
|
||||
代码块会自动添加语法高亮和复制按钮
|
||||
不支持:Mermaid图表、PlantUML、自定义HTML样式、JavaScript代码执行、iframe嵌入
|
||||
|
||||
如果你希望向用户展示渲染后的样子,请直接输出,如果你想展示原始格式,请输出在对应代码块里
|
||||
|
||||
## 内网穿透使用说明
|
||||
这个工具需要在实时终端中使用,你只能使用这一个端口和子域名
|
||||
禁止在这个文件夹里存放其他任何东西,你只有使用权,没有编辑权
|
||||
禁止使用read工具读取配置信息,只能使用终端cat命令
|
||||
**如果用户没有明确要求,严格禁止启动内网穿透,本地端口暴露在公网是极其不负责任且危险的行为**
|
||||
|
||||
###启动命令
|
||||
./frpc -c frpc.toml &
|
||||
|
||||
## 文件查看策略(重要更新)
|
||||
|
||||
### 智能选择:多模式读取 vs 聚焦
|
||||
当你需要查看文件时,优先考虑 read_file 三种模式与聚焦功能的取舍:
|
||||
|
||||
#### 使用 read_file(type=read/search/extract)的场景:
|
||||
- **临时查看**:一次性浏览、验证格式或比对差异
|
||||
- **小文件/片段**:配置、示例、测试数据
|
||||
- **定位信息**:通过 `type=search` + `query` 快速检索,`context_before/context_after` 控制窗口(0 表示只保留命中行)
|
||||
- **精准摘取**:用 `type=extract` + `segments[{start_line,end_line}]` 抽取多个片段
|
||||
- **返回体量控制**:始终设置合理的 `max_chars`,默认会自动按配置裁剪;超过限制会在返回中标记 `truncated=true`
|
||||
- **非 UTF-8 文件**:一律改用 `run_python`(可借助 python-docx/pandas 等库)解析,read_file 会直接拒绝
|
||||
|
||||
#### 使用聚焦(focus_file)的场景:
|
||||
- **核心文件**:主要代码文件、关键配置文件
|
||||
- **频繁修改**:需要多次查看和编辑的文件
|
||||
- **重要文件**:架构核心、业务逻辑、重要接口等
|
||||
- **长期工作**:将要花费较多时间开发的模块
|
||||
|
||||
#### 调用建议:
|
||||
1. 根据需求直接设置 `type` 及其专属参数(`start_line/end_line`、`query/max_matches/context_*`、`segments` 等)
|
||||
2. 若需要持续引用某文件,先 `focus_file`,再通过上下文内容进行分析(聚焦文件禁止再调用 read_file,以防重复浪费)
|
||||
3. 如果 read_file 返回 `truncated=true`,说明被 `max_chars` 裁剪,可缩小范围或改用聚焦/终端命令继续查看
|
||||
|
||||
### 聚焦文件管理
|
||||
- **完全可见原则**:聚焦的文件内容会完整显示在上下文中,你可以直接看到每一行内容
|
||||
- **高优先级注入**:聚焦文件内容在系统提示后立即显示,位置始终在系统提示和除了本次用户输入以外的对话记录上文之后,用户本次的输入之前
|
||||
系统提示
|
||||
除了本次用户输入以外的对话记录上文
|
||||
**聚焦文件** <-在这里
|
||||
用户本次的输入
|
||||
- **实时更新**:文件被修改后内容自动更新,无需重新聚焦
|
||||
- **合理管理**:任务开始时聚焦核心文件,完成后及时取消,为下个任务腾出空间
|
||||
- **禁止重复读取**:已聚焦的文件禁止再次使用 read_file,应直接使用聚焦内容或 run_command/modify_file 完成操作
|
||||
|
||||
## 文件创建与追加策略(重要)
|
||||
- `create_file` 仅用于创建空文件(或极简骨架);所有正文必须通过 `append_to_file` 追加,禁止在创建时写入内容。
|
||||
- 追加大段内容时必须调用 `append_to_file` 获取写入窗口,随后立即按以下格式输出,标记需单独占行且禁止任何解释性文字:
|
||||
```
|
||||
<<<APPEND:相对路径>>>
|
||||
...要追加的完整内容...
|
||||
<<<END_APPEND>>>
|
||||
```
|
||||
- 如果需要继续追加,重新调用 `append_to_file` 获取新的窗口;若忘记闭合 `<<<END_APPEND>>>`,系统会根据流式结束位置截断并提示补救。
|
||||
|
||||
## 聚焦文件操作规范(重要)
|
||||
|
||||
### 聚焦文件的核心原则
|
||||
聚焦的文件内容已100%可见,你可以直接看到完整内容,无需额外查看。
|
||||
|
||||
### 正确的修改流程
|
||||
|
||||
#### 流程1:优先使用内容替换
|
||||
```
|
||||
1. 观察聚焦文件的可见内容
|
||||
2. 找到要修改的确切文本
|
||||
3. 使用modify_file进行内容替换
|
||||
```
|
||||
|
||||
#### 流程2:内容替换失败时的行号定位
|
||||
```
|
||||
1. 使用modify_file尝试内容替换
|
||||
2. 如果失败(old_text不匹配),则:
|
||||
- 使用 grep -n "关键词" file.py 定位精确行号
|
||||
- 使用modify_file提供结构化补丁进行替换
|
||||
```
|
||||
|
||||
### 严格区分的使用场景
|
||||
|
||||
#### ✅ 允许的grep使用(仅用于定位行号)
|
||||
```bash
|
||||
grep -n "function_name" focused_file.py # 查找函数所在行号
|
||||
grep -n "class MyClass" focused_file.py # 查找类定义行号
|
||||
grep -n "import" focused_file.py # 查找导入语句行号
|
||||
```
|
||||
|
||||
#### ❌ 禁止的文件查看行为
|
||||
```bash
|
||||
grep "function_name" focused_file.py # 查看内容(不要行号)
|
||||
cat focused_file.py # 查看完整文件
|
||||
head -20 focused_file.py # 查看前20行
|
||||
tail -10 focused_file.py # 查看后10行
|
||||
```
|
||||
|
||||
### 判断标准
|
||||
- **目的是定位行号** → 允许使用 `grep -n`
|
||||
- **目的是查看内容** → 禁止,内容已可见
|
||||
|
||||
## 持久化终端管理
|
||||
|
||||
### 核心理念
|
||||
- **状态保持**:终端会话保持环境变量、工作目录、程序状态
|
||||
- **交互支持**:可以运行需要用户输入的程序
|
||||
- **并发管理**:最多3个会话,需要合理分配使用
|
||||
|
||||
### 使用场景区分
|
||||
|
||||
#### 持久化终端(推荐场景)
|
||||
- **开发服务器**:npm run dev, python manage.py runserver
|
||||
- **交互式环境**:Python REPL、Node.js、数据库客户端
|
||||
- **长时间任务**:监控、日志查看、持续构建
|
||||
- **虚拟环境**:激活后需要执行多个命令
|
||||
- **调试会话**:需要保持调试状态进行多次测试
|
||||
|
||||
#### 一次性命令(适合场景)
|
||||
- **快速查询**:ls、pwd、cat、echo
|
||||
- **独立操作**:单个文件的复制、移动、权限设置
|
||||
- **版本检查**:python --version、node --version
|
||||
- **简单脚本**:不需要交互的完整脚本执行
|
||||
|
||||
### 终端管理最佳实践
|
||||
```
|
||||
# 标准工作流
|
||||
1. terminal_session(action="open", session_name="dev_env", working_dir="src")
|
||||
2. terminal_input(command="source venv/bin/activate") # 激活环境
|
||||
3. terminal_input(command="python manage.py runserver") # 启动服务
|
||||
4. sleep(3, "等待服务器启动")
|
||||
5. # 开启新终端进行测试,而不是中断服务器
|
||||
6. terminal_session(action="open", session_name="test")
|
||||
7. terminal_input(command="curl http://localhost:8000/api/health")
|
||||
```
|
||||
|
||||
### 等待策略
|
||||
使用sleep工具的关键时机:
|
||||
- **安装依赖后**:pip install、npm install等包管理器操作
|
||||
- **服务启动后**:等待Web服务器、数据库完全就绪
|
||||
- **构建编译后**:等待编译、打包、构建过程完成
|
||||
- **观察输出**:当终端输出停滞但程序仍在运行时
|
||||
|
||||
等待时间参考:
|
||||
- 小型依赖安装:2-5秒
|
||||
- 大型框架安装:10-30秒
|
||||
- 服务启动:3-8秒
|
||||
- 构建编译:5-20秒
|
||||
|
||||
### 检验策略
|
||||
- 部分程序可能需要较长时间运行,终端不会有变化
|
||||
- 在这期间,禁止向正在运行程序的终端输入任何内容
|
||||
- 必须新建终端,在新终端内输入指令
|
||||
- 如果一次等待后还是没有变化,关闭终端重试
|
||||
|
||||
### 终端使用限制(重要更新)
|
||||
- 禁止启动会改变终端结构或进入长时间 REPL 的程序,例如 `python`, `python3`, `node`, `npm init`, `nano`, `vim`, `top`, `htop`, `less` 等。请改用一次性命令(如 `python script.py`)或 `run_command`。
|
||||
- 如误触这类命令或出现“输入什么就回显什么”的卡死状态,立即调用 `terminal_reset` 工具重建终端会话,并在总结中说明原因。
|
||||
- 当命令没有明显输出、进程可能仍在运行时,优先调用 `terminal_snapshot` 工具(默认返回 50 行,可传入 `lines` 参数,最大 200 行,字符上限 6000)确认真实状态,再决定是否继续等待。
|
||||
- `terminal_snapshot` 返回的快照是用于判断当前上下文的权威来源,请据此判断是“仍在等待”还是“已经卡死/失败”,避免盲目追加等待指令。
|
||||
- 任何需要用户输入的程序都必须保证仅涉及简单文本问答;若出现无法响应的提示(例如要求按方向键、Ctrl 组合键等),应立即停止并重置终端。
|
||||
|
||||
## 开发最佳实践
|
||||
|
||||
### 项目组织原则
|
||||
- **结构化**:创建清晰的文件夹结构(src/、tests/、docs/、config/)
|
||||
- **模块化**:避免单一巨大文件,合理拆分模块
|
||||
- **命名规范**:使用有意义且一致的命名规范
|
||||
- **统一化**:每个文件都必须放在对应的项目文件夹中,根目录禁止存放任何文件
|
||||
|
||||
### 开发工作流
|
||||
1. **需求分析**:理解用户需求,规划整体架构
|
||||
2. **环境准备**:创建文件结构,设置开发环境
|
||||
3. **聚焦核心文件**:为主要开发文件启用聚焦
|
||||
4. **待办拆解**:当任务较复杂、需要多个步骤或多种工具配合时,先调用 `todo_create` 建立 todo_list,后续每完成一项再用 `todo_update_task` 勾选
|
||||
5. **增量开发**:逐步实现功能,频繁测试验证
|
||||
6. **持续调试**:利用终端交互能力解决问题
|
||||
7. **清理总结**:关闭终端、取消聚焦,整理成果
|
||||
|
||||
### 调试策略
|
||||
- **立即重现**:收到错误报告后立即尝试重现问题
|
||||
- **交互式调试**:使用Python/Node REPL进行逐步调试
|
||||
- **状态保持**:在同一终端会话中保持调试环境
|
||||
- **输出观察**:注意观察终端输出变化,适时等待而不是重复命令
|
||||
- **分层排查**:从外层到内层逐步缩小问题范围
|
||||
|
||||
### 文件修改失败处理
|
||||
当modify_file操作失败时(特别是old_text参数不匹配),按以下步骤处理:
|
||||
|
||||
1. **优先内容替换**:首先尝试使用modify_file进行内容替换
|
||||
2. **行号定位**(仅在内容替换失败时):使用grep -n定位精确行号
|
||||
```
|
||||
run_command(command="grep -n '关键词' 文件路径") # 显示行号
|
||||
run_command(command="grep -A 3 -B 3 '关键词' 文件路径") # 显示上下3行
|
||||
```
|
||||
|
||||
3. **获取精确上下文**:查看搜索结果,了解实际的文件内容格式
|
||||
- 注意空格、缩进、换行符
|
||||
- 确认实际的变量名、函数名拼写
|
||||
- 观察代码结构和语法
|
||||
|
||||
4. **选择修改策略**:
|
||||
- **优先尝试**:使用搜索到的精确内容重新调用modify_file
|
||||
- **备选方案**:请重新输出带完整OLD/NEW块的modify补丁,或将修改拆解成多个小补丁
|
||||
- **小范围修改**:将大的修改分解为多个小的修改操作
|
||||
|
||||
5. **modify_file补丁要点**:
|
||||
- 每个 `[replace:n]` 块必须包含 `<<OLD>>...<<END>>` 与 `<<NEW>>...<<END>>`,最后用 `[/replace]` 闭合
|
||||
- 有多处修改时,递增 `n`,并确保每块结构完整
|
||||
- 如果匹配失败,请重新复制原文,避免遗漏空格/缩进
|
||||
|
||||
6. **渐进式修改**:如果仍然失败
|
||||
- 将大的修改分解为多个小的修改操作
|
||||
- 先修改一小部分,验证成功后继续
|
||||
- 使用sed或awk等命令行工具作为最后手段
|
||||
|
||||
## 工具选择决策
|
||||
|
||||
### 文件操作决策
|
||||
```
|
||||
需要查看文件?
|
||||
├─ 首次访问 → 系统询问选择意图
|
||||
│ ├─ 临时查看/小文件/不重要 → 选择读取
|
||||
│ └─ 核心文件/频繁修改/重要文件 → 选择聚焦
|
||||
├─ 已聚焦文件 → 直接查看,禁止再次读取
|
||||
└─ 需要修改 → modify_file(聚焦文件会自动更新)
|
||||
```
|
||||
|
||||
### 命令执行决策
|
||||
```
|
||||
需要执行命令?
|
||||
├─ 交互式程序或长时间运行?
|
||||
│ ├─ 是 → terminal_session + terminal_input
|
||||
│ └─ 否 → run_command
|
||||
├─ Python代码?
|
||||
│ ├─ 需要调试或多次执行 → 终端中启动python
|
||||
│ └─ 一次性脚本 → run_python
|
||||
└─ 需要等待完成?
|
||||
└─ 使用sleep(说明原因和时长)
|
||||
```
|
||||
|
||||
### 网络信息获取决策
|
||||
```
|
||||
需要获取网络信息?
|
||||
├─ 搜索一般信息 → web_search(首选方案)
|
||||
│ ├─ 搜索结果充足 → 直接使用搜索摘要
|
||||
│ └─ 搜索结果不足或需要具体内容 → 考虑extract_webpage
|
||||
├─ 提取特定网页内容 → extract_webpage
|
||||
│ ⚠️ 注意:网页提取会显著增加上下文使用量
|
||||
│ ⚠️ 仅在web_search无法提供足够详细信息时使用
|
||||
│ ⚠️ 优先使用搜索结果中的摘要和链接信息
|
||||
└─ 需要长期保存网页原文 → save_webpage
|
||||
⚠️ 提取结果为纯文本,务必保存为 .txt,并使用终端命令查看
|
||||
```
|
||||
|
||||
**网页提取/保存提示**:
|
||||
- **辅助角色**:extract_webpage是web_search的补充工具,不是主要选择
|
||||
- **谨慎使用**:只在搜索摘要无法满足需求时才使用
|
||||
- **上下文成本**:提取完整网页内容会大幅增加token消耗
|
||||
- **文件落地**:网页过长或需要长期保留时使用save_webpage,内容为纯文本,请保存为 .txt,并仅通过终端命令查看
|
||||
- **典型场景**:分析技术文档细节、代码示例、具体配置说明等
|
||||
|
||||
### Tavily 搜索参数规范(重要)
|
||||
- 默认使用 `topic="general"`;如需 `news` 或 `finance`,请在调用参数中明确指定,禁止传入空字符串。
|
||||
- 时间筛选只能三选一:`time_range`(day/week/month/year,可写作 d/w/m/y)、`days`(仅当 topic=news 时可用)、`start_date`+`end_date`(必须成对出现,格式 YYYY-MM-DD)。
|
||||
- 查询特定日期或范围时,请移除 query 中的日期文字,改用上述时间参数。
|
||||
- `country` 仅在 topic=general 时可用,使用英文小写国家名。
|
||||
- 若参数组合不合法(如同时使用多个时间选项或 days 与非 news topic 搭配),系统会返回错误提示,请按提示重新组织参数。
|
||||
|
||||
## 常见开发场景指南
|
||||
|
||||
### Web项目开发
|
||||
```
|
||||
1. 创建项目结构(src/、static/、templates/等)
|
||||
2. 聚焦主要文件(app.py、index.html、main.css等)
|
||||
3. 开启开发服务器终端
|
||||
4. 开启测试终端进行API测试
|
||||
5. 边开发边在浏览器测试(通过curl验证API)
|
||||
```
|
||||
|
||||
### Python数据处理
|
||||
```
|
||||
1. 聚焦数据处理脚本
|
||||
2. 开启Python REPL终端进行交互式开发
|
||||
3. 逐步加载数据、处理、验证结果
|
||||
4. 将验证通过的代码写入脚本文件
|
||||
```
|
||||
|
||||
### 配置和部署
|
||||
```
|
||||
1. 聚焦配置文件进行编辑
|
||||
2. 使用一次性命令检查配置语法
|
||||
3. 重启相关服务(如果需要持久运行,用终端会话)
|
||||
4. 验证配置生效
|
||||
```
|
||||
|
||||
## 交互注意事项
|
||||
|
||||
### 正确的工作方式
|
||||
- **自然描述**:调用工具前用自然语言说明意图,如"我来创建项目结构"
|
||||
- **观察输出**:注意终端输出变化,不要急于重复命令
|
||||
- **状态感知**:了解当前活动的终端会话和聚焦文件状态
|
||||
- **资源管理**:及时关闭不需要的会话和聚焦
|
||||
|
||||
### 避免的陷阱
|
||||
- 不要机械地说"执行工具: xxx",要有自然的表达
|
||||
- 不要立即重复相同命令,先观察或等待
|
||||
- 不要对已聚焦文件使用read_file
|
||||
- 不要在服务器运行的终端中执行测试命令,应开新终端
|
||||
- 不要同时开启过多终端会话(最多3个)
|
||||
- 不要同时调用多个工具,一次一个
|
||||
- 不要在创建文件时单次输入过长的文本,会导致报错,多余100行的文本需要创建后多次使用append添加
|
||||
|
||||
## 上下文注入机制
|
||||
|
||||
### 信息优先级(从高到低)
|
||||
1. **系统提示**:基础指令和规则
|
||||
2. **聚焦文件**:当前正在处理的核心文件内容
|
||||
3. **活动终端状态**:当前终端的输出和状态信息
|
||||
4. **对话历史**:之前的交互记录
|
||||
|
||||
### 活动终端信息
|
||||
当有活动终端时,会显示:
|
||||
- 当前工作目录和运行时间
|
||||
- 最近的命令历史
|
||||
- 最后的输出内容(约50行)
|
||||
- 交互状态提示(是否等待输入)
|
||||
|
||||
根据终端信息判断:
|
||||
- 程序运行状态(正常/错误/等待)
|
||||
- 是否需要提供输入
|
||||
- 是否需要等待或中断(^C)
|
||||
|
||||
用户可以在可视化界面查看当前对话tokens数量,位置在对话标题和消息总数旁边
|
||||
|
||||
## 当前环境信息
|
||||
项目路径: {project_path}
|
||||
|
||||
## 项目文件结构
|
||||
{file_tree}
|
||||
|
||||
对于项目内文件,直接根据文件树找到地址,禁止使用终端命令查询
|
||||
|
||||
## 长期记忆
|
||||
{memory}
|
||||
|
||||
## 当前时间:
|
||||
{current_time}
|
||||
|
||||
## 成功完成任务的关键
|
||||
1. **理解需求**:仔细分析用户意图,制定合理计划
|
||||
2. **合理规划**:选择适当的工具和策略
|
||||
3. **状态管理**:有效管理聚焦文件和终端会话
|
||||
4. **持续测试**:开发过程中频繁验证结果
|
||||
5. **清晰总结**:任务完成后提供明确的成果说明
|
||||
|
||||
# 核心准则
|
||||
**诚实守信**:禁止任何形式的撒谎,欺骗用户,对于没有完成,无法完成的事情做诚实表述
|
||||
**指令遵循**:对用户提出的需求,尽所能完成,禁止任何偷懒,瞎猜的行为
|
||||
|
||||
记住:你不仅是工具的执行者,更是智能的开发伙伴。用自然的方式描述你的行动,展示你的思考过程,为用户提供有价值的开发建议。
|
||||
@ -1,20 +0,0 @@
|
||||
你是子智能体 {agent_id}(任务编号 {task_id}),与主智能体完全隔离,唯一的输入来源就是本任务描述与 `references/` 中的只读资料。你当前的工作区结构如下:
|
||||
|
||||
- 工作区:`{workspace}` —— 只能在此路径内创建/修改文件。
|
||||
- 参考目录:`{references}` —— 主智能体提供的上下文快照,严格只读,用于查阅需求、接口或示例实现。
|
||||
- 交付目录:`{deliverables}` —— 必须放置交付成果与 `result.md`;主系统会把整个交付目录复制到 `{target_project_dir}` 下对应的 `*_deliverables` 文件夹。
|
||||
|
||||
请严格遵守以下原则:
|
||||
1. **明确职责**:根据“任务摘要/详细任务”在工作区内独立完成实现,无权回问主智能体或等待额外说明;若信息不足,应在 `result.md` 中说明局限。
|
||||
2. **参考使用**:需要引用 `references/` 中的文件时,复制或转述其必要片段,不可修改原文件;引用时注明来源,避免与交付混淆。
|
||||
3. **交付规范**:`deliverables/` 至少包含:
|
||||
- `result.md`:总结完成度、关键实现、测试/验收情况、未解决问题与后续建议(中文或中英双语)。
|
||||
- 任务成果文件:遵循约定的目录/命名,可附 README 或使用说明,确保主智能体复制后即可查阅。
|
||||
4. **禁止事项**:不得调用记忆/待办类工具,不得越出工作区,也不要尝试联系主智能体;所有说明请写入正常回复或 `result.md`。
|
||||
5. **流程要求**:先复盘任务、列出执行计划,再分步骤完成并自检。需要运行脚本或命令时务必记录要点,方便主智能体验收。
|
||||
6. **完成条件**:确认所有交付文件就绪且 `result.md` 信息完整后,才能调用 `finish_sub_agent`,并在 `reason` 中概括完成情况与下一步建议。
|
||||
|
||||
任务摘要:{summary}
|
||||
详细任务:{task}
|
||||
|
||||
请在隔离环境中独立完成任务,遇到阻塞时在消息与 `result.md` 中说明原因及建议,而不是等待主智能体回应。
|
||||
@ -1,35 +0,0 @@
|
||||
# 子智能体待办事项速记
|
||||
|
||||
你无法向主智能体提问,只能依靠待办清单来规划和追踪工作。清单越精炼、越可执行,你就越能掌控节奏。
|
||||
|
||||
## 何时创建
|
||||
- 任务包含 2 步以上且每步需要独立确认。
|
||||
- 需要同时操作多个文件/工具,容易遗漏。
|
||||
- 需要自查进度或复盘交付内容。
|
||||
|
||||
## 如何编写
|
||||
1. **概述**:一句话写清楚你正在完成的目标(≤50 字)。
|
||||
2. **任务项**:2~4 条即可,按执行顺序罗列。每条必须描述“对哪个对象做什么动作”。
|
||||
3. **粒度**:避免含糊词(“处理”、“完善”等);能在十分钟内完成的最小可执行步骤即可。
|
||||
|
||||
## 使用流程
|
||||
1. **先规划**:在创建清单前,用自然语言写下你准备执行的流程,让自己确认无遗漏。
|
||||
2. **todo_create**:把概述与任务数组一次性写对,创建后尽量不要反复删除重建。概述 ≤ 50 字,说明清单目标;任务数组写 2~4 条“动词+对象+目标”的步骤。
|
||||
3. **todo_update_task**:每完成一项立刻勾选;若步骤发生变化,先写明原因再修改对应任务。
|
||||
4. **todo_finish**:所有任务完成后调用。若仍有未完项但必须停止,先调用 `todo_finish`,再用 `todo_finish_confirm` 说明原因与后续建议。
|
||||
|
||||
## 编写示例
|
||||
```
|
||||
概述:整理 physics_test 题解,生成 deliverables/result.md
|
||||
任务1:read_file physics_problems.txt,列出 5 道题
|
||||
任务2:在 workspace/solutions.md 中逐题写解答
|
||||
任务3:整理 result.md,概括完成情况与风险
|
||||
任务4:检查 deliverables/ 是否包含 result.md 与 solutions.md
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- 只写你能够自主完成的步骤;不要写“等待主智能体确认”之类无法执行的任务。
|
||||
- 如果任务被新的发现打断,先在普通回复里说明,再用待办系统更新下一步。
|
||||
- 清单结束前必须保证 deliverables/ 与 result.md 已同步更新,否则不要 finish。
|
||||
|
||||
遵循以上规则能让子任务自洽、可追踪,也方便最终在 `result.md` 中回溯整个执行过程。***
|
||||
@ -1,13 +0,0 @@
|
||||
请使用待办事项系统(todo_list)来规划多步骤任务,遵守以下原则:
|
||||
|
||||
1. **建立条件**:只要任务需要多个步骤、文件或工具,就先调用 `todo_create`。已有 todo_list 时请延续维护,不要重复创建。
|
||||
2. **结构要求**:
|
||||
- 概述写清目标和核心约束,控制在 50 字以内。
|
||||
- 任务最多 4 条,按执行顺序排列。
|
||||
- 每一条必须是立即可执行的动作,禁止含糊的“修改/优化/完善”等描述。
|
||||
3. **逻辑顺序**:按照真实操作顺序安排任务,例如“搜索资料 → 写文件 → 验证程序”,或“创建 HTML,CSS,JS → 启动网页并验证”。
|
||||
4. **细分粒度**:任务描述到具体文件/命令级别,例如“创建 src/pages/home.html 骨架”“撰写 home.css 布局样式”,而不是“搭建页面模块”。
|
||||
5. **一次成型**:把任务写成可以一次完成的操作,避免“先草稿再优化”之类重复步骤。
|
||||
6. **执行纪律**:
|
||||
- 完成某项后再调用 `todo_update_task` 勾选对应 task。
|
||||
- 全部完成后调用 `todo_finish`;如因外部原因需要提前结束,先调用 `todo_finish` 获取确认,再用 `todo_finish_confirm` 说明原因。
|
||||
@ -1,18 +0,0 @@
|
||||
"""子智能体Web服务入口(监听8092端口)。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from web_server import run_server, parse_arguments, DEFAULT_PORT
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_arguments()
|
||||
port = args.port or DEFAULT_PORT
|
||||
# 子智能体服务默认使用8092端口
|
||||
if port == DEFAULT_PORT:
|
||||
port = 8092
|
||||
run_server(
|
||||
path=args.path or str(Path(".").resolve()),
|
||||
thinking_mode=args.thinking_mode,
|
||||
port=port,
|
||||
debug=args.debug,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,194 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude颜色展示 - 简化版</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--claude-bg: #eeece2;
|
||||
--claude-text: #3d3929;
|
||||
--claude-accent: #da7756;
|
||||
--claude-accent-strong: #bd5d3a;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
background-color: var(--claude-bg);
|
||||
color: var(--claude-text);
|
||||
line-height: 1.5;
|
||||
overflow-y: auto;
|
||||
min-height: 100vh;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8em;
|
||||
margin: 15px 0;
|
||||
text-align: center;
|
||||
color: var(--claude-text);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3em;
|
||||
margin: 20px 0 10px 0;
|
||||
color: var(--claude-text);
|
||||
border-bottom: 2px solid var(--claude-accent);
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.color-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
margin: 10px 0;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 60px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 15px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.color-info {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.color-name {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.text-sample {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin: 10px 0;
|
||||
border-left: 3px solid var(--claude-accent);
|
||||
}
|
||||
|
||||
.button-demo {
|
||||
background: var(--claude-accent-strong);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chat-sample {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.user-msg {
|
||||
background: rgba(255,255,255,0.7);
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px 12px 0 12px;
|
||||
margin: 5px 0;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.ai-msg {
|
||||
background: rgba(218, 119, 86, 0.12);
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px 12px 12px 0;
|
||||
margin: 5px 0;
|
||||
margin-right: 20px;
|
||||
border-left: 2px solid var(--claude-accent-strong);
|
||||
}
|
||||
|
||||
/* 颜色定义 */
|
||||
.bg-color { background-color: var(--claude-bg); }
|
||||
.text-color { background-color: var(--claude-text); }
|
||||
.logo-color { background-color: var(--claude-accent); }
|
||||
.button-color { background-color: var(--claude-accent-strong); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Claude聊天界面颜色</h1>
|
||||
|
||||
<h2>核心颜色</h2>
|
||||
<div class="color-card">
|
||||
<div class="color-swatch bg-color"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-name">背景颜色</div>
|
||||
<div class="color-hex">#eeece2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-card">
|
||||
<div class="color-swatch text-color"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-name">字体颜色</div>
|
||||
<div class="color-hex">#3d3929</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-card">
|
||||
<div class="color-swatch logo-color"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-name">品牌主色</div>
|
||||
<div class="color-hex">#da7756</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-card">
|
||||
<div class="color-swatch button-color"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-name">按钮颜色</div>
|
||||
<div class="color-hex">#bd5d3a</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>字体样式</h2>
|
||||
<div class="text-sample">
|
||||
字体栈:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif
|
||||
<br><br>
|
||||
<i>斜体样式展示</i> | <b>粗体样式展示</b>
|
||||
</div>
|
||||
|
||||
<h2>界面元素</h2>
|
||||
<div class="text-sample">
|
||||
<a href="#" class="button-demo">主要按钮</a>
|
||||
<a href="#" class="button-demo" style="background: #da7756;">悬停效果</a>
|
||||
</div>
|
||||
|
||||
<h2>对话界面模拟</h2>
|
||||
<div class="chat-sample">
|
||||
<div class="user-msg">你好,Claude!今天天气怎么样?</div>
|
||||
<div class="ai-msg">你好!我无法获取实时天气信息,但你可以通过天气应用查看当地天气。</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sample">
|
||||
这种配色方案创造出温暖、专业的对话环境,背景是米白色(#eeece2),文字是深棕色(#3d3929),给人舒适友好的感觉。
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,399 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Agent 调试监控</title>
|
||||
<script src="https://unpkg.com/vue@3.3.4/dist/vue.global.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/socket.io-client@4.6.1/dist/socket.io.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: monospace;
|
||||
background: #1a1a1a;
|
||||
color: #0f0;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.panel {
|
||||
background: #000;
|
||||
border: 1px solid #0f0;
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.event-log {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.event-item {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.event-type {
|
||||
color: #ff0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.timestamp {
|
||||
color: #888;
|
||||
}
|
||||
.actions-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.action-card {
|
||||
border: 1px solid #444;
|
||||
padding: 5px;
|
||||
background: #111;
|
||||
}
|
||||
.action-type {
|
||||
color: #0ff;
|
||||
}
|
||||
.tool-status {
|
||||
color: #f0f;
|
||||
}
|
||||
.streaming-true {
|
||||
background: #330;
|
||||
}
|
||||
.status-running {
|
||||
background: #003;
|
||||
}
|
||||
.status-completed {
|
||||
background: #030;
|
||||
}
|
||||
.raw-data {
|
||||
background: #222;
|
||||
padding: 5px;
|
||||
margin: 5px 0;
|
||||
white-space: pre-wrap;
|
||||
font-size: 11px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
button {
|
||||
background: #0f0;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #0ff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="debug-app" class="container">
|
||||
<h1>🔧 AI Agent 调试监控</h1>
|
||||
|
||||
<!-- 连接状态 -->
|
||||
<div class="panel">
|
||||
<h3>连接状态</h3>
|
||||
<div>Socket连接: <span :style="{color: isConnected ? '#0f0' : '#f00'}">{{ isConnected ? '已连接' : '未连接' }}</span></div>
|
||||
<div>当前消息索引: {{ currentMessageIndex }}</div>
|
||||
<div>消息总数: {{ messages.length }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="panel">
|
||||
<h3>控制面板</h3>
|
||||
<button @click="clearLogs">清除日志</button>
|
||||
<button @click="testMessage">发送测试消息</button>
|
||||
<button @click="exportData">导出数据</button>
|
||||
<label>
|
||||
<input type="checkbox" v-model="pauseUpdates"> 暂停更新
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 事件日志 -->
|
||||
<div class="panel">
|
||||
<h3>WebSocket事件流 (最新 {{ events.length }} 条)</h3>
|
||||
<div class="event-log">
|
||||
<div v-for="(event, idx) in events" :key="idx" class="event-item">
|
||||
<span class="timestamp">{{ event.time }}</span>
|
||||
<span class="event-type">{{ event.type }}</span>
|
||||
<span v-if="event.data">: {{ JSON.stringify(event.data).slice(0, 200) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前消息的Actions监控 -->
|
||||
<div class="panel" v-if="currentMessageIndex >= 0 && messages[currentMessageIndex]">
|
||||
<h3>当前消息Actions状态 (消息 #{{ currentMessageIndex }})</h3>
|
||||
<div class="actions-monitor">
|
||||
<div v-for="(action, idx) in messages[currentMessageIndex].actions"
|
||||
:key="action.id"
|
||||
class="action-card"
|
||||
:class="{
|
||||
'streaming-true': action.streaming,
|
||||
'status-running': action.tool && action.tool.status === 'running',
|
||||
'status-completed': action.tool && action.tool.status === 'completed'
|
||||
}">
|
||||
<div class="action-type">Action #{{ idx }}: {{ action.type }}</div>
|
||||
<div v-if="action.type === 'text'">
|
||||
Streaming: <b>{{ action.streaming }}</b><br>
|
||||
Content长度: {{ action.content ? action.content.length : 0 }}
|
||||
</div>
|
||||
<div v-if="action.type === 'tool'">
|
||||
工具: {{ action.tool.name }}<br>
|
||||
<span class="tool-status">状态: {{ action.tool.status }}</span><br>
|
||||
ID: {{ action.tool.id }}<br>
|
||||
有结果: {{ !!action.tool.result }}
|
||||
</div>
|
||||
<div v-if="action.type === 'thinking'">
|
||||
Streaming: <b>{{ action.streaming }}</b><br>
|
||||
Content长度: {{ action.content ? action.content.length : 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 原始数据查看 -->
|
||||
<div class="panel">
|
||||
<h3>最新消息原始数据</h3>
|
||||
<div class="raw-data" v-if="messages.length > 0">{{ JSON.stringify(messages[messages.length - 1], null, 2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
const debugApp = createApp({
|
||||
data() {
|
||||
return {
|
||||
isConnected: false,
|
||||
socket: null,
|
||||
events: [],
|
||||
messages: [],
|
||||
currentMessageIndex: -1,
|
||||
pauseUpdates: false,
|
||||
maxEvents: 100
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.initSocket();
|
||||
},
|
||||
|
||||
methods: {
|
||||
initSocket() {
|
||||
this.socket = io('/', {
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
|
||||
// 监听所有事件
|
||||
const events = [
|
||||
'connect', 'disconnect', 'system_ready',
|
||||
'ai_message_start', 'thinking_start', 'thinking_chunk', 'thinking_end',
|
||||
'text_start', 'text_chunk', 'text_end',
|
||||
'tool_preparing', 'tool_hint', 'tool_start', 'tool_status',
|
||||
'tool_execution_start', 'tool_execution_end', 'update_action',
|
||||
'task_complete', 'error'
|
||||
];
|
||||
|
||||
events.forEach(eventName => {
|
||||
this.socket.on(eventName, (data) => {
|
||||
this.logEvent(eventName, data);
|
||||
this.handleEvent(eventName, data);
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
this.isConnected = false;
|
||||
});
|
||||
},
|
||||
|
||||
logEvent(type, data) {
|
||||
if (this.pauseUpdates) return;
|
||||
|
||||
const event = {
|
||||
time: new Date().toLocaleTimeString('zh-CN', { hour12: false, milliseconds: true }),
|
||||
type: type,
|
||||
data: data
|
||||
};
|
||||
|
||||
this.events.unshift(event);
|
||||
if (this.events.length > this.maxEvents) {
|
||||
this.events = this.events.slice(0, this.maxEvents);
|
||||
}
|
||||
},
|
||||
|
||||
handleEvent(eventName, data) {
|
||||
if (this.pauseUpdates) return;
|
||||
|
||||
// 处理消息相关事件
|
||||
switch(eventName) {
|
||||
case 'ai_message_start':
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
actions: [],
|
||||
timestamp: Date.now()
|
||||
});
|
||||
this.currentMessageIndex = this.messages.length - 1;
|
||||
break;
|
||||
|
||||
case 'thinking_start':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
msg.actions.push({
|
||||
id: Date.now(),
|
||||
type: 'thinking',
|
||||
content: '',
|
||||
streaming: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thinking_end':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
const thinkingAction = msg.actions.find(a => a.type === 'thinking' && a.streaming);
|
||||
if (thinkingAction) {
|
||||
thinkingAction.streaming = false;
|
||||
console.log('思考结束,设置streaming=false');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text_start':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
msg.actions.push({
|
||||
id: Date.now(),
|
||||
type: 'text',
|
||||
content: '',
|
||||
streaming: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text_chunk':
|
||||
if (this.currentMessageIndex >= 0 && data.content) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
const textAction = [...msg.actions].reverse().find(a => a.type === 'text' && a.streaming);
|
||||
if (textAction) {
|
||||
textAction.content += data.content;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text_end':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
const textAction = [...msg.actions].reverse().find(a => a.type === 'text' && a.streaming);
|
||||
if (textAction) {
|
||||
textAction.streaming = false;
|
||||
console.log('文本结束,设置streaming=false');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_preparing':
|
||||
case 'tool_hint':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
msg.actions.push({
|
||||
id: data.id,
|
||||
type: 'tool',
|
||||
tool: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
status: 'preparing',
|
||||
arguments: {},
|
||||
result: null
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
// 查找准备中的工具或创建新的
|
||||
let tool = msg.actions.find(a =>
|
||||
a.type === 'tool' &&
|
||||
(a.id === data.preparing_id || a.tool.id === data.preparing_id)
|
||||
);
|
||||
|
||||
if (tool) {
|
||||
tool.tool.status = 'running';
|
||||
tool.tool.arguments = data.arguments;
|
||||
console.log('工具开始运行,状态改为running');
|
||||
} else {
|
||||
msg.actions.push({
|
||||
id: data.id,
|
||||
type: 'tool',
|
||||
tool: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
status: 'running',
|
||||
arguments: data.arguments,
|
||||
result: null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_action':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
const tool = msg.actions.find(a =>
|
||||
a.type === 'tool' &&
|
||||
(a.tool.id === data.id || a.tool.id === data.preparing_id || a.id === data.id)
|
||||
);
|
||||
|
||||
if (tool) {
|
||||
tool.tool.status = data.status || 'completed';
|
||||
tool.tool.result = data.result;
|
||||
console.log(`工具完成,状态改为${tool.tool.status}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task_complete':
|
||||
this.currentMessageIndex = -1;
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
clearLogs() {
|
||||
this.events = [];
|
||||
this.messages = [];
|
||||
this.currentMessageIndex = -1;
|
||||
},
|
||||
|
||||
testMessage() {
|
||||
this.socket.emit('send_message', { message: '创建一个test.txt文件' });
|
||||
},
|
||||
|
||||
exportData() {
|
||||
const data = {
|
||||
events: this.events,
|
||||
messages: this.messages,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `debug-${Date.now()}.json`;
|
||||
a.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debugApp.mount('#debug-app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,899 +0,0 @@
|
||||
(() => {
|
||||
const API_BASE = '/api/gui/files';
|
||||
const EDITOR_PAGE = '/file-manager/editor';
|
||||
|
||||
const state = {
|
||||
currentPath: '',
|
||||
items: [],
|
||||
selected: new Set(),
|
||||
lastSelectedIndex: null,
|
||||
clipboard: null, // {mode: 'copy'|'cut', items: []}
|
||||
treeCache: new Map(),
|
||||
treeExpanded: new Set(['']),
|
||||
isDraggingSelection: false,
|
||||
dragStart: null,
|
||||
selectionRect: null,
|
||||
selectionJustFinished: false,
|
||||
selectionDisabled: false,
|
||||
};
|
||||
|
||||
const icons = {
|
||||
directory: '📁',
|
||||
default: '📄',
|
||||
editable: '📝',
|
||||
code: '💻',
|
||||
markdown: '🧾',
|
||||
image: '🖼️',
|
||||
archive: '🗃️',
|
||||
};
|
||||
|
||||
const fileGrid = document.getElementById('fileGrid');
|
||||
const directoryTree = document.getElementById('directoryTree');
|
||||
const breadcrumbEl = document.getElementById('breadcrumb');
|
||||
const selectionInfo = document.getElementById('selectionInfo');
|
||||
const statusBar = document.getElementById('statusBar');
|
||||
const contextMenu = document.getElementById('contextMenu');
|
||||
const dialogBackdrop = document.getElementById('dialogBackdrop');
|
||||
const dialogTitle = document.getElementById('dialogTitle');
|
||||
const dialogContent = document.getElementById('dialogContent');
|
||||
const dialogCancel = document.getElementById('dialogCancel');
|
||||
const dialogConfirm = document.getElementById('dialogConfirm');
|
||||
const hiddenUploader = document.getElementById('hiddenUploader');
|
||||
const pasteBtn = document.getElementById('btnPaste');
|
||||
|
||||
const newFolderBtn = document.getElementById('btnNewFolder');
|
||||
const newFileBtn = document.getElementById('btnNewFile');
|
||||
const refreshBtn = document.getElementById('btnRefresh');
|
||||
const uploadBtn = document.getElementById('btnUpload');
|
||||
const backBtn = document.getElementById('btnBack');
|
||||
const returnChatBtn = document.getElementById('btnReturnChat');
|
||||
const downloadBtn = document.getElementById('btnDownload');
|
||||
const renameBtn = document.getElementById('btnRename');
|
||||
const copyBtn = document.getElementById('btnCopy');
|
||||
const cutBtn = document.getElementById('btnCut');
|
||||
const deleteBtn = document.getElementById('btnDelete');
|
||||
const toggleSelectionBtn = document.getElementById('btnToggleSelection');
|
||||
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialPathParam = (urlParams.get('path') || '').replace(/^\//, '').replace(/\/$/, '');
|
||||
|
||||
dialogBackdrop.hidden = true;
|
||||
|
||||
let dialogHandlers = { confirm: null, cancel: null };
|
||||
|
||||
function clearDialogHandlers() {
|
||||
dialogHandlers.confirm = null;
|
||||
dialogHandlers.cancel = null;
|
||||
}
|
||||
|
||||
function registerDialogHandlers(confirmHandler, cancelHandler) {
|
||||
dialogHandlers.confirm = confirmHandler || null;
|
||||
dialogHandlers.cancel = cancelHandler || null;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogBackdrop.hidden = true;
|
||||
clearDialogHandlers();
|
||||
}
|
||||
|
||||
dialogCancel.addEventListener('click', () => {
|
||||
if (dialogHandlers.cancel) {
|
||||
const handler = dialogHandlers.cancel;
|
||||
clearDialogHandlers();
|
||||
handler();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
dialogConfirm.addEventListener('click', () => {
|
||||
if (dialogHandlers.confirm) {
|
||||
const handler = dialogHandlers.confirm;
|
||||
clearDialogHandlers();
|
||||
handler();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
dialogBackdrop.addEventListener('click', (event) => {
|
||||
if (event.target === dialogBackdrop) {
|
||||
if (dialogHandlers.cancel) {
|
||||
const handler = dialogHandlers.cancel;
|
||||
clearDialogHandlers();
|
||||
handler();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !dialogBackdrop.hidden) {
|
||||
if (dialogHandlers.cancel) {
|
||||
const handler = dialogHandlers.cancel;
|
||||
clearDialogHandlers();
|
||||
handler();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
|
||||
function showStatus(message) {
|
||||
statusBar.textContent = message;
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function joinPath(base, name) {
|
||||
if (!base) return name;
|
||||
return `${base.replace(/\/$/, '')}/${name}`;
|
||||
}
|
||||
|
||||
function getIcon(entry) {
|
||||
if (entry.type === 'directory') return icons.directory;
|
||||
if (entry.is_editable) return icons.editable;
|
||||
const ext = entry.extension || '';
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp'].includes(ext)) {
|
||||
return icons.image;
|
||||
}
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
|
||||
return icons.archive;
|
||||
}
|
||||
if (['.js', '.ts', '.py', '.rb', '.php', '.java', '.kt', '.go', '.rs', '.c', '.cpp', '.h', '.hpp'].includes(ext)) {
|
||||
return icons.code;
|
||||
}
|
||||
if (['.md', '.markdown'].includes(ext)) {
|
||||
return icons.markdown;
|
||||
}
|
||||
return icons.default;
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.success === false) {
|
||||
const message = data.error || data.message || `请求失败 (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function updateUrl(path) {
|
||||
const url = new URL(window.location.href);
|
||||
if (path) {
|
||||
url.searchParams.set('path', path);
|
||||
} else {
|
||||
url.searchParams.delete('path');
|
||||
}
|
||||
window.history.replaceState({}, '', url.pathname + url.search);
|
||||
}
|
||||
|
||||
async function ensureAncestors(path) {
|
||||
await ensureTreeNode('', false);
|
||||
state.treeExpanded.add('');
|
||||
if (!path) {
|
||||
renderTree();
|
||||
return;
|
||||
}
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index];
|
||||
current = current ? `${current}/${segment}` : segment;
|
||||
if (index < segments.length - 1) {
|
||||
state.treeExpanded.add(current);
|
||||
}
|
||||
await ensureTreeNode(current, false);
|
||||
}
|
||||
renderTree();
|
||||
}
|
||||
|
||||
async function loadDirectory(path = '', { updateHistory = true } = {}) {
|
||||
hideContextMenu();
|
||||
showStatus('加载中...');
|
||||
try {
|
||||
const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`);
|
||||
const resolvedPath = result.data.path || '';
|
||||
state.currentPath = resolvedPath;
|
||||
state.items = result.data.items || [];
|
||||
const directoryEntries = state.items.filter((item) => item.type === 'directory');
|
||||
state.treeCache.set(resolvedPath, directoryEntries);
|
||||
if (updateHistory) {
|
||||
updateUrl(resolvedPath);
|
||||
}
|
||||
await ensureAncestors(resolvedPath);
|
||||
renderBreadcrumb(result.data.breadcrumb || []);
|
||||
renderGrid();
|
||||
updateSelection([]);
|
||||
state.lastSelectedIndex = null;
|
||||
showStatus(`已加载 ${state.items.length} 项`);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreadcrumb(crumbs) {
|
||||
breadcrumbEl.innerHTML = '';
|
||||
crumbs.forEach((crumb, index) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = crumb.name;
|
||||
span.dataset.path = crumb.path;
|
||||
span.addEventListener('click', () => {
|
||||
loadDirectory(crumb.path);
|
||||
});
|
||||
breadcrumbEl.appendChild(span);
|
||||
if (index < crumbs.length - 1) {
|
||||
const sep = document.createElement('span');
|
||||
sep.textContent = '›';
|
||||
sep.classList.add('fm-breadcrumb-sep');
|
||||
breadcrumbEl.appendChild(sep);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
fileGrid.innerHTML = '';
|
||||
state.items.forEach((entry, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'fm-card';
|
||||
card.tabIndex = 0;
|
||||
card.dataset.path = entry.path;
|
||||
card.dataset.index = index;
|
||||
if (state.selected.has(entry.path)) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'fm-card-icon';
|
||||
icon.textContent = getIcon(entry);
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'fm-card-name';
|
||||
name.textContent = entry.name;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'fm-card-meta';
|
||||
const lines = [];
|
||||
if (entry.type === 'file') {
|
||||
lines.push(formatSize(entry.size));
|
||||
} else {
|
||||
lines.push('目录');
|
||||
}
|
||||
lines.push(formatTime(entry.modified_at));
|
||||
meta.innerHTML = lines.join('<br>');
|
||||
|
||||
card.appendChild(icon);
|
||||
card.appendChild(name);
|
||||
card.appendChild(meta);
|
||||
|
||||
card.addEventListener('click', (event) => handleItemClick(event, entry, index));
|
||||
card.addEventListener('dblclick', () => handleItemDoubleClick(entry));
|
||||
card.addEventListener('contextmenu', (event) => handleItemContextMenu(event, entry));
|
||||
|
||||
fileGrid.appendChild(card);
|
||||
});
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fm-drop-overlay';
|
||||
overlay.textContent = '释放即可上传到此目录';
|
||||
fileGrid.appendChild(overlay);
|
||||
}
|
||||
|
||||
function updateSelection(paths, options = { append: false, range: false }) {
|
||||
if (!options.append && !options.range) {
|
||||
state.selected.clear();
|
||||
if (paths.length === 0) {
|
||||
state.lastSelectedIndex = null;
|
||||
}
|
||||
}
|
||||
paths.forEach((path) => {
|
||||
if (state.selected.has(path) && options.append) {
|
||||
state.selected.delete(path);
|
||||
} else {
|
||||
state.selected.add(path);
|
||||
}
|
||||
});
|
||||
syncSelectionUI();
|
||||
}
|
||||
|
||||
function syncSelectionUI() {
|
||||
const cards = fileGrid.querySelectorAll('.fm-card');
|
||||
cards.forEach((card) => {
|
||||
if (state.selected.has(card.dataset.path)) {
|
||||
card.classList.add('selected');
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
selectionInfo.textContent = `已选中 ${state.selected.size} 项`;
|
||||
pasteBtn.disabled = !state.clipboard || !state.clipboard.items.length;
|
||||
}
|
||||
|
||||
function handleItemClick(event, entry, index) {
|
||||
const isMetaKey = event.metaKey || event.ctrlKey;
|
||||
const isShiftKey = event.shiftKey;
|
||||
if (isShiftKey && state.lastSelectedIndex !== null) {
|
||||
const start = Math.min(state.lastSelectedIndex, index);
|
||||
const end = Math.max(state.lastSelectedIndex, index);
|
||||
const paths = state.items.slice(start, end + 1).map((item) => item.path);
|
||||
updateSelection(paths, { range: true });
|
||||
} else if (isMetaKey) {
|
||||
updateSelection([entry.path], { append: true });
|
||||
state.lastSelectedIndex = index;
|
||||
} else {
|
||||
updateSelection([entry.path], { append: false });
|
||||
state.lastSelectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemDoubleClick(entry) {
|
||||
if (entry.type === 'directory') {
|
||||
loadDirectory(entry.path);
|
||||
return;
|
||||
}
|
||||
if (entry.is_editable) {
|
||||
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
|
||||
return;
|
||||
}
|
||||
window.open(`${API_BASE}/download?path=${encodeURIComponent(entry.path)}`, '_blank');
|
||||
}
|
||||
|
||||
function handleItemContextMenu(event, entry) {
|
||||
event.preventDefault();
|
||||
if (!state.selected.has(entry.path)) {
|
||||
updateSelection([entry.path], { append: false });
|
||||
}
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
const single = state.selected.size === 1;
|
||||
const singleEntry = single ? getSingleSelected() : null;
|
||||
contextMenu.innerHTML = '';
|
||||
|
||||
const entries = [];
|
||||
|
||||
if (singleEntry) {
|
||||
if (singleEntry.type === 'directory') {
|
||||
entries.push({ label: '打开', action: openSelected, disabled: false });
|
||||
} else if (singleEntry.is_editable) {
|
||||
entries.push({ label: '在编辑器中打开', action: openEditor, disabled: false });
|
||||
if (singleEntry.extension === '.html' || singleEntry.extension === '.htm') {
|
||||
entries.push({ label: '预览', action: previewSelected, disabled: false });
|
||||
}
|
||||
entries.push({ label: '下载', action: downloadSelected, disabled: false });
|
||||
} else {
|
||||
const isHtml = singleEntry.extension === '.html' || singleEntry.extension === '.htm';
|
||||
if (isHtml) {
|
||||
entries.push({ label: '预览', action: previewSelected, disabled: false });
|
||||
}
|
||||
entries.push({ label: '下载', action: downloadSelected, disabled: false });
|
||||
}
|
||||
} else if (state.selected.size > 0) {
|
||||
entries.push({ label: '下载', action: downloadSelected, disabled: false });
|
||||
}
|
||||
|
||||
entries.push(
|
||||
{ label: '重命名', action: renameSelected, disabled: !single },
|
||||
{ label: '复制', action: copySelected, disabled: state.selected.size === 0 },
|
||||
{ label: '剪切', action: cutSelected, disabled: state.selected.size === 0 },
|
||||
{ label: '粘贴', action: pasteClipboard, disabled: !state.clipboard || !state.clipboard.items.length },
|
||||
{ label: '删除', action: deleteSelected, disabled: state.selected.size === 0 },
|
||||
);
|
||||
|
||||
entries.forEach((item) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = item.label;
|
||||
btn.disabled = item.disabled;
|
||||
btn.addEventListener('click', () => {
|
||||
hideContextMenu();
|
||||
item.action();
|
||||
});
|
||||
contextMenu.appendChild(btn);
|
||||
});
|
||||
contextMenu.style.display = 'block';
|
||||
const { innerWidth, innerHeight } = window;
|
||||
const menuRect = contextMenu.getBoundingClientRect();
|
||||
const left = clamp(x, 0, innerWidth - menuRect.width);
|
||||
const top = clamp(y, 0, innerHeight - menuRect.height);
|
||||
contextMenu.style.left = `${left}px`;
|
||||
contextMenu.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
contextMenu.style.display = 'none';
|
||||
}
|
||||
|
||||
function getSingleSelected() {
|
||||
if (state.selected.size !== 1) return null;
|
||||
const path = Array.from(state.selected)[0];
|
||||
return state.items.find((item) => item.path === path) || null;
|
||||
}
|
||||
|
||||
function openSelected() {
|
||||
const entry = getSingleSelected();
|
||||
if (!entry) return;
|
||||
handleItemDoubleClick(entry);
|
||||
}
|
||||
|
||||
function previewSelected() {
|
||||
const entry = getSingleSelected();
|
||||
if (!entry) return;
|
||||
if (entry.extension !== '.html' && entry.extension !== '.htm') {
|
||||
showStatus('仅支持预览 HTML 文件');
|
||||
return;
|
||||
}
|
||||
window.open(`/file-preview/${encodeURIComponent(entry.path)}`, '_blank');
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
const entry = getSingleSelected();
|
||||
if (!entry || !entry.is_editable) return;
|
||||
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
|
||||
}
|
||||
|
||||
function triggerBlobDownload(filename, blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function downloadSelected() {
|
||||
if (!state.selected.size) return;
|
||||
if (state.selected.size === 1) {
|
||||
const path = Array.from(state.selected)[0];
|
||||
window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/download/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: Array.from(state.selected) })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let message = '批量下载失败';
|
||||
try {
|
||||
const data = await resp.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
triggerBlobDownload(`selected_${Date.now()}.zip`, blob);
|
||||
showStatus(`已开始下载 ${state.selected.size} 个项目`);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSelected() {
|
||||
const entry = getSingleSelected();
|
||||
if (!entry) return;
|
||||
const newName = await promptDialog('重命名', entry.name);
|
||||
if (!newName) return;
|
||||
try {
|
||||
await request(`${API_BASE}/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: entry.path, new_name: newName }),
|
||||
});
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
if (!state.selected.size) return;
|
||||
state.clipboard = { mode: 'copy', items: Array.from(state.selected) };
|
||||
showStatus(`已复制 ${state.clipboard.items.length} 项`);
|
||||
syncSelectionUI();
|
||||
}
|
||||
|
||||
function cutSelected() {
|
||||
if (!state.selected.size) return;
|
||||
state.clipboard = { mode: 'cut', items: Array.from(state.selected) };
|
||||
showStatus(`已剪切 ${state.clipboard.items.length} 项`);
|
||||
syncSelectionUI();
|
||||
}
|
||||
|
||||
async function pasteClipboard() {
|
||||
if (!state.clipboard || !state.clipboard.items.length) return;
|
||||
const endpoint = state.clipboard.mode === 'copy' ? 'copy' : 'move';
|
||||
try {
|
||||
await request(`${API_BASE}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
paths: state.clipboard.items,
|
||||
target_dir: state.currentPath,
|
||||
}),
|
||||
});
|
||||
if (state.clipboard.mode === 'cut') {
|
||||
state.clipboard = null;
|
||||
}
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!state.selected.size) return;
|
||||
const confirm = await confirmDialog(`确认删除选中的 ${state.selected.size} 项吗?该操作不可撤销。`);
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await request(`${API_BASE}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: Array.from(state.selected) }),
|
||||
});
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function promptDialog(title, defaultValue = '') {
|
||||
return new Promise((resolve) => {
|
||||
dialogTitle.textContent = title;
|
||||
dialogContent.innerHTML = '';
|
||||
const input = document.createElement('input');
|
||||
input.value = defaultValue;
|
||||
input.autofocus = true;
|
||||
dialogContent.appendChild(input);
|
||||
const finish = (value) => {
|
||||
closeDialog();
|
||||
resolve(value);
|
||||
};
|
||||
registerDialogHandlers(() => finish(input.value.trim()), () => finish(null));
|
||||
dialogBackdrop.hidden = false;
|
||||
input.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
evt.preventDefault();
|
||||
finish(input.value.trim());
|
||||
} else if (evt.key === 'Escape') {
|
||||
evt.preventDefault();
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
setTimeout(() => input.select(), 50);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDialog(message) {
|
||||
return new Promise((resolve) => {
|
||||
dialogTitle.textContent = '确认操作';
|
||||
dialogContent.innerHTML = `<p>${message}</p>`;
|
||||
const finish = (value) => {
|
||||
closeDialog();
|
||||
resolve(value);
|
||||
};
|
||||
registerDialogHandlers(() => finish(true), () => finish(false));
|
||||
dialogBackdrop.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleGlobalClick(event) {
|
||||
if (!contextMenu.contains(event.target)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function handleGridBackgroundClick(event) {
|
||||
if (event.target === fileGrid) {
|
||||
if (state.selectionJustFinished) {
|
||||
return;
|
||||
}
|
||||
updateSelection([]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnter(event) {
|
||||
event.preventDefault();
|
||||
fileGrid.classList.add('drop-target');
|
||||
}
|
||||
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleDragLeave(event) {
|
||||
if (event.target === fileGrid) {
|
||||
fileGrid.classList.remove('drop-target');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(event) {
|
||||
event.preventDefault();
|
||||
fileGrid.classList.remove('drop-target');
|
||||
if (!event.dataTransfer || !event.dataTransfer.files.length) return;
|
||||
const files = event.dataTransfer.files;
|
||||
await uploadFiles(files, state.currentPath);
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList, targetPath) {
|
||||
for (const file of fileList) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
form.append('filename', file.name);
|
||||
form.append('path', targetPath);
|
||||
try {
|
||||
await request(`${API_BASE}/upload`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
showStatus(`已上传 ${file.name}`);
|
||||
} catch (err) {
|
||||
showStatus(`上传失败:${err.message}`);
|
||||
}
|
||||
}
|
||||
await loadDirectory(state.currentPath);
|
||||
}
|
||||
|
||||
function initSelectionRectangle() {
|
||||
fileGrid.addEventListener('pointerdown', (event) => {
|
||||
if (state.selectionDisabled) return;
|
||||
if (event.target !== fileGrid) return;
|
||||
state.isDraggingSelection = true;
|
||||
state.dragStart = { x: event.clientX, y: event.clientY };
|
||||
state.selectionRect = document.createElement('div');
|
||||
state.selectionRect.className = 'fm-selection-rect';
|
||||
fileGrid.appendChild(state.selectionRect);
|
||||
updateSelection([]);
|
||||
state.selectionJustFinished = false;
|
||||
fileGrid.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
fileGrid.addEventListener('pointermove', (event) => {
|
||||
if (!state.isDraggingSelection || !state.selectionRect) return;
|
||||
const rect = fileGrid.getBoundingClientRect();
|
||||
const current = { x: event.clientX, y: event.clientY };
|
||||
const x = Math.min(state.dragStart.x, current.x) - rect.left + fileGrid.scrollLeft;
|
||||
const y = Math.min(state.dragStart.y, current.y) - rect.top + fileGrid.scrollTop;
|
||||
const width = Math.abs(state.dragStart.x - current.x);
|
||||
const height = Math.abs(state.dragStart.y - current.y);
|
||||
Object.assign(state.selectionRect.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
});
|
||||
|
||||
const selectionBox = {
|
||||
left: Math.min(state.dragStart.x, current.x),
|
||||
right: Math.max(state.dragStart.x, current.x),
|
||||
top: Math.min(state.dragStart.y, current.y),
|
||||
bottom: Math.max(state.dragStart.y, current.y),
|
||||
};
|
||||
|
||||
const selected = [];
|
||||
const cards = fileGrid.querySelectorAll('.fm-card');
|
||||
cards.forEach((card) => {
|
||||
const bounds = card.getBoundingClientRect();
|
||||
const intersects = !(selectionBox.right < bounds.left ||
|
||||
selectionBox.left > bounds.right ||
|
||||
selectionBox.bottom < bounds.top ||
|
||||
selectionBox.top > bounds.bottom);
|
||||
if (intersects) {
|
||||
selected.push(card.dataset.path);
|
||||
}
|
||||
});
|
||||
updateSelection(selected);
|
||||
});
|
||||
|
||||
fileGrid.addEventListener('pointerup', (event) => {
|
||||
if (!state.isDraggingSelection) return;
|
||||
state.isDraggingSelection = false;
|
||||
if (state.selectionRect) {
|
||||
state.selectionRect.remove();
|
||||
state.selectionRect = null;
|
||||
}
|
||||
state.selectionJustFinished = true;
|
||||
requestAnimationFrame(() => {
|
||||
state.selectionJustFinished = false;
|
||||
});
|
||||
fileGrid.releasePointerCapture(event.pointerId);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTreeNode(path, shouldRender = true) {
|
||||
let changed = false;
|
||||
if (!state.treeCache.has(path)) {
|
||||
try {
|
||||
const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`);
|
||||
const directories = result.data.items.filter((item) => item.type === 'directory');
|
||||
state.treeCache.set(path, directories);
|
||||
changed = true;
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
if (shouldRender && changed) {
|
||||
renderTree();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
directoryTree.innerHTML = '';
|
||||
const rootNode = createTreeNode('', '根目录');
|
||||
directoryTree.appendChild(rootNode);
|
||||
}
|
||||
|
||||
function createTreeNode(path, name) {
|
||||
const li = document.createElement('li');
|
||||
const header = document.createElement('div');
|
||||
header.className = 'fm-tree-item';
|
||||
if (path === state.currentPath) {
|
||||
header.classList.add('active');
|
||||
}
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'fm-tree-toggle';
|
||||
toggle.textContent = state.treeExpanded.has(path) ? '▾' : '▸';
|
||||
toggle.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
if (state.treeExpanded.has(path)) {
|
||||
state.treeExpanded.delete(path);
|
||||
} else {
|
||||
state.treeExpanded.add(path);
|
||||
await ensureTreeNode(path, false);
|
||||
}
|
||||
renderTree();
|
||||
});
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = name;
|
||||
label.addEventListener('click', () => loadDirectory(path));
|
||||
|
||||
header.addEventListener('click', () => loadDirectory(path));
|
||||
|
||||
header.appendChild(toggle);
|
||||
header.appendChild(label);
|
||||
li.appendChild(header);
|
||||
|
||||
if (state.treeExpanded.has(path)) {
|
||||
const children = document.createElement('ul');
|
||||
children.className = 'fm-tree-children';
|
||||
const dirs = state.treeCache.get(path) || [];
|
||||
dirs.forEach((dir) => {
|
||||
const child = createTreeNode(dir.path, dir.name);
|
||||
children.appendChild(child);
|
||||
});
|
||||
li.appendChild(children);
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
function bindToolbar() {
|
||||
newFolderBtn.addEventListener('click', async () => {
|
||||
const name = await promptDialog('新建文件夹', '新建文件夹');
|
||||
if (!name) return;
|
||||
try {
|
||||
await request(`${API_BASE}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: state.currentPath,
|
||||
name,
|
||||
type: 'directory',
|
||||
}),
|
||||
});
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
newFileBtn.addEventListener('click', async () => {
|
||||
const name = await promptDialog('新建文件', '新建文件.txt');
|
||||
if (!name) return;
|
||||
try {
|
||||
await request(`${API_BASE}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: state.currentPath,
|
||||
name,
|
||||
type: 'file',
|
||||
}),
|
||||
});
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener('click', () => loadDirectory(state.currentPath));
|
||||
uploadBtn.addEventListener('click', () => hiddenUploader.click());
|
||||
backBtn.addEventListener('click', () => {
|
||||
if (!state.currentPath) {
|
||||
loadDirectory('');
|
||||
return;
|
||||
}
|
||||
const segments = state.currentPath.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
loadDirectory('');
|
||||
return;
|
||||
}
|
||||
segments.pop();
|
||||
const parentPath = segments.join('/');
|
||||
loadDirectory(parentPath);
|
||||
});
|
||||
|
||||
returnChatBtn.addEventListener('click', () => {
|
||||
window.location.href = '/new';
|
||||
});
|
||||
downloadBtn.addEventListener('click', downloadSelected);
|
||||
renameBtn.addEventListener('click', renameSelected);
|
||||
copyBtn.addEventListener('click', copySelected);
|
||||
cutBtn.addEventListener('click', cutSelected);
|
||||
pasteBtn.addEventListener('click', pasteClipboard);
|
||||
deleteBtn.addEventListener('click', deleteSelected);
|
||||
toggleSelectionBtn.addEventListener('click', () => {
|
||||
state.selectionDisabled = !state.selectionDisabled;
|
||||
toggleSelectionBtn.textContent = state.selectionDisabled ? '启用框选' : '禁用框选';
|
||||
const msg = state.selectionDisabled ? '已禁用框选' : '已启用框选';
|
||||
showStatus(msg);
|
||||
});
|
||||
|
||||
hiddenUploader.addEventListener('change', async (event) => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length) {
|
||||
await uploadFiles(files, state.currentPath);
|
||||
}
|
||||
hiddenUploader.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
function bindGlobalEvents() {
|
||||
document.addEventListener('click', handleGlobalClick);
|
||||
fileGrid.addEventListener('click', handleGridBackgroundClick);
|
||||
fileGrid.addEventListener('contextmenu', (event) => {
|
||||
if (event.target === fileGrid) {
|
||||
event.preventDefault();
|
||||
if (state.selected.size) {
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
});
|
||||
fileGrid.addEventListener('dragenter', handleDragEnter);
|
||||
fileGrid.addEventListener('dragover', handleDragOver);
|
||||
fileGrid.addEventListener('dragleave', handleDragLeave);
|
||||
fileGrid.addEventListener('drop', handleDrop);
|
||||
initSelectionRectangle();
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
bindToolbar();
|
||||
bindGlobalEvents();
|
||||
await loadDirectory(initialPathParam, { updateHistory: false });
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error(err);
|
||||
showStatus(err.message);
|
||||
});
|
||||
})();
|
||||
@ -1,60 +0,0 @@
|
||||
.fe-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(17, 18, 26, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.fe-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fe-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fe-path {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
max-width: 52vw;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fe-status {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.fe-main {
|
||||
height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
#editorArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: #0f1119;
|
||||
color: #f1f3f5;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
font-family: "Fira Code", "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
#editorArea:focus {
|
||||
outline: none;
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>文件编辑器</title>
|
||||
<link rel="stylesheet" href="/static/file_manager/style.css">
|
||||
<link rel="stylesheet" href="/static/file_manager/editor.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="editorApp">
|
||||
<header class="fe-header">
|
||||
<div class="fe-left">
|
||||
<button class="fm-btn" id="btnBack">← 返回</button>
|
||||
<div class="fe-path" id="filePath"></div>
|
||||
</div>
|
||||
<div class="fe-right">
|
||||
<span class="fe-status" id="statusInfo">未保存修改</span>
|
||||
<button class="fm-btn" id="btnDownload">下载</button>
|
||||
<button class="fm-btn" id="btnIncreaseFont">A+</button>
|
||||
<button class="fm-btn" id="btnDecreaseFont">A-</button>
|
||||
<button class="fm-btn primary" id="btnSave">保存</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="fe-main">
|
||||
<textarea id="editorArea" spellcheck="false"></textarea>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/static/file_manager/editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,135 +0,0 @@
|
||||
(() => {
|
||||
const API_BASE = '/api/gui/files';
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const path = params.get('path');
|
||||
|
||||
const editorArea = document.getElementById('editorArea');
|
||||
const filePathEl = document.getElementById('filePath');
|
||||
const statusInfo = document.getElementById('statusInfo');
|
||||
const saveBtn = document.getElementById('btnSave');
|
||||
const downloadBtn = document.getElementById('btnDownload');
|
||||
const backBtn = document.getElementById('btnBack');
|
||||
const fontIncreaseBtn = document.getElementById('btnIncreaseFont');
|
||||
const fontDecreaseBtn = document.getElementById('btnDecreaseFont');
|
||||
|
||||
let originalContent = '';
|
||||
let dirty = false;
|
||||
let fontSize = 14;
|
||||
|
||||
if (!path) {
|
||||
editorArea.value = '缺少 path 参数,无法加载文件。';
|
||||
editorArea.disabled = true;
|
||||
saveBtn.disabled = true;
|
||||
downloadBtn.disabled = true;
|
||||
statusInfo.textContent = '缺少路径';
|
||||
return;
|
||||
}
|
||||
|
||||
filePathEl.textContent = path;
|
||||
|
||||
function setDirty(value) {
|
||||
dirty = value;
|
||||
statusInfo.textContent = dirty ? '有未保存的更改' : '已保存';
|
||||
saveBtn.disabled = !dirty;
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.success === false) {
|
||||
const message = data.error || data.message || `请求失败 (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadFile() {
|
||||
statusInfo.textContent = '加载中...';
|
||||
try {
|
||||
const result = await request(`${API_BASE}/text?path=${encodeURIComponent(path)}`);
|
||||
originalContent = result.content || '';
|
||||
editorArea.value = originalContent;
|
||||
setDirty(false);
|
||||
statusInfo.textContent = `最后修改时间:${result.modified_at}`;
|
||||
} catch (err) {
|
||||
editorArea.value = `文件加载失败:${err.message}`;
|
||||
editorArea.disabled = true;
|
||||
saveBtn.disabled = true;
|
||||
statusInfo.textContent = '无法加载文件';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
statusInfo.textContent = '保存中...';
|
||||
try {
|
||||
await request(`${API_BASE}/text`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, content: editorArea.value }),
|
||||
});
|
||||
originalContent = editorArea.value;
|
||||
setDirty(false);
|
||||
statusInfo.textContent = '已保存';
|
||||
} catch (err) {
|
||||
statusInfo.textContent = `保存失败:${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
editorArea.addEventListener('input', () => {
|
||||
if (editorArea.disabled) return;
|
||||
setDirty(editorArea.value !== originalContent);
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', () => {
|
||||
if (!dirty) return;
|
||||
saveFile();
|
||||
});
|
||||
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank');
|
||||
});
|
||||
|
||||
const getParentDirectory = () => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.length <= 1) {
|
||||
return '';
|
||||
}
|
||||
segments.pop();
|
||||
return segments.join('/');
|
||||
};
|
||||
|
||||
const navigateToManager = () => {
|
||||
const parentDir = getParentDirectory();
|
||||
const target = parentDir ? `/file-manager?path=${encodeURIComponent(parentDir)}` : '/file-manager';
|
||||
window.location.href = target;
|
||||
};
|
||||
|
||||
backBtn.addEventListener('click', () => {
|
||||
if (dirty) {
|
||||
const confirmLeave = window.confirm('有未保存的更改,确认要离开吗?');
|
||||
if (!confirmLeave) return;
|
||||
}
|
||||
navigateToManager();
|
||||
});
|
||||
|
||||
fontIncreaseBtn.addEventListener('click', () => {
|
||||
fontSize = Math.min(fontSize + 1, 28);
|
||||
editorArea.style.fontSize = `${fontSize}px`;
|
||||
});
|
||||
|
||||
fontDecreaseBtn.addEventListener('click', () => {
|
||||
fontSize = Math.max(fontSize - 1, 10);
|
||||
editorArea.style.fontSize = `${fontSize}px`;
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
});
|
||||
|
||||
loadFile().catch((err) => {
|
||||
console.error(err);
|
||||
statusInfo.textContent = err.message;
|
||||
});
|
||||
})();
|
||||
@ -1,60 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>文件管理器</title>
|
||||
<link rel="stylesheet" href="/static/file_manager/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="fm-header">
|
||||
<div class="fm-header-left">
|
||||
<button class="fm-btn" id="btnBack" title="返回上一页">← 返回</button>
|
||||
<button class="fm-btn" id="btnReturnChat" title="回到对话页面">返回对话</button>
|
||||
<button class="fm-btn" id="btnToggleSelection" title="切换框选功能">禁用框选</button>
|
||||
<div class="fm-breadcrumb" id="breadcrumb"></div>
|
||||
</div>
|
||||
<div class="fm-header-right">
|
||||
<input type="file" id="hiddenUploader" hidden>
|
||||
<button class="fm-btn" id="btnNewFolder">新建文件夹</button>
|
||||
<button class="fm-btn" id="btnNewFile">新建文件</button>
|
||||
<button class="fm-btn" id="btnUpload">上传</button>
|
||||
<button class="fm-btn" id="btnRefresh">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="fm-main">
|
||||
<aside class="fm-sidebar">
|
||||
<div class="fm-sidebar-title">目录</div>
|
||||
<ul class="fm-tree" id="directoryTree"></ul>
|
||||
</aside>
|
||||
<section class="fm-content">
|
||||
<div class="fm-toolbar">
|
||||
<div class="fm-selection-info" id="selectionInfo">已选中 0 项</div>
|
||||
<div class="fm-toolbar-right">
|
||||
<button class="fm-btn" id="btnDownload">下载</button>
|
||||
<button class="fm-btn" id="btnRename">重命名</button>
|
||||
<button class="fm-btn" id="btnCopy">复制</button>
|
||||
<button class="fm-btn" id="btnCut">剪切</button>
|
||||
<button class="fm-btn" id="btnPaste" disabled>粘贴</button>
|
||||
<button class="fm-btn danger" id="btnDelete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fm-grid" id="fileGrid"></div>
|
||||
<div class="fm-status-bar" id="statusBar">拖拽文件到此处可上传到当前目录</div>
|
||||
</section>
|
||||
</main>
|
||||
<div class="fm-context-menu" id="contextMenu"></div>
|
||||
<div class="fm-dialog-backdrop" id="dialogBackdrop" hidden>
|
||||
<div class="fm-dialog" id="dialog">
|
||||
<h3 id="dialogTitle"></h3>
|
||||
<div id="dialogContent"></div>
|
||||
<div class="fm-dialog-actions">
|
||||
<button class="fm-btn" id="dialogCancel">取消</button>
|
||||
<button class="fm-btn primary" id="dialogConfirm">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/file_manager/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,357 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
|
||||
background: #1d1f27;
|
||||
color: #f1f3f5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(17, 18, 26, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.fm-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fm-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fm-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fm-breadcrumb span {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.fm-breadcrumb span:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fm-btn {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: inherit;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.fm-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.fm-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fm-btn.primary {
|
||||
background: #3d8bfd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fm-btn.primary:hover {
|
||||
background: #377df5;
|
||||
}
|
||||
|
||||
.fm-btn.danger {
|
||||
background: #f06595;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fm-btn.danger:hover {
|
||||
background: #e64980;
|
||||
}
|
||||
|
||||
.fm-main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fm-sidebar {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
background: rgba(17, 18, 26, 0.92);
|
||||
}
|
||||
|
||||
.fm-sidebar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fm-tree {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fm-tree li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.fm-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.fm-tree-item:hover,
|
||||
.fm-tree-item.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fm-tree-toggle {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.fm-tree-children {
|
||||
list-style: none;
|
||||
padding-left: 16px;
|
||||
margin: 6px 0 0;
|
||||
border-left: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fm-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(17, 18, 26, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.fm-toolbar-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fm-selection-info {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.fm-grid {
|
||||
flex: 1;
|
||||
padding: 18px;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.fm-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: border 0.2s, background 0.2s, transform 0.2s;
|
||||
user-select: none;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.fm-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.fm-card.selected {
|
||||
border-color: #3d8bfd;
|
||||
background: rgba(61, 139, 253, 0.2);
|
||||
}
|
||||
|
||||
.fm-card-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.fm-card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fm-card-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.fm-status-bar {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(17, 18, 26, 0.8);
|
||||
}
|
||||
|
||||
.fm-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: rgba(17, 18, 26, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 6px 0;
|
||||
width: 180px;
|
||||
display: none;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.fm-context-menu button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fm-context-menu button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fm-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.fm-dialog-backdrop[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.fm-dialog {
|
||||
background: #1f212b;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 360px;
|
||||
max-width: 92vw;
|
||||
}
|
||||
|
||||
.fm-dialog h3 {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.fm-dialog input,
|
||||
.fm-dialog textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fm-dialog textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.fm-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.fm-selection-rect {
|
||||
position: absolute;
|
||||
border: 1px solid rgba(61, 139, 253, 0.8);
|
||||
background: rgba(61, 139, 253, 0.25);
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.fm-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px dashed rgba(61, 139, 253, 0.8);
|
||||
background: rgba(61, 139, 253, 0.12);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(61, 139, 253, 0.9);
|
||||
}
|
||||
|
||||
.fm-grid.drop-target .fm-drop-overlay {
|
||||
display: flex;
|
||||
}
|
||||
@ -1,622 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Agent System</title>
|
||||
|
||||
<!-- Vue 3 CDN -->
|
||||
<script src="https://unpkg.com/vue@3.3.4/dist/vue.global.prod.js"></script>
|
||||
|
||||
<!-- Socket.IO Client -->
|
||||
<script src="/static/vendor/socket.io.min.js"></script>
|
||||
|
||||
<!-- Marked.js for Markdown -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
|
||||
|
||||
<!-- Prism.js for code highlighting -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
||||
|
||||
<!-- KaTeX for LaTeX (无defer,同步加载) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js"></script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="!isConnected && messages.length === 0" style="text-align: center; padding: 50px;">
|
||||
<h2>正在连接服务器...</h2>
|
||||
<p>如果长时间无响应,请刷新页面</p>
|
||||
</div>
|
||||
|
||||
<!-- Main UI (只在连接后显示) -->
|
||||
<template v-else>
|
||||
<!-- 顶部状态栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<span class="logo">
|
||||
<template v-if="isSubAgentView">
|
||||
🤖 子智能体 <strong>#{{ subAgentTaskInfo.agent_id || '—' }}</strong>
|
||||
</template>
|
||||
<template v-else>
|
||||
🤖 AI Agent
|
||||
</template>
|
||||
</span>
|
||||
<span class="agent-version" v-if="isSubAgentView">
|
||||
工作目录: {{ formatPathDisplay(subAgentTaskInfo.workspace_dir) }}
|
||||
</span>
|
||||
<span class="agent-version" v-else-if="agentVersion">{{ agentVersion }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="thinking-mode" v-if="!isSubAgentView">{{ thinkingMode }}</span>
|
||||
<span class="thinking-mode" v-else>
|
||||
状态: {{ formatSubAgentStatus(subAgentTaskInfo.status) }}
|
||||
</span>
|
||||
<button v-if="isSubAgentView"
|
||||
type="button"
|
||||
class="btn stop-btn terminate-btn"
|
||||
@click="terminateSubAgentTask"
|
||||
:disabled="terminating">
|
||||
{{ terminating ? '关闭中...' : '关闭子智能体' }}
|
||||
</button>
|
||||
<span class="connection-status" :class="{ connected: isConnected }">
|
||||
<span class="status-dot" :class="{ active: isConnected }"></span>
|
||||
{{ isConnected ? '已连接' : '未连接' }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 新增:对话历史侧边栏(最左侧) -->
|
||||
<aside v-if="!isSubAgentView" class="conversation-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="conversation-header">
|
||||
<button @click="createNewConversation" class="new-conversation-btn" v-if="!sidebarCollapsed">
|
||||
<span class="btn-icon">+</span>
|
||||
<span class="btn-text">新建对话</span>
|
||||
</button>
|
||||
<button @click="toggleSidebar" class="toggle-sidebar-btn">
|
||||
<span v-if="sidebarCollapsed">☰</span>
|
||||
<span v-else>←</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="!sidebarCollapsed">
|
||||
<div class="conversation-search">
|
||||
<input v-model="searchQuery"
|
||||
@input="searchConversations"
|
||||
placeholder="搜索对话..."
|
||||
class="search-input">
|
||||
</div>
|
||||
|
||||
<div class="conversation-list">
|
||||
<div v-if="conversationsLoading" class="loading-conversations">
|
||||
正在加载...
|
||||
</div>
|
||||
<div v-else-if="conversations.length === 0" class="no-conversations">
|
||||
暂无对话记录
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: conv.id === currentConversationId }"
|
||||
@click="loadConversation(conv.id)">
|
||||
<div class="conversation-title">{{ conv.title }}</div>
|
||||
<div class="conversation-meta">
|
||||
<span class="conversation-time">{{ formatTime(conv.updated_at) }}</span>
|
||||
<span class="conversation-counts">
|
||||
{{ conv.total_messages }}条消息
|
||||
<span v-if="conv.total_tools > 0"> · {{ conv.total_tools }}工具</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="conversation-actions" @click.stop>
|
||||
<button @click="deleteConversation(conv.id)"
|
||||
class="conversation-action-btn delete-btn"
|
||||
title="删除对话">
|
||||
×
|
||||
</button>
|
||||
<button @click="duplicateConversation(conv.id)"
|
||||
class="conversation-action-btn copy-btn"
|
||||
title="复制对话">
|
||||
⧉
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasMoreConversations" class="load-more">
|
||||
<button @click="loadMoreConversations"
|
||||
:disabled="loadingMoreConversations"
|
||||
class="load-more-btn">
|
||||
{{ loadingMoreConversations ? '载入中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="conversation-collapsed-spacer"></div>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<!-- 左侧文件树 -->
|
||||
<aside class="sidebar left-sidebar" :style="{ width: leftWidth + 'px' }">
|
||||
<div class="sidebar-header">
|
||||
<button class="sidebar-view-toggle"
|
||||
@click="cycleSidebarPanel"
|
||||
:title="panelMode === 'files' ? '查看待办列表' : (panelMode === 'todo' ? (isSubAgentView ? '查看项目文件' : '查看子智能体') : '查看项目文件')">
|
||||
<span v-if="panelMode === 'files'">{{ todoEmoji }}</span>
|
||||
<span v-else-if="panelMode === 'todo'">{{ isSubAgentView ? fileEmoji : '🤖' }}</span>
|
||||
<span v-else>{{ fileEmoji }}</span>
|
||||
</button>
|
||||
<button class="sidebar-manage-btn"
|
||||
v-if="!isSubAgentView"
|
||||
@click="openGuiFileManager"
|
||||
title="打开桌面式文件管理器">
|
||||
管理
|
||||
</button>
|
||||
<h3>
|
||||
<span v-if="panelMode === 'files'">{{ fileEmoji }} 项目文件</span>
|
||||
<span v-else-if="panelMode === 'todo'">{{ todoEmoji }} 待办列表</span>
|
||||
<span v-else-if="!isSubAgentView">🤖 子智能体</span>
|
||||
</h3>
|
||||
</div>
|
||||
<template v-if="panelMode === 'todo'">
|
||||
<div class="todo-panel">
|
||||
<div v-if="!todoList" class="todo-empty">
|
||||
暂无待办列表
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="todo-task"
|
||||
v-for="task in (todoList.tasks || [])"
|
||||
:key="task.index"
|
||||
:class="{ done: task.status === 'done' }">
|
||||
<span class="todo-task-title">task{{ task.index }}:{{ task.title }}</span>
|
||||
<span class="todo-task-status">{{ formatTaskStatus(task) }}</span>
|
||||
</div>
|
||||
<div class="todo-instruction">{{ todoList.instruction }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="panelMode === 'subAgents' && !isSubAgentView">
|
||||
<div class="sub-agent-panel">
|
||||
<div v-if="!subAgents.length" class="sub-agent-empty">
|
||||
暂无运行中的子智能体
|
||||
</div>
|
||||
<div v-else class="sub-agent-cards">
|
||||
<div class="sub-agent-card"
|
||||
v-for="agent in subAgents"
|
||||
:key="agent.task_id"
|
||||
@click="openSubAgent(agent)">
|
||||
<div class="sub-agent-header">
|
||||
<span class="sub-agent-id">#{{
|
||||
agent.agent_id }}</span>
|
||||
<span class="sub-agent-status" :class="agent.status">{{ agent.status }}</span>
|
||||
</div>
|
||||
<div class="sub-agent-summary">{{ agent.summary }}</div>
|
||||
<div class="sub-agent-tool" v-if="agent.last_tool">
|
||||
当前:{{ agent.last_tool }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="file-tree" @contextmenu.prevent>
|
||||
<file-node
|
||||
v-for="node in fileTree"
|
||||
:key="node.path"
|
||||
:node="node"
|
||||
:level="0"
|
||||
:expanded-folders="expandedFolders"
|
||||
@toggle-folder="toggleFolder"
|
||||
@context-menu="showContextMenu"
|
||||
></file-node>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<!-- 左侧拖拽手柄 -->
|
||||
<div class="resize-handle" @mousedown="startResize('left', $event)"></div>
|
||||
|
||||
<!-- 中间聊天区域 -->
|
||||
<main class="chat-container">
|
||||
<!-- 当前对话信息栏 -->
|
||||
<div class="current-conversation-info" v-if="currentConversationTitle">
|
||||
<span class="conversation-title-display">{{ currentConversationTitle }}</span>
|
||||
<span class="conversation-stats">
|
||||
<span class="message-count">{{ messages.length }}条消息</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="messages-area" ref="messagesArea">
|
||||
<div v-for="(msg, index) in messages" :key="index" class="message-block">
|
||||
|
||||
<!-- 用户消息 -->
|
||||
<div v-if="msg.role === 'user'" class="user-message">
|
||||
<div class="message-header">👤 用户</div>
|
||||
<div class="message-text">{{ msg.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- AI消息 -->
|
||||
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
|
||||
<div class="message-header">🤖 AI Assistant</div>
|
||||
|
||||
<!-- 按顺序显示所有actions -->
|
||||
<div v-for="(action, actionIndex) in msg.actions"
|
||||
:key="action.id"
|
||||
class="action-item"
|
||||
:class="{
|
||||
'streaming-content': action.streaming,
|
||||
'completed-tool': action.type === 'tool' && !action.streaming,
|
||||
'immediate-show': action.streaming || action.type === 'text'
|
||||
}">
|
||||
|
||||
<!-- 思考块 -->
|
||||
<div v-if="action.type === 'thinking'"
|
||||
class="collapsible-block thinking-block"
|
||||
:class="{ expanded: expandedBlocks.has(`${index}-thinking-${actionIndex}`) }">
|
||||
<div class="collapsible-header" @click="toggleBlock(`${index}-thinking-${actionIndex}`)">
|
||||
<div class="arrow"></div>
|
||||
<div class="status-icon">
|
||||
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">🧠</span>
|
||||
</div>
|
||||
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
|
||||
</div>
|
||||
<div class="collapsible-content">
|
||||
<div class="content-inner thinking-content"
|
||||
:ref="`thinkingContent-${index}-thinking-${actionIndex}`"
|
||||
@scroll="handleThinkingScroll(`${index}-thinking-${actionIndex}`, $event)"
|
||||
style="max-height: 240px; overflow-y: auto;">
|
||||
{{ action.content }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="action.streaming" class="progress-indicator"></div>
|
||||
</div>
|
||||
|
||||
<!-- 文本输出块 -->
|
||||
<div v-else-if="action.type === 'text'" class="text-output">
|
||||
<div class="text-content"
|
||||
:class="{ 'streaming-text': action.streaming }">
|
||||
<!-- 流式:实时渲染markdown但不包装代码块 -->
|
||||
<div v-if="action.streaming" v-html="renderMarkdown(action.content, true)"></div>
|
||||
<!-- 完成:完整渲染包含代码块包装 -->
|
||||
<div v-else v-html="renderMarkdown(action.content, false)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统提示块 -->
|
||||
<div v-else-if="action.type === 'system'" class="system-action">
|
||||
<div class="system-action-content">
|
||||
{{ action.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 追加内容占位 -->
|
||||
<div v-else-if="action.type === 'append_payload'"
|
||||
class="append-placeholder"
|
||||
:class="{ 'append-error': action.append?.success === false }">
|
||||
<div class="append-placeholder-content">
|
||||
<template v-if="action.append?.success !== false">
|
||||
✏️ 已写入 {{ action.append?.path || '目标文件' }} 的追加内容(内容已保存至文件)
|
||||
</template>
|
||||
<template v-else>
|
||||
❌ 向 {{ action.append?.path || '目标文件' }} 写入失败,内容已截获供后续修复。
|
||||
</template>
|
||||
<div class="append-meta" v-if="action.append">
|
||||
<span v-if="action.append.lines !== null && action.append.lines !== undefined">
|
||||
· 行数 {{ action.append.lines }}
|
||||
</span>
|
||||
<span v-if="action.append.bytes !== null && action.append.bytes !== undefined">
|
||||
· 字节 {{ action.append.bytes }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="append-warning" v-if="action.append?.forced">
|
||||
⚠️ 未检测到结束标记,请根据提示继续补充。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改内容占位 -->
|
||||
<div v-else-if="action.type === 'modify_payload'" class="modify-placeholder">
|
||||
<div class="modify-placeholder-content">
|
||||
🛠️ 已对 {{ action.modify?.path || '目标文件' }} 执行补丁
|
||||
<div class="modify-meta" v-if="action.modify">
|
||||
<span v-if="action.modify.total !== null && action.modify.total !== undefined">
|
||||
· 共 {{ action.modify.total }} 处
|
||||
</span>
|
||||
<span v-if="action.modify.completed && action.modify.completed.length">
|
||||
· 已完成 {{ action.modify.completed.length }} 处
|
||||
</span>
|
||||
<span v-if="action.modify.failed && action.modify.failed.length">
|
||||
· 未完成 {{ action.modify.failed.length }} 处
|
||||
</span>
|
||||
</div>
|
||||
<div class="modify-warning" v-if="action.modify?.forced">
|
||||
⚠️ 未检测到结束标记,系统已在流结束时执行补丁。
|
||||
</div>
|
||||
<div class="modify-warning" v-if="action.modify?.failed && action.modify.failed.length">
|
||||
⚠️ 未完成的序号:{{ action.modify.failed.map(f => f.index || f).join('、') || action.modify.failed.join('、') }},请根据提示重新输出。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具块(修复版) -->
|
||||
<div v-else-if="action.type === 'tool'"
|
||||
class="collapsible-block tool-block"
|
||||
:class="{
|
||||
expanded: expandedBlocks.has(`${index}-tool-${actionIndex}`),
|
||||
processing: action.tool.status === 'preparing' || action.tool.status === 'running',
|
||||
completed: action.tool.status === 'completed'
|
||||
}">
|
||||
<div class="collapsible-header" @click="toggleBlock(`${index}-tool-${actionIndex}`)">
|
||||
<div class="arrow"></div>
|
||||
<div class="status-icon">
|
||||
<!-- 修复:传递完整的tool对象 -->
|
||||
<span class="tool-icon"
|
||||
:class="getToolAnimationClass(action.tool)">
|
||||
{{ getToolIcon(action.tool) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="status-text">
|
||||
{{ getToolStatusText(action.tool) }}
|
||||
</span>
|
||||
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span>
|
||||
</div>
|
||||
<div class="collapsible-content">
|
||||
<div class="content-inner">
|
||||
<!-- 根据工具类型显示不同内容 -->
|
||||
<div v-if="action.tool.name === 'web_search' && action.tool.result">
|
||||
<div class="search-meta">
|
||||
<div><strong>搜索内容:</strong>{{ action.tool.result.query || action.tool.arguments.query }}</div>
|
||||
<div><strong>主题:</strong>{{ formatSearchTopic(action.tool.result.filters || {}) }}</div>
|
||||
<div><strong>时间范围:</strong>{{ formatSearchTime(action.tool.result.filters || {}) }}</div>
|
||||
<div><strong>结果数量:</strong>{{ action.tool.result.total_results }}</div>
|
||||
</div>
|
||||
<div v-if="action.tool.result.results && action.tool.result.results.length" class="search-result-list">
|
||||
<div v-for="item in action.tool.result.results" :key="item.url || item.index" class="search-result-item">
|
||||
<div class="search-result-title">{{ item.title || '无标题' }}</div>
|
||||
<div class="search-result-url"><a v-if="item.url" :href="item.url" target="_blank">{{ item.url }}</a><span v-else>无可用链接</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="search-empty">未返回详细的搜索结果。</div>
|
||||
</div>
|
||||
<div v-else-if="action.tool.name === 'run_python' && action.tool.result">
|
||||
<div class="code-block">
|
||||
<div class="code-label">代码:</div>
|
||||
<pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments.code }}</code></pre>
|
||||
</div>
|
||||
<div v-if="action.tool.result.output" class="output-block">
|
||||
<div class="output-label">输出:</div>
|
||||
<pre>{{ action.tool.result.output }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<pre>{{ JSON.stringify(action.tool.result || action.tool.arguments, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 修复:只在running状态显示进度条 -->
|
||||
<!-- 只在流式消息期间且工具运行时显示进度条 -->
|
||||
<div v-if="streamingMessage && (action.tool.status === 'preparing' || action.tool.status === 'running')"
|
||||
class="progress-indicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统消息 -->
|
||||
<div v-else class="system-message">
|
||||
{{ msg.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scroll-lock-toggle" :class="{ locked: autoScrollEnabled && !userScrolling }">
|
||||
<button @click="toggleScrollLock" class="scroll-lock-btn">
|
||||
<svg v-if="autoScrollEnabled && !userScrolling" viewBox="0 0 24 24" aria-hidden="true" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 11h8a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" />
|
||||
<path d="M9 11V8a3 3 0 0 1 6 0v3" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v12" />
|
||||
<path d="M7 13l5 5 5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-area">
|
||||
<div class="input-wrapper">
|
||||
<textarea
|
||||
v-model="inputMessage"
|
||||
@keydown.enter.ctrl="sendMessage"
|
||||
placeholder="输入消息... (Ctrl+Enter 发送)"
|
||||
class="message-input"
|
||||
:disabled="!isConnected || streamingMessage"
|
||||
rows="3">
|
||||
</textarea>
|
||||
<div class="input-actions">
|
||||
<div class="upload-control" v-if="!isSubAgentView">
|
||||
<input type="file"
|
||||
ref="fileUploadInput"
|
||||
class="file-input-hidden"
|
||||
@change="handleFileSelected">
|
||||
<button type="button"
|
||||
class="btn upload-btn"
|
||||
@click="triggerFileUpload"
|
||||
:disabled="!isConnected || uploading">
|
||||
{{ uploading ? '上传中...' : '上传文件' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-dropdown" ref="toolDropdown" v-if="!isSubAgentView">
|
||||
<button type="button"
|
||||
class="btn tool-btn"
|
||||
@click="toggleToolMenu"
|
||||
:disabled="!isConnected || toolSettingsLoading">
|
||||
工具
|
||||
</button>
|
||||
<transition name="settings-menu">
|
||||
<div class="settings-menu tool-menu" v-if="toolMenuOpen">
|
||||
<div class="tool-menu-status" v-if="toolSettingsLoading">
|
||||
正在同步工具状态...
|
||||
</div>
|
||||
<div v-else-if="toolSettings.length === 0" class="tool-menu-empty">
|
||||
暂无可控工具
|
||||
</div>
|
||||
<div v-else class="tool-menu-list">
|
||||
<div v-for="category in toolSettings"
|
||||
:key="category.id"
|
||||
class="tool-category-item"
|
||||
:class="{ disabled: !category.enabled }">
|
||||
<span class="tool-category-label">
|
||||
<span class="tool-category-icon">{{ toolCategoryEmoji(category.id) }}</span>
|
||||
{{ category.label }}
|
||||
</span>
|
||||
<button type="button"
|
||||
class="menu-btn tool-category-toggle"
|
||||
@click="updateToolCategory(category.id, !category.enabled)"
|
||||
:disabled="streamingMessage || !isConnected || toolSettingsLoading">
|
||||
{{ category.enabled ? '禁用' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<button @click="handleSendOrStop"
|
||||
:disabled="!isConnected || (!inputMessage.trim() && !streamingMessage)"
|
||||
:class="['btn', streamingMessage ? 'stop-btn' : 'send-btn']">
|
||||
{{ streamingMessage ? '停止' : '发送' }}
|
||||
</button>
|
||||
<div class="settings-dropdown" ref="settingsDropdown" v-if="!isSubAgentView">
|
||||
<button type="button"
|
||||
class="btn settings-btn"
|
||||
@click="toggleSettings"
|
||||
:disabled="!isConnected">
|
||||
设置
|
||||
</button>
|
||||
<transition name="settings-menu">
|
||||
<div class="settings-menu" v-if="settingsOpen">
|
||||
<button type="button"
|
||||
class="menu-btn focus-entry"
|
||||
@click="toggleFocusPanel"
|
||||
:disabled="streamingMessage || !isConnected">
|
||||
{{ rightCollapsed ? '展开聚焦面板' : '折叠聚焦面板' }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="menu-btn compress-entry"
|
||||
@click="compressConversation"
|
||||
:disabled="compressing || streamingMessage || !isConnected">
|
||||
{{ compressing ? '压缩中...' : '压缩' }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="menu-btn clear-entry"
|
||||
@click="clearChat"
|
||||
:disabled="streamingMessage || !isConnected">
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧拖拽手柄 -->
|
||||
<div class="resize-handle" @mousedown="startResize('right', $event)"></div>
|
||||
|
||||
<!-- 右侧聚焦文件 -->
|
||||
<aside class="sidebar right-sidebar"
|
||||
:class="{ collapsed: rightCollapsed }"
|
||||
:style="{ width: rightCollapsed ? '0px' : rightWidth + 'px' }">
|
||||
<div class="sidebar-header">
|
||||
<h3>👁️ 聚焦文件 ({{ Object.keys(focusedFiles).length }}/3)</h3>
|
||||
</div>
|
||||
<div class="focused-files" v-if="!rightCollapsed">
|
||||
<div v-if="Object.keys(focusedFiles).length === 0" class="no-files">
|
||||
暂无聚焦文件
|
||||
</div>
|
||||
<div v-else class="file-tabs">
|
||||
<div v-for="(file, path) in focusedFiles" :key="path" class="file-tab">
|
||||
<div class="tab-header">
|
||||
<span class="file-name">{{ path.split('/').pop() }}</span>
|
||||
<span class="file-size">{{ (file.size / 1024).toFixed(1) }}KB</span>
|
||||
</div>
|
||||
<div class="file-content">
|
||||
<pre><code :class="getLanguageClass(path)">{{ file.content }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
<div class="context-menu"
|
||||
v-if="contextMenu.visible"
|
||||
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
|
||||
@click.stop>
|
||||
<button v-if="contextMenu.node && contextMenu.node.type === 'file'"
|
||||
@click.stop="downloadFile(contextMenu.node.path)">
|
||||
下载文件
|
||||
</button>
|
||||
<button v-if="contextMenu.node && contextMenu.node.type === 'folder'"
|
||||
:disabled="!contextMenu.node.path"
|
||||
@click.stop="downloadFolder(contextMenu.node.path)">
|
||||
下载压缩包
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// 全局复制代码块函数
|
||||
function copyCodeBlock(blockId) {
|
||||
const codeElement = document.querySelector(`[data-code-id="${blockId}"]`);
|
||||
if (!codeElement) return;
|
||||
|
||||
const button = document.querySelector(`[data-code="${blockId}"]`);
|
||||
|
||||
// 如果正在显示"已复制"状态,忽略点击
|
||||
if (button.classList.contains('copied')) return;
|
||||
|
||||
// 使用保存的原始代码内容
|
||||
const codeContent = codeElement.dataset.originalCode || codeElement.textContent;
|
||||
|
||||
// 首次点击时保存原始图标
|
||||
if (!button.dataset.originalIcon) {
|
||||
button.dataset.originalIcon = button.textContent;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(codeContent).then(() => {
|
||||
button.textContent = '✓';
|
||||
button.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
// 使用保存的原始图标恢复
|
||||
button.textContent = button.dataset.originalIcon || '📋';
|
||||
button.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('复制失败:', err);
|
||||
// 即使失败也要恢复状态
|
||||
button.textContent = button.dataset.originalIcon || '📋';
|
||||
button.classList.remove('copied');
|
||||
});
|
||||
}
|
||||
// 使用事件委托处理复制按钮点击
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('copy-code-btn')) {
|
||||
const blockId = e.target.getAttribute('data-code');
|
||||
if (blockId) copyCodeBlock(blockId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<!-- 加载应用脚本 -->
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,147 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - AI Agent</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f8f2ec;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.login-card {
|
||||
width: 360px;
|
||||
padding: 32px 28px;
|
||||
background: #fffefc;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 45px rgba(118, 103, 84, 0.18);
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #4b392c;
|
||||
}
|
||||
.form-group {
|
||||
text-align: left;
|
||||
margin-top: 18px;
|
||||
}
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
color: #6c5a4a;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"] {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.3);
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus,
|
||||
input[type="email"]:focus {
|
||||
outline: none;
|
||||
border-color: #d8894c;
|
||||
box-shadow: 0 0 0 3px rgba(216, 137, 76, 0.2);
|
||||
}
|
||||
button {
|
||||
margin-top: 22px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #d8894c;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
margin-top: 14px;
|
||||
color: #c0392b;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1em;
|
||||
}
|
||||
.link {
|
||||
margin-top: 18px;
|
||||
font-size: 0.85rem;
|
||||
color: #6c5a4a;
|
||||
}
|
||||
.link a {
|
||||
color: #d8894c;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h1>AI Agent 登录</h1>
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input type="email" id="email" autocomplete="email" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" autocomplete="current-password" />
|
||||
</div>
|
||||
<button id="login-btn">登录</button>
|
||||
<div class="error" id="error"></div>
|
||||
<div class="link">
|
||||
还没有账号?<a href="/register">点击注册</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const btn = document.getElementById('login-btn');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
if (!email || !password) {
|
||||
errorEl.textContent = '请输入邮箱和密码';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
const resp = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
errorEl.textContent = data.error || '登录失败';
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = '网络错误,请重试';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
btn.click();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,149 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>注册 - AI Agent</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f0eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.register-card {
|
||||
width: 380px;
|
||||
padding: 32px 30px;
|
||||
background: #fffefc;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 45px rgba(118, 103, 84, 0.18);
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
color: #4b392c;
|
||||
}
|
||||
.form-group {
|
||||
text-align: left;
|
||||
margin-top: 16px;
|
||||
}
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
color: #6c5a4a;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.3);
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
margin-top: 22px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #d8894c;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
margin-top: 14px;
|
||||
color: #c0392b;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1em;
|
||||
}
|
||||
.link {
|
||||
margin-top: 18px;
|
||||
font-size: 0.85rem;
|
||||
color: #6c5a4a;
|
||||
}
|
||||
.link a {
|
||||
color: #d8894c;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="register-card">
|
||||
<h1>创建账号</h1>
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" placeholder="仅限小写字母/数字/下划线" autocomplete="username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input type="email" id="email" autocomplete="email" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="invite">邀请码</label>
|
||||
<input type="text" id="invite" placeholder="必填" autocomplete="off" />
|
||||
</div>
|
||||
<button id="register-btn">注册</button>
|
||||
<div class="error" id="error"></div>
|
||||
<div class="link">
|
||||
已有账号?<a href="/login">返回登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const btn = document.getElementById('register-btn');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
async function register() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const invite = document.getElementById('invite').value.trim();
|
||||
|
||||
if (!username || !email || !password || !invite) {
|
||||
errorEl.textContent = '请完整填写所有字段';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
const resp = await fetch('/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, password, invite_code: invite })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
errorEl.textContent = data.error || '注册失败';
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = '网络错误,请稍后再试';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', register);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
register();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,803 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Terminal Monitor - 实时终端查看器</title>
|
||||
|
||||
<!-- xterm.js 终端模拟器 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 头部区域 */
|
||||
.header {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 15px 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(74, 222, 128, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0); }
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
background: #ef4444;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* 会话标签栏 */
|
||||
.session-tabs {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #007acc;
|
||||
border-color: #007acc;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 204, 0.3);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 主要内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 终端容器 */
|
||||
.terminal-wrapper {
|
||||
flex: 1;
|
||||
background: #1e1e1e;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #2d2d2d;
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.control-btn.close { background: #ff5f56; }
|
||||
.control-btn.minimize { background: #ffbd2e; }
|
||||
.control-btn.maximize { background: #27c93f; }
|
||||
|
||||
#terminal {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
padding-bottom: 30px; /* 增加底部内边距 */
|
||||
overflow-y: auto; /* 确保可以滚动 */
|
||||
}
|
||||
|
||||
/* 侧边栏信息 */
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 命令历史 */
|
||||
.command-history {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
padding: 5px;
|
||||
margin: 2px 0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #4ade80;
|
||||
border-left: 2px solid #4ade80;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* 底部状态栏 */
|
||||
.status-bar {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.status-left {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loader {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 提示信息 */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 头部 -->
|
||||
<div class="header">
|
||||
<h1>
|
||||
🖥️ AI Terminal Monitor
|
||||
<span class="status-indicator" id="connectionStatus"></span>
|
||||
<span style="font-size: 14px; font-weight: normal; margin-left: 10px;" id="connectionText">连接中...</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- 会话标签 -->
|
||||
<div class="session-tabs" id="sessionTabs">
|
||||
<div class="tab" onclick="createNewSession()">
|
||||
<span class="tab-icon">➕</span>
|
||||
<span>等待终端会话...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-content">
|
||||
<!-- 终端容器 -->
|
||||
<div class="terminal-wrapper">
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-title">
|
||||
<span id="terminalSession">无活动会话</span>
|
||||
<span id="terminalPath" style="color: #666;">-</span>
|
||||
</div>
|
||||
<div class="terminal-controls">
|
||||
<div class="control-btn close" title="关闭"></div>
|
||||
<div class="control-btn minimize" title="最小化"></div>
|
||||
<div class="control-btn maximize" title="最大化"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal"></div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar">
|
||||
<!-- 会话信息 -->
|
||||
<div class="info-section">
|
||||
<h3>📊 会话信息</h3>
|
||||
<div class="info-item">
|
||||
<span class="info-label">会话名称</span>
|
||||
<span class="info-value" id="sessionName">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">工作目录</span>
|
||||
<span class="info-value" id="workingDir">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Shell类型</span>
|
||||
<span class="info-value" id="shellType">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">运行时间</span>
|
||||
<span class="info-value" id="uptime">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">缓冲区</span>
|
||||
<span class="info-value" id="bufferSize">0 KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 命令历史 -->
|
||||
<div class="info-section">
|
||||
<h3>📝 最近命令</h3>
|
||||
<div class="command-history" id="commandHistory">
|
||||
<div style="color: #666; text-align: center;">暂无命令</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="info-section">
|
||||
<h3>📈 统计</h3>
|
||||
<div class="info-item">
|
||||
<span class="info-label">总命令数</span>
|
||||
<span class="info-value" id="commandCount">0</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">输出行数</span>
|
||||
<span class="info-value" id="outputLines">0</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">活动会话</span>
|
||||
<span class="info-value" id="activeCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<div class="status-bar">
|
||||
<div class="status-left">
|
||||
<div class="status-item">
|
||||
<span>⚡</span>
|
||||
<span id="latency">延迟: 0ms</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>📡</span>
|
||||
<span id="dataRate">0 KB/s</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>🕐</span>
|
||||
<span id="currentTime"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-right">
|
||||
<span style="opacity: 0.6;">AI Agent Terminal Monitor v1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示框 -->
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
|
||||
<!-- 引入依赖 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
|
||||
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
|
||||
|
||||
<script>
|
||||
// 全局变量
|
||||
let term = null;
|
||||
let fitAddon = null;
|
||||
let socket = null;
|
||||
let currentSession = null;
|
||||
let sessions = {};
|
||||
let commandHistory = [];
|
||||
let stats = {
|
||||
commandCount: 0,
|
||||
outputLines: 0,
|
||||
dataReceived: 0,
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
// 初始化终端
|
||||
function initTerminal() {
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#aeafad',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5'
|
||||
},
|
||||
scrollback: 10000,
|
||||
cols: 120, // 设置固定列数
|
||||
rows: 30, // 设置固定行数
|
||||
convertEol: true, // 转换行尾
|
||||
wordSeparator: ' ()[]{}\'"', // 单词分隔符
|
||||
rendererType: 'canvas', // 使用canvas渲染器
|
||||
allowTransparency: false
|
||||
});
|
||||
|
||||
// 添加插件
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// 打开终端
|
||||
term.open(document.getElementById('terminal'));
|
||||
|
||||
// 延迟执行fit以确保正确计算尺寸
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
console.log(`终端尺寸: ${term.cols}x${term.rows}`);
|
||||
}, 100);
|
||||
|
||||
// 显示欢迎信息
|
||||
term.writeln('\x1b[1;36m╔════════════════════════════════════════╗\x1b[0m');
|
||||
term.writeln('\x1b[1;36m║ 🤖 AI Terminal Monitor v1.0 ║\x1b[0m');
|
||||
term.writeln('\x1b[1;36m╚════════════════════════════════════════╝\x1b[0m');
|
||||
term.writeln('');
|
||||
term.writeln('\x1b[33m正在连接到AI Agent...\x1b[0m');
|
||||
term.writeln('');
|
||||
}
|
||||
|
||||
// 初始化WebSocket连接
|
||||
function initWebSocket() {
|
||||
socket = io();
|
||||
|
||||
// 连接成功
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket连接成功');
|
||||
updateConnectionStatus(true);
|
||||
term.writeln('\x1b[32m✓ 已连接到服务器\x1b[0m');
|
||||
|
||||
// 订阅所有终端事件
|
||||
socket.emit('terminal_subscribe', { all: true });
|
||||
console.log('已发送终端订阅请求');
|
||||
});
|
||||
|
||||
// 连接断开
|
||||
socket.on('disconnect', () => {
|
||||
console.log('WebSocket连接断开');
|
||||
updateConnectionStatus(false);
|
||||
term.writeln('\x1b[31m✗ 与服务器断开连接\x1b[0m');
|
||||
});
|
||||
|
||||
// 订阅成功确认
|
||||
socket.on('terminal_subscribed', (data) => {
|
||||
console.log('终端订阅成功:', data);
|
||||
term.writeln('\x1b[32m✓ 已订阅终端事件\x1b[0m');
|
||||
});
|
||||
|
||||
// 终端启动事件
|
||||
socket.on('terminal_started', (data) => {
|
||||
console.log('终端启动:', data);
|
||||
addSession(data.session, data);
|
||||
term.writeln(`\x1b[32m[终端启动]\x1b[0m ${data.session} - ${data.working_dir}`);
|
||||
});
|
||||
|
||||
// 终端列表更新
|
||||
socket.on('terminal_list_update', (data) => {
|
||||
console.log('终端列表更新:', data);
|
||||
if (data.terminals && data.terminals.length > 0) {
|
||||
// 清除所有会话并重新添加
|
||||
sessions = {};
|
||||
for (const terminal of data.terminals) {
|
||||
sessions[terminal.name] = {
|
||||
working_dir: terminal.working_dir,
|
||||
is_running: terminal.is_running,
|
||||
shell: 'bash'
|
||||
};
|
||||
}
|
||||
updateSessionTabs();
|
||||
if (data.active && !currentSession) {
|
||||
switchToSession(data.active);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 终端输出事件
|
||||
socket.on('terminal_output', (data) => {
|
||||
console.log('收到终端输出:', data.session, data.data.length + '字节');
|
||||
if (data.session === currentSession || !currentSession) {
|
||||
// 如果没有当前会话,自动切换到这个会话
|
||||
if (!currentSession && data.session) {
|
||||
if (!sessions[data.session]) {
|
||||
addSession(data.session, {
|
||||
session: data.session,
|
||||
working_dir: 'unknown'
|
||||
});
|
||||
}
|
||||
switchToSession(data.session);
|
||||
}
|
||||
// 直接写入原始输出
|
||||
term.write(data.data);
|
||||
stats.outputLines++;
|
||||
stats.dataReceived += data.data.length;
|
||||
updateStats();
|
||||
}
|
||||
});
|
||||
|
||||
// 终端输入事件(AI发送的命令)
|
||||
socket.on('terminal_input', (data) => {
|
||||
console.log('收到终端输入:', data.session, data.data);
|
||||
if (data.session === currentSession || !currentSession) {
|
||||
// 如果没有当前会话,自动切换到这个会话
|
||||
if (!currentSession && data.session) {
|
||||
if (!sessions[data.session]) {
|
||||
addSession(data.session, {
|
||||
session: data.session,
|
||||
working_dir: 'unknown'
|
||||
});
|
||||
}
|
||||
switchToSession(data.session);
|
||||
}
|
||||
// 用绿色显示输入的命令
|
||||
term.write(`\x1b[1;32m➜ ${data.data}\x1b[0m`);
|
||||
|
||||
// 添加到命令历史
|
||||
addCommandToHistory(data.data.trim());
|
||||
stats.commandCount++;
|
||||
updateStats();
|
||||
}
|
||||
});
|
||||
|
||||
// 终端关闭事件
|
||||
socket.on('terminal_closed', (data) => {
|
||||
console.log('终端关闭:', data);
|
||||
removeSession(data.session);
|
||||
term.writeln(`\x1b[31m[终端关闭]\x1b[0m ${data.session}`);
|
||||
});
|
||||
|
||||
// 终端重置事件
|
||||
socket.on('terminal_reset', (data) => {
|
||||
console.log('终端重置:', data);
|
||||
if (data.session) {
|
||||
if (!sessions[data.session]) {
|
||||
addSession(data.session, {
|
||||
session: data.session,
|
||||
working_dir: data.working_dir || 'unknown'
|
||||
});
|
||||
} else {
|
||||
sessions[data.session].working_dir = data.working_dir || sessions[data.session].working_dir;
|
||||
}
|
||||
switchToSession(data.session);
|
||||
}
|
||||
term.writeln(`\x1b[33m[终端已重置]\x1b[0m ${data.session || ''}`);
|
||||
resetCommandHistory();
|
||||
stats.commandCount = 0;
|
||||
stats.outputLines = 0;
|
||||
stats.dataReceived = 0;
|
||||
stats.startTime = Date.now();
|
||||
updateStats();
|
||||
});
|
||||
|
||||
// 终端切换事件
|
||||
socket.on('terminal_switched', (data) => {
|
||||
console.log('终端切换:', data);
|
||||
switchToSession(data.current);
|
||||
});
|
||||
|
||||
// 调试:监听所有事件
|
||||
const onevent = socket.onevent;
|
||||
socket.onevent = function (packet) {
|
||||
const args = packet.data || [];
|
||||
console.log('Socket事件:', args[0], args[1]);
|
||||
onevent.call(this, packet);
|
||||
};
|
||||
}
|
||||
|
||||
// 添加会话
|
||||
function addSession(name, info) {
|
||||
sessions[name] = info;
|
||||
updateSessionTabs();
|
||||
|
||||
// 如果是第一个会话,自动切换
|
||||
if (!currentSession) {
|
||||
switchToSession(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除会话
|
||||
function removeSession(name) {
|
||||
delete sessions[name];
|
||||
updateSessionTabs();
|
||||
|
||||
// 如果是当前会话,切换到其他会话
|
||||
if (currentSession === name) {
|
||||
const remaining = Object.keys(sessions);
|
||||
if (remaining.length > 0) {
|
||||
switchToSession(remaining[0]);
|
||||
} else {
|
||||
currentSession = null;
|
||||
updateSessionInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换会话
|
||||
function switchToSession(name) {
|
||||
currentSession = name;
|
||||
updateSessionTabs();
|
||||
updateSessionInfo();
|
||||
|
||||
// 清空终端并显示切换信息
|
||||
term.clear();
|
||||
term.writeln(`\x1b[36m[切换到终端: ${name}]\x1b[0m`);
|
||||
term.writeln('');
|
||||
|
||||
// 请求该会话的历史输出
|
||||
socket.emit('get_terminal_output', { session: name });
|
||||
}
|
||||
|
||||
// 更新会话标签
|
||||
function updateSessionTabs() {
|
||||
const container = document.getElementById('sessionTabs');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (Object.keys(sessions).length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="tab">
|
||||
<span class="tab-icon">⏳</span>
|
||||
<span>等待终端会话...</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, info] of Object.entries(sessions)) {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = `tab ${name === currentSession ? 'active' : ''}`;
|
||||
tab.innerHTML = `
|
||||
<span class="tab-icon">📟</span>
|
||||
<span>${name}</span>
|
||||
`;
|
||||
tab.onclick = () => switchToSession(name);
|
||||
container.appendChild(tab);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会话信息
|
||||
function updateSessionInfo() {
|
||||
if (!currentSession || !sessions[currentSession]) {
|
||||
document.getElementById('sessionName').textContent = '-';
|
||||
document.getElementById('workingDir').textContent = '-';
|
||||
document.getElementById('shellType').textContent = '-';
|
||||
document.getElementById('terminalSession').textContent = '无活动会话';
|
||||
document.getElementById('terminalPath').textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
const info = sessions[currentSession];
|
||||
document.getElementById('sessionName').textContent = currentSession;
|
||||
document.getElementById('workingDir').textContent = info.working_dir || '-';
|
||||
document.getElementById('shellType').textContent = info.shell || '-';
|
||||
document.getElementById('terminalSession').textContent = currentSession;
|
||||
document.getElementById('terminalPath').textContent = info.working_dir || '-';
|
||||
}
|
||||
|
||||
// 添加命令到历史
|
||||
function addCommandToHistory(command) {
|
||||
commandHistory.unshift(command);
|
||||
if (commandHistory.length > 20) {
|
||||
commandHistory.pop();
|
||||
}
|
||||
updateCommandHistory();
|
||||
}
|
||||
|
||||
function resetCommandHistory() {
|
||||
commandHistory = [];
|
||||
updateCommandHistory();
|
||||
}
|
||||
|
||||
// 更新命令历史显示
|
||||
function updateCommandHistory() {
|
||||
const container = document.getElementById('commandHistory');
|
||||
if (commandHistory.length === 0) {
|
||||
container.innerHTML = '<div style="color: #666; text-align: center;">暂无命令</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = commandHistory
|
||||
.map(cmd => `<div class="command-item">${cmd}</div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 更新统计信息
|
||||
function updateStats() {
|
||||
document.getElementById('commandCount').textContent = stats.commandCount;
|
||||
document.getElementById('outputLines').textContent = stats.outputLines;
|
||||
document.getElementById('activeCount').textContent = Object.keys(sessions).length;
|
||||
document.getElementById('bufferSize').textContent = (stats.dataReceived / 1024).toFixed(1) + ' KB';
|
||||
|
||||
// 更新运行时间
|
||||
const uptime = Math.floor((Date.now() - stats.startTime) / 1000);
|
||||
const hours = Math.floor(uptime / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
const seconds = uptime % 60;
|
||||
document.getElementById('uptime').textContent =
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 更新连接状态
|
||||
function updateConnectionStatus(connected) {
|
||||
const indicator = document.getElementById('connectionStatus');
|
||||
const text = document.getElementById('connectionText');
|
||||
|
||||
if (connected) {
|
||||
indicator.classList.remove('disconnected');
|
||||
text.textContent = '已连接';
|
||||
text.style.color = '#4ade80';
|
||||
} else {
|
||||
indicator.classList.add('disconnected');
|
||||
text.textContent = '未连接';
|
||||
text.style.color = '#ef4444';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前时间
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
document.getElementById('currentTime').textContent =
|
||||
now.toLocaleTimeString('zh-CN');
|
||||
}
|
||||
|
||||
// 窗口大小调整
|
||||
window.addEventListener('resize', () => {
|
||||
if (fitAddon) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTerminal();
|
||||
initWebSocket();
|
||||
|
||||
// 定时更新
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
setInterval(updateStats, 1000);
|
||||
updateCurrentTime();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
7
sub_agent/static/vendor/socket.io.min.js
generated
vendored
7
sub_agent/static/vendor/socket.io.min.js
generated
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -1,660 +0,0 @@
|
||||
# ========== api_client.py ==========
|
||||
# utils/api_client.py - DeepSeek API 客户端(支持Web模式)- 简化版
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import asyncio
|
||||
from typing import List, Dict, Optional, AsyncGenerator, Any
|
||||
try:
|
||||
from config import API_BASE_URL, API_KEY, MODEL_ID, OUTPUT_FORMATS, DEFAULT_RESPONSE_MAX_TOKENS
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import API_BASE_URL, API_KEY, MODEL_ID, OUTPUT_FORMATS, DEFAULT_RESPONSE_MAX_TOKENS
|
||||
|
||||
class DeepSeekClient:
|
||||
def __init__(self, thinking_mode: bool = True, web_mode: bool = False):
|
||||
self.api_base_url = API_BASE_URL
|
||||
self.api_key = API_KEY
|
||||
self.model_id = MODEL_ID
|
||||
self.thinking_mode = thinking_mode # True=智能思考模式, False=快速模式
|
||||
self.web_mode = web_mode # Web模式标志,用于禁用print输出
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# 每个任务的独立状态
|
||||
self.current_task_first_call = True # 当前任务是否是第一次调用
|
||||
self.current_task_thinking = "" # 当前任务的思考内容
|
||||
|
||||
def _print(self, message: str, end: str = "\n", flush: bool = False):
|
||||
"""安全的打印函数,在Web模式下不输出"""
|
||||
if not self.web_mode:
|
||||
print(message, end=end, flush=flush)
|
||||
|
||||
def _format_read_file_result(self, data: Dict) -> str:
|
||||
"""根据读取模式格式化 read_file 工具结果。"""
|
||||
if not isinstance(data, dict):
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
if not data.get("success"):
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
read_type = data.get("type", "read")
|
||||
truncated_note = "(内容已截断)" if data.get("truncated") else ""
|
||||
path = data.get("path", "未知路径")
|
||||
max_chars = data.get("max_chars")
|
||||
max_note = f"(max_chars={max_chars})" if max_chars else ""
|
||||
|
||||
if read_type == "read":
|
||||
line_start = data.get("line_start")
|
||||
line_end = data.get("line_end")
|
||||
char_count = data.get("char_count", len(data.get("content", "") or ""))
|
||||
header = f"读取 {path} 行 {line_start}~{line_end},返回 {char_count} 字符 {max_note}{truncated_note}".strip()
|
||||
content = data.get("content", "")
|
||||
return f"{header}\n```\n{content}\n```"
|
||||
|
||||
if read_type == "search":
|
||||
query = data.get("query", "")
|
||||
actual = data.get("actual_matches", 0)
|
||||
returned = data.get("returned_matches", 0)
|
||||
case_hint = "区分大小写" if data.get("case_sensitive") else "不区分大小写"
|
||||
header = (
|
||||
f"在 {path} 中搜索 \"{query}\",返回 {returned}/{actual} 条结果({case_hint})"
|
||||
f" {max_note}{truncated_note}"
|
||||
).strip()
|
||||
match_texts = []
|
||||
for idx, match in enumerate(data.get("matches", []), 1):
|
||||
match_note = "(片段截断)" if match.get("truncated") else ""
|
||||
hits = match.get("hits") or []
|
||||
hit_text = ", ".join(str(h) for h in hits) if hits else "无"
|
||||
label = match.get("id") or f"match_{idx}"
|
||||
snippet = match.get("snippet", "")
|
||||
match_texts.append(
|
||||
f"[{label}] 行 {match.get('line_start')}~{match.get('line_end')} 命中行: {hit_text}{match_note}\n```\n{snippet}\n```"
|
||||
)
|
||||
if not match_texts:
|
||||
match_texts.append("未找到匹配内容。")
|
||||
return "\n".join([header] + match_texts)
|
||||
|
||||
if read_type == "extract":
|
||||
segments = data.get("segments", [])
|
||||
header = (
|
||||
f"从 {path} 抽取 {len(segments)} 个片段 {max_note}{truncated_note}"
|
||||
).strip()
|
||||
seg_texts = []
|
||||
for idx, segment in enumerate(segments, 1):
|
||||
seg_note = "(片段截断)" if segment.get("truncated") else ""
|
||||
label = segment.get("label") or f"segment_{idx}"
|
||||
snippet = segment.get("content", "")
|
||||
seg_texts.append(
|
||||
f"[{label}] 行 {segment.get('line_start')}~{segment.get('line_end')}{seg_note}\n```\n{snippet}\n```"
|
||||
)
|
||||
if not seg_texts:
|
||||
seg_texts.append("未提供可抽取的片段。")
|
||||
return "\n".join([header] + seg_texts)
|
||||
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
def _extract_reasoning_delta(self, delta: Dict[str, Any]) -> str:
|
||||
"""统一提取思考内容,兼容 reasoning_content / reasoning_details。"""
|
||||
if not isinstance(delta, dict):
|
||||
return ""
|
||||
if "reasoning_content" in delta:
|
||||
return delta.get("reasoning_content") or ""
|
||||
details = delta.get("reasoning_details")
|
||||
if isinstance(details, list):
|
||||
parts: List[str] = []
|
||||
for item in details:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if text:
|
||||
parts.append(text)
|
||||
if parts:
|
||||
return "".join(parts)
|
||||
return ""
|
||||
|
||||
def _merge_system_messages(self, messages: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
将多个 system 消息合并为一个。
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
merged_contents: List[str] = []
|
||||
new_messages: List[Dict] = []
|
||||
first_system_index: Optional[int] = None
|
||||
for msg in messages:
|
||||
if msg.get("role") == "system":
|
||||
if first_system_index is None:
|
||||
first_system_index = len(new_messages)
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
merged_contents.append(content)
|
||||
else:
|
||||
merged_contents.append(json.dumps(content, ensure_ascii=False))
|
||||
else:
|
||||
new_messages.append(msg)
|
||||
if not merged_contents:
|
||||
return messages
|
||||
merged = {
|
||||
"role": "system",
|
||||
"content": "\n\n".join(c for c in merged_contents if c)
|
||||
}
|
||||
insert_at = first_system_index if first_system_index is not None else 0
|
||||
new_messages.insert(insert_at, merged)
|
||||
return new_messages
|
||||
|
||||
def start_new_task(self):
|
||||
"""开始新任务(重置任务级别的状态)"""
|
||||
self.current_task_first_call = True
|
||||
self.current_task_thinking = ""
|
||||
|
||||
def get_current_thinking_mode(self) -> bool:
|
||||
"""获取当前应该使用的思考模式"""
|
||||
if not self.thinking_mode:
|
||||
# 快速模式,始终不使用思考
|
||||
return False
|
||||
else:
|
||||
# 思考模式:当前任务的第一次用思考,后续不用
|
||||
return self.current_task_first_call
|
||||
|
||||
def _validate_json_string(self, json_str: str) -> tuple:
|
||||
"""
|
||||
验证JSON字符串的完整性
|
||||
|
||||
Returns:
|
||||
(is_valid: bool, error_message: str, parsed_data: dict or None)
|
||||
"""
|
||||
if not json_str or not json_str.strip():
|
||||
return True, "", {}
|
||||
|
||||
# 检查基本的JSON结构标记
|
||||
stripped = json_str.strip()
|
||||
if not stripped.startswith('{') or not stripped.endswith('}'):
|
||||
return False, "JSON字符串格式不完整(缺少开始或结束大括号)", None
|
||||
|
||||
# 检查引号配对
|
||||
in_string = False
|
||||
escape_next = False
|
||||
quote_count = 0
|
||||
|
||||
for char in stripped:
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
|
||||
if char == '\\':
|
||||
escape_next = True
|
||||
continue
|
||||
|
||||
if char == '"':
|
||||
quote_count += 1
|
||||
in_string = not in_string
|
||||
|
||||
if in_string:
|
||||
return False, "JSON字符串中存在未闭合的引号", None
|
||||
|
||||
# 尝试解析JSON
|
||||
try:
|
||||
parsed_data = json.loads(stripped)
|
||||
return True, "", parsed_data
|
||||
except json.JSONDecodeError as e:
|
||||
return False, f"JSON解析错误: {str(e)}", None
|
||||
|
||||
def _safe_tool_arguments_parse(self, arguments_str: str, tool_name: str) -> tuple:
|
||||
"""
|
||||
安全地解析工具参数,保持失败即时返回
|
||||
|
||||
Returns:
|
||||
(success: bool, arguments: dict, error_message: str)
|
||||
"""
|
||||
if not arguments_str or not arguments_str.strip():
|
||||
return True, {}, ""
|
||||
|
||||
# 长度检查
|
||||
max_length = 999999999 # 50KB限制
|
||||
if len(arguments_str) > max_length:
|
||||
return False, {}, f"参数过长({len(arguments_str)}字符),超过{max_length}字符限制"
|
||||
|
||||
# 尝试直接解析JSON
|
||||
try:
|
||||
parsed_data = json.loads(arguments_str)
|
||||
return True, parsed_data, ""
|
||||
except json.JSONDecodeError as e:
|
||||
preview_length = 200
|
||||
stripped = arguments_str.strip()
|
||||
preview = stripped[:preview_length] + "..." if len(stripped) > preview_length else stripped
|
||||
return False, {}, f"JSON解析失败: {str(e)}\n参数预览: {preview}"
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
stream: bool = True
|
||||
) -> AsyncGenerator[Dict, None]:
|
||||
"""
|
||||
异步调用DeepSeek API
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
tools: 工具定义列表
|
||||
stream: 是否流式输出
|
||||
|
||||
Yields:
|
||||
响应内容块
|
||||
"""
|
||||
# 检查API密钥
|
||||
if not self.api_key or self.api_key == "your-deepseek-api-key":
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API密钥未配置,请在config.py中设置API_KEY")
|
||||
return
|
||||
|
||||
# 决定是否使用思考模式
|
||||
current_thinking_mode = self.get_current_thinking_mode()
|
||||
|
||||
# 如果是思考模式且不是当前任务的第一次,显示提示
|
||||
if self.thinking_mode and not self.current_task_first_call:
|
||||
self._print(f"{OUTPUT_FORMATS['info']} [任务内快速模式] 使用本次任务的思考继续处理...")
|
||||
|
||||
try:
|
||||
max_tokens = int(DEFAULT_RESPONSE_MAX_TOKENS)
|
||||
if max_tokens <= 0:
|
||||
raise ValueError("max_tokens must be positive")
|
||||
except (TypeError, ValueError):
|
||||
max_tokens = 4096
|
||||
|
||||
final_messages = self._merge_system_messages(messages)
|
||||
|
||||
payload = {
|
||||
"model": self.model_id,
|
||||
"messages": final_messages,
|
||||
"stream": stream,
|
||||
"thinking": {"type": "enabled" if current_thinking_mode else "disabled"},
|
||||
"max_tokens": max_tokens
|
||||
}
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(http2=True, timeout=300) as client:
|
||||
if stream:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{self.api_base_url}/chat/completions",
|
||||
json=payload,
|
||||
headers=self.headers
|
||||
) as response:
|
||||
# 检查响应状态
|
||||
if response.status_code != 200:
|
||||
error_text = await response.aread()
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API请求失败 ({response.status_code}): {error_text}")
|
||||
return
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("data:"):
|
||||
json_str = line[5:].strip()
|
||||
if json_str == "[DONE]":
|
||||
break
|
||||
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
yield data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
else:
|
||||
response = await client.post(
|
||||
f"{self.api_base_url}/chat/completions",
|
||||
json=payload,
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code != 200:
|
||||
error_text = response.text
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API请求失败 ({response.status_code}): {error_text}")
|
||||
return
|
||||
yield response.json()
|
||||
|
||||
except httpx.ConnectError:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} 无法连接到API服务器,请检查网络连接")
|
||||
except httpx.TimeoutException:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API请求超时")
|
||||
except Exception as e:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API调用异常: {e}")
|
||||
|
||||
async def chat_with_tools(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
tools: List[Dict],
|
||||
tool_handler: callable
|
||||
) -> str:
|
||||
"""
|
||||
带工具调用的对话(支持多轮)
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
tools: 工具定义
|
||||
tool_handler: 工具处理函数
|
||||
|
||||
Returns:
|
||||
最终回答
|
||||
"""
|
||||
final_response = ""
|
||||
max_iterations = 200 # 最大迭代次数
|
||||
iteration = 0
|
||||
all_tool_results = [] # 记录所有工具调用结果
|
||||
|
||||
# 如果是思考模式且不是当前任务的第一次调用,注入本次任务的思考
|
||||
# 注意:这里重置的是当前任务的第一次调用标志,确保新用户请求重新思考
|
||||
# 只有在同一个任务的多轮迭代中才应该注入
|
||||
# 对于新的用户请求,应该重新开始思考,而不是使用之前的思考内容
|
||||
# 只有在当前任务有思考内容且不是第一次调用时才注入
|
||||
if (self.thinking_mode and
|
||||
not self.current_task_first_call and
|
||||
self.current_task_thinking and
|
||||
iteration == 0): # 只在第一次迭代时注入,避免多次注入
|
||||
# 在messages末尾添加一个系统消息,包含本次任务的思考
|
||||
thinking_context = f"\n=== 📋 本次任务的思考 ===\n{self.current_task_thinking}\n=== 思考结束 ===\n提示:这是本次任务的初始思考,你可以基于此继续处理。"
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": thinking_context
|
||||
})
|
||||
|
||||
while iteration < max_iterations:
|
||||
iteration += 1
|
||||
|
||||
# 调用API(始终提供工具定义)
|
||||
full_response = ""
|
||||
tool_calls = []
|
||||
current_thinking = ""
|
||||
|
||||
# 状态标志
|
||||
in_thinking = False
|
||||
thinking_printed = False
|
||||
|
||||
# 获取当前是否应该显示思考
|
||||
should_show_thinking = self.get_current_thinking_mode()
|
||||
|
||||
async for chunk in self.chat(messages, tools, stream=True):
|
||||
if "choices" not in chunk:
|
||||
continue
|
||||
|
||||
delta = chunk["choices"][0].get("delta", {})
|
||||
|
||||
# 处理思考内容(只在思考模式开启时)
|
||||
if should_show_thinking:
|
||||
reasoning_content = self._extract_reasoning_delta(delta)
|
||||
if reasoning_content:
|
||||
if not in_thinking:
|
||||
self._print("💭 [正在思考]\n", end="", flush=True)
|
||||
in_thinking = True
|
||||
thinking_printed = True
|
||||
current_thinking += reasoning_content
|
||||
self._print(reasoning_content, end="", flush=True)
|
||||
|
||||
# 处理正常内容 - 独立的if,不是elif
|
||||
if "content" in delta:
|
||||
content = delta["content"]
|
||||
if content: # 只处理非空内容
|
||||
# 如果之前在输出思考,先结束思考输出
|
||||
if in_thinking:
|
||||
self._print("\n\n💭 [思考结束]\n\n", end="", flush=True)
|
||||
in_thinking = False
|
||||
full_response += content
|
||||
self._print(content, end="", flush=True)
|
||||
|
||||
# 收集工具调用 - 改进的拼接逻辑
|
||||
# 收集工具调用 - 修复JSON分片问题
|
||||
if "tool_calls" in delta:
|
||||
for tool_call in delta["tool_calls"]:
|
||||
tool_index = tool_call.get("index", 0)
|
||||
|
||||
# 查找或创建对应索引的工具调用
|
||||
existing_call = None
|
||||
for existing in tool_calls:
|
||||
if existing.get("index") == tool_index:
|
||||
existing_call = existing
|
||||
break
|
||||
|
||||
if not existing_call and tool_call.get("id"):
|
||||
# 创建新的工具调用
|
||||
new_call = {
|
||||
"id": tool_call.get("id"),
|
||||
"index": tool_index,
|
||||
"type": tool_call.get("type", "function"),
|
||||
"function": {
|
||||
"name": tool_call.get("function", {}).get("name", ""),
|
||||
"arguments": ""
|
||||
}
|
||||
}
|
||||
tool_calls.append(new_call)
|
||||
existing_call = new_call
|
||||
|
||||
# 安全地拼接arguments - 简单字符串拼接,不尝试JSON验证
|
||||
if existing_call and "function" in tool_call and "arguments" in tool_call["function"]:
|
||||
new_args = tool_call["function"]["arguments"]
|
||||
if new_args: # 只拼接非空内容
|
||||
existing_call["function"]["arguments"] += new_args
|
||||
|
||||
self._print() # 最终换行
|
||||
|
||||
# 如果思考还没结束(只调用工具没有文本),手动结束
|
||||
if in_thinking:
|
||||
self._print("\n💭 [思考结束]\n")
|
||||
|
||||
# 在思考模式下,如果是当前任务的第一次调用且有思考内容,保存它
|
||||
if self.thinking_mode and self.current_task_first_call and current_thinking:
|
||||
self.current_task_thinking = current_thinking
|
||||
self.current_task_first_call = False # 标记当前任务的第一次调用已完成
|
||||
|
||||
# 如果没有工具调用,说明完成了
|
||||
if not tool_calls:
|
||||
if full_response: # 有正常回复,任务完成
|
||||
final_response = full_response
|
||||
break
|
||||
elif iteration == 1: # 第一次就没有工具调用也没有内容,可能有问题
|
||||
self._print(f"{OUTPUT_FORMATS['warning']} 模型未返回内容")
|
||||
break
|
||||
|
||||
# 构建助手消息 - 始终包含所有收集到的内容
|
||||
assistant_content_parts = []
|
||||
|
||||
# 添加正式回复内容(如果有)
|
||||
if full_response:
|
||||
assistant_content_parts.append(full_response)
|
||||
|
||||
# 添加工具调用说明
|
||||
if tool_calls:
|
||||
tool_names = [tc['function']['name'] for tc in tool_calls]
|
||||
assistant_content_parts.append(f"执行工具: {', '.join(tool_names)}")
|
||||
|
||||
# 合并所有内容
|
||||
assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else "执行工具调用"
|
||||
|
||||
assistant_message = {
|
||||
"role": "assistant",
|
||||
"content": assistant_content,
|
||||
"tool_calls": tool_calls
|
||||
}
|
||||
|
||||
messages.append(assistant_message)
|
||||
|
||||
# 执行所有工具调用 - 使用鲁棒的参数解析
|
||||
for tool_call in tool_calls:
|
||||
function_name = tool_call["function"]["name"]
|
||||
arguments_str = tool_call["function"]["arguments"]
|
||||
|
||||
# 使用改进的参数解析方法,增强JSON修复能力
|
||||
success, arguments, error_msg = self._safe_tool_arguments_parse(arguments_str, function_name)
|
||||
|
||||
if not success:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} 工具参数解析失败: {error_msg}")
|
||||
self._print(f" 工具名称: {function_name}")
|
||||
self._print(f" 参数长度: {len(arguments_str)} 字符")
|
||||
|
||||
# 返回详细的错误信息给模型
|
||||
error_response = {
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
"tool_name": function_name,
|
||||
"arguments_length": len(arguments_str),
|
||||
"suggestion": "请检查参数格式或减少参数长度后重试"
|
||||
}
|
||||
|
||||
# 如果参数过长,提供分块建议
|
||||
if len(arguments_str) > 10000:
|
||||
error_response["suggestion"] = "参数过长,建议分块处理或使用更简洁的内容"
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"name": function_name,
|
||||
"content": json.dumps(error_response, ensure_ascii=False)
|
||||
})
|
||||
|
||||
# 记录失败的调用,防止死循环检测失效
|
||||
all_tool_results.append({
|
||||
"tool": function_name,
|
||||
"args": {"parse_error": error_msg, "length": len(arguments_str)},
|
||||
"result": f"参数解析失败: {error_msg}"
|
||||
})
|
||||
continue
|
||||
|
||||
self._print(f"\n{OUTPUT_FORMATS['action']} 调用工具: {function_name}")
|
||||
|
||||
# 额外的参数长度检查(针对特定工具)
|
||||
if function_name == "modify_file" and "content" in arguments:
|
||||
content_length = len(arguments.get("content", ""))
|
||||
if content_length > 9999999999: # 降低到50KB限制
|
||||
error_msg = f"内容过长({content_length}字符),超过50KB限制"
|
||||
self._print(f"{OUTPUT_FORMATS['warning']} {error_msg}")
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"name": function_name,
|
||||
"content": json.dumps({
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
"suggestion": "请将内容分成多个小块分别修改,或使用replace操作只修改必要部分"
|
||||
}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
all_tool_results.append({
|
||||
"tool": function_name,
|
||||
"args": arguments,
|
||||
"result": error_msg
|
||||
})
|
||||
continue
|
||||
|
||||
tool_result = await tool_handler(function_name, arguments)
|
||||
|
||||
# 解析工具结果,提取关键信息
|
||||
try:
|
||||
result_data = json.loads(tool_result)
|
||||
if function_name == "read_file":
|
||||
tool_result_msg = self._format_read_file_result(result_data)
|
||||
else:
|
||||
tool_result_msg = tool_result
|
||||
except:
|
||||
tool_result_msg = tool_result
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"name": function_name,
|
||||
"content": tool_result_msg
|
||||
})
|
||||
|
||||
# 记录工具结果
|
||||
all_tool_results.append({
|
||||
"tool": function_name,
|
||||
"args": arguments,
|
||||
"result": tool_result_msg
|
||||
})
|
||||
|
||||
# 如果连续多次调用同样的工具,可能陷入循环
|
||||
if len(all_tool_results) >= 8:
|
||||
recent_tools = [r["tool"] for r in all_tool_results[-8:]]
|
||||
if len(set(recent_tools)) == 1: # 最近8次都是同一个工具
|
||||
self._print(f"\n{OUTPUT_FORMATS['warning']} 检测到重复操作,停止执行")
|
||||
break
|
||||
|
||||
if iteration >= max_iterations:
|
||||
self._print(f"\n{OUTPUT_FORMATS['warning']} 达到最大迭代次数限制")
|
||||
|
||||
return final_response
|
||||
|
||||
async def simple_chat(self, messages: List[Dict]) -> tuple:
|
||||
"""
|
||||
简单对话(无工具调用)
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
|
||||
Returns:
|
||||
(模型回答, 思考内容)
|
||||
"""
|
||||
full_response = ""
|
||||
thinking_content = ""
|
||||
in_thinking = False
|
||||
|
||||
# 获取当前是否应该显示思考
|
||||
should_show_thinking = self.get_current_thinking_mode()
|
||||
|
||||
# 如果是思考模式且不是当前任务的第一次调用,注入本次任务的思考
|
||||
if self.thinking_mode and not self.current_task_first_call and self.current_task_thinking:
|
||||
thinking_context = f"\n=== 📋 本次任务的思考 ===\n{self.current_task_thinking}\n=== 思考结束 ===\n"
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": thinking_context
|
||||
})
|
||||
|
||||
try:
|
||||
async for chunk in self.chat(messages, tools=None, stream=True):
|
||||
if "choices" not in chunk:
|
||||
continue
|
||||
|
||||
delta = chunk["choices"][0].get("delta", {})
|
||||
|
||||
# 处理思考内容
|
||||
if should_show_thinking:
|
||||
reasoning_content = self._extract_reasoning_delta(delta)
|
||||
if reasoning_content:
|
||||
if not in_thinking:
|
||||
self._print("💭 [正在思考]\n", end="", flush=True)
|
||||
in_thinking = True
|
||||
thinking_content += reasoning_content
|
||||
self._print(reasoning_content, end="", flush=True)
|
||||
|
||||
# 处理正常内容 - 独立的if而不是elif
|
||||
if "content" in delta:
|
||||
content = delta["content"]
|
||||
if content: # 只处理非空内容
|
||||
if in_thinking:
|
||||
self._print("\n\n💭 [思考结束]\n\n", end="", flush=True)
|
||||
in_thinking = False
|
||||
full_response += content
|
||||
self._print(content, end="", flush=True)
|
||||
|
||||
self._print() # 最终换行
|
||||
|
||||
# 如果思考还没结束(极少情况),手动结束
|
||||
if in_thinking:
|
||||
self._print("\n💭 [思考结束]\n")
|
||||
|
||||
# 在思考模式下,如果是当前任务的第一次调用且有思考内容,保存它
|
||||
if self.thinking_mode and self.current_task_first_call and thinking_content:
|
||||
self.current_task_thinking = thinking_content
|
||||
self.current_task_first_call = False
|
||||
|
||||
# 如果没有收到任何响应
|
||||
if not full_response and not thinking_content:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API未返回任何内容,请检查API密钥和模型ID")
|
||||
return "", ""
|
||||
|
||||
except Exception as e:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API调用失败: {e}")
|
||||
return "", ""
|
||||
|
||||
return full_response, thinking_content
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,880 +0,0 @@
|
||||
# utils/conversation_manager.py - 对话持久化管理器(集成Token统计)
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
try:
|
||||
from config import DATA_DIR
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import DATA_DIR
|
||||
import tiktoken
|
||||
|
||||
@dataclass
|
||||
class ConversationMetadata:
|
||||
"""对话元数据"""
|
||||
id: str
|
||||
title: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
project_path: Optional[str]
|
||||
project_relative_path: Optional[str]
|
||||
thinking_mode: bool
|
||||
total_messages: int
|
||||
total_tools: int
|
||||
run_mode: str = "fast"
|
||||
status: str = "active" # active, archived, error
|
||||
|
||||
class ConversationManager:
|
||||
"""对话持久化管理器"""
|
||||
|
||||
def __init__(self, base_dir: Optional[str] = None):
|
||||
self.base_dir = Path(base_dir).expanduser().resolve() if base_dir else Path(DATA_DIR).resolve()
|
||||
self.conversations_dir = self.base_dir / "conversations"
|
||||
self.index_file = self.conversations_dir / "index.json"
|
||||
self.current_conversation_id: Optional[str] = None
|
||||
self.workspace_root = Path(__file__).resolve().parents[1]
|
||||
self._ensure_directories()
|
||||
self._index_verified = False
|
||||
self._load_index(ensure_integrity=True)
|
||||
|
||||
# 初始化tiktoken编码器
|
||||
try:
|
||||
self.encoding = tiktoken.get_encoding("cl100k_base")
|
||||
except Exception as e:
|
||||
print(f"⚠️ tiktoken初始化失败: {e}")
|
||||
self.encoding = None
|
||||
|
||||
def _ensure_directories(self):
|
||||
"""确保必要的目录存在"""
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.conversations_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 如果索引文件不存在,创建空索引
|
||||
if not self.index_file.exists():
|
||||
self._save_index({})
|
||||
|
||||
def _iter_conversation_files(self):
|
||||
"""遍历对话文件(排除索引文件)"""
|
||||
for path in self.conversations_dir.glob("*.json"):
|
||||
if path == self.index_file:
|
||||
continue
|
||||
yield path
|
||||
|
||||
def _rebuild_index_from_files(self) -> Dict:
|
||||
"""从现有对话文件重建索引"""
|
||||
rebuilt_index: Dict[str, Dict] = {}
|
||||
for file_path in self._iter_conversation_files():
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
raw = f.read().strip()
|
||||
if not raw:
|
||||
continue
|
||||
data = json.loads(raw)
|
||||
except Exception as exc:
|
||||
print(f"⚠️ 重建索引时跳过 {file_path.name}: {exc}")
|
||||
continue
|
||||
|
||||
conv_id = data.get("id") or file_path.stem
|
||||
metadata = data.get("metadata", {}) or {}
|
||||
|
||||
rebuilt_index[conv_id] = {
|
||||
"title": data.get("title") or "未命名对话",
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"project_relative_path": metadata.get("project_relative_path"),
|
||||
"thinking_mode": metadata.get("thinking_mode", False),
|
||||
"total_messages": metadata.get("total_messages", 0),
|
||||
"total_tools": metadata.get("total_tools", 0),
|
||||
"status": metadata.get("status", "active"),
|
||||
}
|
||||
if rebuilt_index:
|
||||
print(f"🔄 已从对话文件重建索引,共 {len(rebuilt_index)} 条记录")
|
||||
return rebuilt_index
|
||||
|
||||
def _index_missing_conversations(self, index: Dict) -> bool:
|
||||
"""检测本地对话文件是否未出现在索引里"""
|
||||
index_ids = set(index.keys())
|
||||
for file_path in self._iter_conversation_files():
|
||||
conv_id = file_path.stem
|
||||
if conv_id and conv_id not in index_ids:
|
||||
print(f"🔍 对话 {conv_id} 未包含在索引中,将执行重建。")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load_index(self, ensure_integrity: bool = False) -> Dict:
|
||||
"""加载对话索引,可选地校验并重建"""
|
||||
try:
|
||||
index: Dict = {}
|
||||
if self.index_file.exists():
|
||||
with open(self.index_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
index = json.loads(content)
|
||||
if index:
|
||||
if ensure_integrity and not self._index_verified:
|
||||
if self._index_missing_conversations(index):
|
||||
rebuilt = self._rebuild_index_from_files()
|
||||
if rebuilt:
|
||||
self._save_index(rebuilt)
|
||||
index = rebuilt
|
||||
self._index_verified = True
|
||||
return index
|
||||
# 索引为空但对话文件仍然存在时尝试重建
|
||||
rebuilt = self._rebuild_index_from_files()
|
||||
if rebuilt:
|
||||
self._save_index(rebuilt)
|
||||
if ensure_integrity:
|
||||
self._index_verified = True
|
||||
return rebuilt
|
||||
return {}
|
||||
# 索引缺失但存在对话文件时重建
|
||||
rebuilt = self._rebuild_index_from_files()
|
||||
if rebuilt:
|
||||
self._save_index(rebuilt)
|
||||
if ensure_integrity:
|
||||
self._index_verified = True
|
||||
return rebuilt
|
||||
return index
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
print(f"⚠️ 加载对话索引失败,将尝试重建: {e}")
|
||||
backup_path = self.index_file.with_name(
|
||||
f"{self.index_file.stem}_corrupt_{int(time.time())}{self.index_file.suffix}"
|
||||
)
|
||||
try:
|
||||
if self.index_file.exists():
|
||||
self.index_file.replace(backup_path)
|
||||
print(f"🗄️ 已备份损坏的索引文件到: {backup_path.name}")
|
||||
except Exception as backup_exc:
|
||||
print(f"⚠️ 备份损坏索引文件失败: {backup_exc}")
|
||||
rebuilt = self._rebuild_index_from_files()
|
||||
if rebuilt:
|
||||
self._save_index(rebuilt)
|
||||
if ensure_integrity:
|
||||
self._index_verified = True
|
||||
return rebuilt
|
||||
return {}
|
||||
|
||||
def _save_index(self, index: Dict):
|
||||
"""保存对话索引"""
|
||||
temp_file = self.index_file.with_suffix(self.index_file.suffix + ".tmp")
|
||||
try:
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(index, f, ensure_ascii=False, indent=2)
|
||||
os.replace(temp_file, self.index_file)
|
||||
except Exception as e:
|
||||
try:
|
||||
if temp_file.exists():
|
||||
temp_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
print(f"⌘ 保存对话索引失败: {e}")
|
||||
|
||||
def _generate_conversation_id(self) -> str:
|
||||
"""生成唯一的对话ID"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
# 添加毫秒确保唯一性
|
||||
ms = int(time.time() * 1000) % 1000
|
||||
return f"conv_{timestamp}_{ms:03d}"
|
||||
|
||||
def _get_conversation_file_path(self, conversation_id: str) -> Path:
|
||||
"""获取对话文件路径"""
|
||||
return self.conversations_dir / f"{conversation_id}.json"
|
||||
|
||||
def _extract_title_from_messages(self, messages: List[Dict]) -> str:
|
||||
"""从消息中提取标题"""
|
||||
# 找到第一个用户消息作为标题
|
||||
for msg in messages:
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "").strip()
|
||||
if content:
|
||||
# 取前50个字符作为标题
|
||||
title = content[:50]
|
||||
if len(content) > 50:
|
||||
title += "..."
|
||||
return title
|
||||
return "新对话"
|
||||
|
||||
def _count_tools_in_messages(self, messages: List[Dict]) -> int:
|
||||
"""统计消息中的工具调用数量"""
|
||||
tool_count = 0
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant" and "tool_calls" in msg:
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
tool_count += len(tool_calls) if isinstance(tool_calls, list) else 0
|
||||
elif msg.get("role") == "tool":
|
||||
tool_count += 1
|
||||
return tool_count
|
||||
|
||||
def _prepare_project_path_metadata(self, project_path: Optional[str]) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
将项目路径规范化为绝对/相对形式,便于在不同机器间迁移
|
||||
"""
|
||||
normalized = {
|
||||
"project_path": None,
|
||||
"project_relative_path": None
|
||||
}
|
||||
|
||||
if not project_path:
|
||||
return normalized
|
||||
|
||||
try:
|
||||
absolute_path = Path(project_path).expanduser().resolve()
|
||||
normalized["project_path"] = str(absolute_path)
|
||||
|
||||
try:
|
||||
relative_path = absolute_path.relative_to(self.workspace_root)
|
||||
normalized["project_relative_path"] = relative_path.as_posix()
|
||||
except ValueError:
|
||||
normalized["project_relative_path"] = None
|
||||
except Exception:
|
||||
# 回退为原始字符串,至少不会阻止对话保存
|
||||
normalized["project_path"] = str(project_path)
|
||||
normalized["project_relative_path"] = None
|
||||
|
||||
return normalized
|
||||
|
||||
def _initialize_token_statistics(self) -> Dict:
|
||||
"""初始化Token统计结构"""
|
||||
return {
|
||||
"total_input_tokens": 0,
|
||||
"total_output_tokens": 0,
|
||||
"updated_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def _validate_token_statistics(self, data: Dict) -> Dict:
|
||||
"""验证并修复Token统计数据"""
|
||||
token_stats = data.get("token_statistics", {})
|
||||
|
||||
# 确保必要字段存在
|
||||
if "total_input_tokens" not in token_stats:
|
||||
token_stats["total_input_tokens"] = 0
|
||||
if "total_output_tokens" not in token_stats:
|
||||
token_stats["total_output_tokens"] = 0
|
||||
if "updated_at" not in token_stats:
|
||||
token_stats["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 确保数值类型正确
|
||||
try:
|
||||
token_stats["total_input_tokens"] = int(token_stats["total_input_tokens"])
|
||||
token_stats["total_output_tokens"] = int(token_stats["total_output_tokens"])
|
||||
except (ValueError, TypeError):
|
||||
print("⚠️ Token统计数据损坏,重置为0")
|
||||
token_stats["total_input_tokens"] = 0
|
||||
token_stats["total_output_tokens"] = 0
|
||||
|
||||
data["token_statistics"] = token_stats
|
||||
return data
|
||||
|
||||
def create_conversation(
|
||||
self,
|
||||
project_path: str,
|
||||
thinking_mode: bool = False,
|
||||
initial_messages: List[Dict] = None
|
||||
) -> str:
|
||||
"""
|
||||
创建新对话
|
||||
|
||||
Args:
|
||||
project_path: 项目路径
|
||||
thinking_mode: 思考模式
|
||||
initial_messages: 初始消息列表
|
||||
|
||||
Returns:
|
||||
conversation_id: 对话ID
|
||||
"""
|
||||
conversation_id = self._generate_conversation_id()
|
||||
messages = initial_messages or []
|
||||
|
||||
# 创建对话数据
|
||||
path_metadata = self._prepare_project_path_metadata(project_path)
|
||||
conversation_data = {
|
||||
"id": conversation_id,
|
||||
"title": self._extract_title_from_messages(messages),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"messages": messages,
|
||||
"todo_list": None,
|
||||
"metadata": {
|
||||
"project_path": path_metadata["project_path"],
|
||||
"project_relative_path": path_metadata["project_relative_path"],
|
||||
"thinking_mode": thinking_mode,
|
||||
"total_messages": len(messages),
|
||||
"total_tools": self._count_tools_in_messages(messages),
|
||||
"status": "active"
|
||||
},
|
||||
"token_statistics": self._initialize_token_statistics() # 新增
|
||||
}
|
||||
|
||||
# 保存对话文件
|
||||
self._save_conversation_file(conversation_id, conversation_data)
|
||||
|
||||
# 更新索引
|
||||
self._update_index(conversation_id, conversation_data)
|
||||
|
||||
self.current_conversation_id = conversation_id
|
||||
print(f"📝 创建新对话: {conversation_id} - {conversation_data['title']}")
|
||||
|
||||
return conversation_id
|
||||
|
||||
def _save_conversation_file(self, conversation_id: str, data: Dict):
|
||||
"""保存对话文件"""
|
||||
file_path = self._get_conversation_file_path(conversation_id)
|
||||
temp_file = file_path.with_suffix(file_path.suffix + ".tmp")
|
||||
try:
|
||||
# 确保Token统计数据有效
|
||||
data = self._validate_token_statistics(data)
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(temp_file, file_path)
|
||||
except Exception as e:
|
||||
try:
|
||||
if temp_file.exists():
|
||||
temp_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
print(f"⌘ 保存对话文件失败 {conversation_id}: {e}")
|
||||
|
||||
def _update_index(self, conversation_id: str, conversation_data: Dict):
|
||||
"""更新对话索引"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
|
||||
# 创建元数据
|
||||
metadata = ConversationMetadata(
|
||||
id=conversation_id,
|
||||
title=conversation_data["title"],
|
||||
created_at=conversation_data["created_at"],
|
||||
updated_at=conversation_data["updated_at"],
|
||||
project_path=conversation_data["metadata"]["project_path"],
|
||||
project_relative_path=conversation_data["metadata"].get("project_relative_path"),
|
||||
thinking_mode=conversation_data["metadata"]["thinking_mode"],
|
||||
total_messages=conversation_data["metadata"]["total_messages"],
|
||||
total_tools=conversation_data["metadata"]["total_tools"],
|
||||
status=conversation_data["metadata"].get("status", "active")
|
||||
)
|
||||
|
||||
# 添加到索引
|
||||
index[conversation_id] = {
|
||||
"title": metadata.title,
|
||||
"created_at": metadata.created_at,
|
||||
"updated_at": metadata.updated_at,
|
||||
"project_path": metadata.project_path,
|
||||
"project_relative_path": metadata.project_relative_path,
|
||||
"thinking_mode": metadata.thinking_mode,
|
||||
"total_messages": metadata.total_messages,
|
||||
"total_tools": metadata.total_tools,
|
||||
"status": metadata.status
|
||||
}
|
||||
|
||||
self._save_index(index)
|
||||
except Exception as e:
|
||||
print(f"⌘ 更新对话索引失败: {e}")
|
||||
|
||||
def save_conversation(
|
||||
self,
|
||||
conversation_id: str,
|
||||
messages: List[Dict],
|
||||
project_path: str = None,
|
||||
thinking_mode: bool = None,
|
||||
todo_list: Optional[Dict] = None
|
||||
) -> bool:
|
||||
"""
|
||||
保存对话(更新现有对话)
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
messages: 消息列表
|
||||
project_path: 项目路径
|
||||
thinking_mode: 思考模式
|
||||
|
||||
Returns:
|
||||
bool: 保存是否成功
|
||||
"""
|
||||
try:
|
||||
# 加载现有对话数据
|
||||
existing_data = self.load_conversation(conversation_id)
|
||||
if not existing_data:
|
||||
print(f"⚠️ 对话 {conversation_id} 不存在,无法更新")
|
||||
return False
|
||||
|
||||
# 更新数据
|
||||
existing_data["messages"] = messages
|
||||
existing_data["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 更新标题(如果消息发生变化)
|
||||
new_title = self._extract_title_from_messages(messages)
|
||||
if new_title != "新对话":
|
||||
existing_data["title"] = new_title
|
||||
|
||||
# 更新元数据
|
||||
if project_path is not None:
|
||||
path_metadata = self._prepare_project_path_metadata(project_path)
|
||||
existing_data["metadata"]["project_path"] = path_metadata["project_path"]
|
||||
existing_data["metadata"]["project_relative_path"] = path_metadata["project_relative_path"]
|
||||
else:
|
||||
existing_data["metadata"].setdefault("project_relative_path", None)
|
||||
if thinking_mode is not None:
|
||||
existing_data["metadata"]["thinking_mode"] = thinking_mode
|
||||
|
||||
existing_data["metadata"]["total_messages"] = len(messages)
|
||||
existing_data["metadata"]["total_tools"] = self._count_tools_in_messages(messages)
|
||||
|
||||
# 更新待办列表
|
||||
existing_data["todo_list"] = todo_list
|
||||
|
||||
# 确保Token统计结构存在(向后兼容)
|
||||
if "token_statistics" not in existing_data:
|
||||
existing_data["token_statistics"] = self._initialize_token_statistics()
|
||||
else:
|
||||
existing_data["token_statistics"]["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 保存文件
|
||||
self._save_conversation_file(conversation_id, existing_data)
|
||||
|
||||
# 更新索引
|
||||
self._update_index(conversation_id, existing_data)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⌘ 保存对话失败 {conversation_id}: {e}")
|
||||
return False
|
||||
|
||||
def load_conversation(self, conversation_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
加载对话数据
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
Dict: 对话数据,如果不存在返回None
|
||||
"""
|
||||
try:
|
||||
file_path = self._get_conversation_file_path(conversation_id)
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
if not content:
|
||||
return None
|
||||
|
||||
data = json.loads(content)
|
||||
|
||||
metadata = data.get("metadata", {})
|
||||
if "project_relative_path" not in metadata:
|
||||
metadata["project_relative_path"] = None
|
||||
self._save_conversation_file(conversation_id, data)
|
||||
print(f"🔧 为对话 {conversation_id} 添加相对路径字段")
|
||||
|
||||
# 向后兼容:确保Token统计结构存在
|
||||
if "token_statistics" not in data:
|
||||
data["token_statistics"] = self._initialize_token_statistics()
|
||||
# 自动保存修复后的数据
|
||||
self._save_conversation_file(conversation_id, data)
|
||||
print(f"🔧 为对话 {conversation_id} 添加Token统计结构")
|
||||
else:
|
||||
# 验证现有Token统计数据
|
||||
data = self._validate_token_statistics(data)
|
||||
|
||||
return data
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
print(f"⌘ 加载对话失败 {conversation_id}: {e}")
|
||||
return None
|
||||
|
||||
def update_token_statistics(self, conversation_id: str, input_tokens: int, output_tokens: int) -> bool:
|
||||
"""
|
||||
更新对话的Token统计
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
input_tokens: 输入Token数量
|
||||
output_tokens: 输出Token数量
|
||||
|
||||
Returns:
|
||||
bool: 更新是否成功
|
||||
"""
|
||||
try:
|
||||
conversation_data = self.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
print(f"⚠️ 无法找到对话 {conversation_id},跳过Token统计")
|
||||
return False
|
||||
|
||||
# 确保Token统计结构存在
|
||||
if "token_statistics" not in conversation_data:
|
||||
conversation_data["token_statistics"] = self._initialize_token_statistics()
|
||||
|
||||
# 更新统计数据
|
||||
token_stats = conversation_data["token_statistics"]
|
||||
token_stats["total_input_tokens"] = token_stats.get("total_input_tokens", 0) + input_tokens
|
||||
token_stats["total_output_tokens"] = token_stats.get("total_output_tokens", 0) + output_tokens
|
||||
token_stats["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 保存更新
|
||||
self._save_conversation_file(conversation_id, conversation_data)
|
||||
|
||||
print(f"📊 Token统计已更新: +{input_tokens}输入, +{output_tokens}输出 "
|
||||
f"(总计: {token_stats['total_input_tokens']}输入, {token_stats['total_output_tokens']}输出)")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⌘ 更新Token统计失败 {conversation_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_token_statistics(self, conversation_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
获取对话的Token统计
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
Dict: Token统计数据
|
||||
"""
|
||||
try:
|
||||
conversation_data = self.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
return None
|
||||
|
||||
token_stats = conversation_data.get("token_statistics", {})
|
||||
|
||||
# 确保基本字段存在
|
||||
result = {
|
||||
"total_input_tokens": token_stats.get("total_input_tokens", 0),
|
||||
"total_output_tokens": token_stats.get("total_output_tokens", 0),
|
||||
"total_tokens": token_stats.get("total_input_tokens", 0) + token_stats.get("total_output_tokens", 0),
|
||||
"updated_at": token_stats.get("updated_at"),
|
||||
"conversation_id": conversation_id
|
||||
}
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"⌘ 获取Token统计失败 {conversation_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_conversation_list(self, limit: int = 50, offset: int = 0) -> Dict:
|
||||
"""
|
||||
获取对话列表
|
||||
|
||||
Args:
|
||||
limit: 限制数量
|
||||
offset: 偏移量
|
||||
|
||||
Returns:
|
||||
Dict: 包含对话列表和统计信息
|
||||
"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
|
||||
# 按更新时间倒序排列
|
||||
sorted_conversations = sorted(
|
||||
index.items(),
|
||||
key=lambda x: x[1].get("updated_at", ""),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# 分页
|
||||
total = len(sorted_conversations)
|
||||
conversations = sorted_conversations[offset:offset+limit]
|
||||
|
||||
# 格式化结果
|
||||
result = []
|
||||
for conv_id, metadata in conversations:
|
||||
result.append({
|
||||
"id": conv_id,
|
||||
"title": metadata.get("title", "未命名对话"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"updated_at": metadata.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"project_relative_path": metadata.get("project_relative_path"),
|
||||
"thinking_mode": metadata.get("thinking_mode", False),
|
||||
"total_messages": metadata.get("total_messages", 0),
|
||||
"total_tools": metadata.get("total_tools", 0),
|
||||
"status": metadata.get("status", "active")
|
||||
})
|
||||
|
||||
return {
|
||||
"conversations": result,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": offset + limit < total
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"⌘ 获取对话列表失败: {e}")
|
||||
return {
|
||||
"conversations": [],
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False
|
||||
}
|
||||
|
||||
def delete_conversation(self, conversation_id: str) -> bool:
|
||||
"""
|
||||
删除对话
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
bool: 删除是否成功
|
||||
"""
|
||||
try:
|
||||
# 删除对话文件
|
||||
file_path = self._get_conversation_file_path(conversation_id)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
# 从索引中删除
|
||||
index = self._load_index()
|
||||
if conversation_id in index:
|
||||
del index[conversation_id]
|
||||
self._save_index(index)
|
||||
|
||||
# 如果删除的是当前对话,清除当前对话ID
|
||||
if self.current_conversation_id == conversation_id:
|
||||
self.current_conversation_id = None
|
||||
|
||||
print(f"🗑️ 已删除对话: {conversation_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⌘ 删除对话失败 {conversation_id}: {e}")
|
||||
return False
|
||||
|
||||
def archive_conversation(self, conversation_id: str) -> bool:
|
||||
"""
|
||||
归档对话(标记为已归档,不删除)
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
bool: 归档是否成功
|
||||
"""
|
||||
try:
|
||||
# 更新对话状态
|
||||
conversation_data = self.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
return False
|
||||
|
||||
conversation_data["metadata"]["status"] = "archived"
|
||||
conversation_data["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 保存更新
|
||||
self._save_conversation_file(conversation_id, conversation_data)
|
||||
self._update_index(conversation_id, conversation_data)
|
||||
|
||||
print(f"📦 已归档对话: {conversation_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⌘ 归档对话失败 {conversation_id}: {e}")
|
||||
return False
|
||||
|
||||
def search_conversations(self, query: str, limit: int = 20) -> List[Dict]:
|
||||
"""
|
||||
搜索对话
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
limit: 限制数量
|
||||
|
||||
Returns:
|
||||
List[Dict]: 匹配的对话列表
|
||||
"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
results = []
|
||||
|
||||
query_lower = query.lower()
|
||||
|
||||
for conv_id, metadata in index.items():
|
||||
# 搜索标题
|
||||
title = metadata.get("title", "").lower()
|
||||
if query_lower in title:
|
||||
score = 100 # 标题匹配权重最高
|
||||
results.append((score, {
|
||||
"id": conv_id,
|
||||
"title": metadata.get("title"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"updated_at": metadata.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"match_type": "title"
|
||||
}))
|
||||
continue
|
||||
|
||||
# 搜索项目路径
|
||||
project_path = metadata.get("project_path", "").lower()
|
||||
if query_lower in project_path:
|
||||
results.append((50, {
|
||||
"id": conv_id,
|
||||
"title": metadata.get("title"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"updated_at": metadata.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"match_type": "project_path"
|
||||
}))
|
||||
|
||||
# 按分数排序
|
||||
results.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
# 返回前N个结果
|
||||
return [result[1] for result in results[:limit]]
|
||||
except Exception as e:
|
||||
print(f"⌘ 搜索对话失败: {e}")
|
||||
return []
|
||||
|
||||
def cleanup_old_conversations(self, days: int = 30) -> int:
|
||||
"""
|
||||
清理旧对话(可选功能)
|
||||
|
||||
Args:
|
||||
days: 保留天数
|
||||
|
||||
Returns:
|
||||
int: 清理的对话数量
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
cutoff_iso = cutoff_date.isoformat()
|
||||
|
||||
index = self._load_index()
|
||||
to_delete = []
|
||||
|
||||
for conv_id, metadata in index.items():
|
||||
updated_at = metadata.get("updated_at", "")
|
||||
if updated_at < cutoff_iso and metadata.get("status") != "archived":
|
||||
to_delete.append(conv_id)
|
||||
|
||||
deleted_count = 0
|
||||
for conv_id in to_delete:
|
||||
if self.delete_conversation(conv_id):
|
||||
deleted_count += 1
|
||||
|
||||
if deleted_count > 0:
|
||||
print(f"🧹 清理了 {deleted_count} 个旧对话")
|
||||
|
||||
return deleted_count
|
||||
except Exception as e:
|
||||
print(f"⌘ 清理旧对话失败: {e}")
|
||||
return 0
|
||||
|
||||
def get_statistics(self) -> Dict:
|
||||
"""
|
||||
获取对话统计信息
|
||||
|
||||
Returns:
|
||||
Dict: 统计信息
|
||||
"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
|
||||
total_conversations = len(index)
|
||||
total_messages = sum(meta.get("total_messages", 0) for meta in index.values())
|
||||
total_tools = sum(meta.get("total_tools", 0) for meta in index.values())
|
||||
|
||||
# 按状态分类
|
||||
status_count = {}
|
||||
for metadata in index.values():
|
||||
status = metadata.get("status", "active")
|
||||
status_count[status] = status_count.get(status, 0) + 1
|
||||
|
||||
# 按思考模式分类
|
||||
thinking_mode_count = {
|
||||
"thinking": sum(1 for meta in index.values() if meta.get("thinking_mode")),
|
||||
"fast": sum(1 for meta in index.values() if not meta.get("thinking_mode"))
|
||||
}
|
||||
|
||||
# 新增:Token统计汇总
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
token_stats_count = 0
|
||||
|
||||
for conv_id in index.keys():
|
||||
token_stats = self.get_token_statistics(conv_id)
|
||||
if token_stats:
|
||||
total_input_tokens += token_stats.get("total_input_tokens", 0)
|
||||
total_output_tokens += token_stats.get("total_output_tokens", 0)
|
||||
token_stats_count += 1
|
||||
|
||||
return {
|
||||
"total_conversations": total_conversations,
|
||||
"total_messages": total_messages,
|
||||
"total_tools": total_tools,
|
||||
"status_distribution": status_count,
|
||||
"thinking_mode_distribution": thinking_mode_count,
|
||||
"token_statistics": {
|
||||
"total_input_tokens": total_input_tokens,
|
||||
"total_output_tokens": total_output_tokens,
|
||||
"total_tokens": total_input_tokens + total_output_tokens,
|
||||
"conversations_with_stats": token_stats_count
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"⌘ 获取统计信息失败: {e}")
|
||||
return {}
|
||||
|
||||
def get_current_conversation_id(self) -> Optional[str]:
|
||||
"""获取当前对话ID"""
|
||||
return self.current_conversation_id
|
||||
|
||||
def set_current_conversation_id(self, conversation_id: str):
|
||||
"""设置当前对话ID"""
|
||||
self.current_conversation_id = conversation_id
|
||||
|
||||
def calculate_conversation_tokens(self, conversation_id: str, context_manager=None, focused_files=None, terminal_content="") -> dict:
|
||||
"""计算对话的真实API token消耗"""
|
||||
try:
|
||||
if not context_manager:
|
||||
return {"total_tokens": 0}
|
||||
|
||||
conversation_data = self.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
return {"total_tokens": 0}
|
||||
|
||||
# 使用宿主终端的构建流程以贴合真实API请求
|
||||
if getattr(context_manager, "main_terminal", None):
|
||||
main_terminal = context_manager.main_terminal
|
||||
context = main_terminal.build_context()
|
||||
messages = main_terminal.build_messages(context, "")
|
||||
tools = main_terminal.define_tools()
|
||||
else:
|
||||
context = context_manager.build_main_context(memory_content="")
|
||||
messages = context_manager.build_messages(context, "")
|
||||
tools = self._get_tools_definition(context_manager) or []
|
||||
|
||||
total_tokens = context_manager.calculate_input_tokens(messages, tools)
|
||||
|
||||
return {"total_tokens": total_tokens}
|
||||
|
||||
except Exception as e:
|
||||
print(f"计算token失败: {e}")
|
||||
return {"total_tokens": 0}
|
||||
def _get_tools_definition(self, context_manager):
|
||||
"""获取工具定义"""
|
||||
try:
|
||||
# 需要找到工具定义的来源,通常在 main_terminal 中
|
||||
# 你需要找到 main_terminal 的引用或者 define_tools 方法
|
||||
|
||||
# 方法1: 如果 context_manager 有 main_terminal 引用
|
||||
if hasattr(context_manager, 'main_terminal') and context_manager.main_terminal:
|
||||
return context_manager.main_terminal.define_tools()
|
||||
|
||||
# 方法2: 如果有其他方式获取工具定义
|
||||
# 你需要去找一下在哪里调用了 calculate_input_tokens,看看 tools 参数是怎么传的
|
||||
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"获取工具定义失败: {e}")
|
||||
return []
|
||||
@ -1,131 +0,0 @@
|
||||
# utils/logger.py - 日志系统
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
try:
|
||||
from config import LOGS_DIR, LOG_LEVEL, LOG_FORMAT
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import LOGS_DIR, LOG_LEVEL, LOG_FORMAT
|
||||
|
||||
def setup_logger(name: str, log_file: str = None) -> logging.Logger:
|
||||
"""
|
||||
设置日志记录器
|
||||
|
||||
Args:
|
||||
name: 日志记录器名称
|
||||
log_file: 日志文件路径(可选)
|
||||
|
||||
Returns:
|
||||
配置好的日志记录器
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(getattr(logging, LOG_LEVEL))
|
||||
|
||||
# 清除已有的处理器
|
||||
logger.handlers.clear()
|
||||
|
||||
# 创建格式化器
|
||||
formatter = logging.Formatter(LOG_FORMAT)
|
||||
|
||||
# 控制台处理器(只显示WARNING及以上)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.WARNING)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件处理器
|
||||
if log_file:
|
||||
file_path = Path(LOGS_DIR) / log_file
|
||||
else:
|
||||
# 默认日志文件
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
file_path = Path(LOGS_DIR) / f"agent_{today}.log"
|
||||
|
||||
# 确保日志目录存在
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_handler = logging.FileHandler(file_path, encoding='utf-8')
|
||||
file_handler.setLevel(getattr(logging, LOG_LEVEL))
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
class TaskLogger:
|
||||
"""任务专用日志记录器"""
|
||||
|
||||
def __init__(self, task_id: str):
|
||||
self.task_id = task_id
|
||||
self.log_file = Path(LOGS_DIR) / "tasks" / f"{task_id}.log"
|
||||
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.logger = setup_logger(f"task_{task_id}", str(self.log_file))
|
||||
|
||||
def log_action(self, action: str, details: dict = None):
|
||||
"""记录操作"""
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"action": action,
|
||||
"details": details or {}
|
||||
}
|
||||
self.logger.info(f"ACTION: {log_entry}")
|
||||
|
||||
def log_result(self, success: bool, message: str, data: dict = None):
|
||||
"""记录结果"""
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"success": success,
|
||||
"message": message,
|
||||
"data": data or {}
|
||||
}
|
||||
|
||||
if success:
|
||||
self.logger.info(f"RESULT: {log_entry}")
|
||||
else:
|
||||
self.logger.error(f"RESULT: {log_entry}")
|
||||
|
||||
def log_error(self, error: Exception, context: str = ""):
|
||||
"""记录错误"""
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"error": str(error),
|
||||
"type": type(error).__name__,
|
||||
"context": context
|
||||
}
|
||||
self.logger.error(f"ERROR: {log_entry}", exc_info=True)
|
||||
|
||||
def get_log_content(self) -> str:
|
||||
"""获取日志内容"""
|
||||
if self.log_file.exists():
|
||||
with open(self.log_file, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
return ""
|
||||
|
||||
class ErrorLogger:
|
||||
"""错误专用日志记录器"""
|
||||
|
||||
@staticmethod
|
||||
def log_error(module: str, error: Exception, context: dict = None):
|
||||
"""记录错误到错误日志"""
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
error_file = Path(LOGS_DIR) / "errors" / f"errors_{today}.log"
|
||||
error_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = setup_logger(f"error_{module}", str(error_file))
|
||||
|
||||
error_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"module": module,
|
||||
"error": str(error),
|
||||
"type": type(error).__name__,
|
||||
"context": context or {}
|
||||
}
|
||||
|
||||
logger.error(f"ERROR: {error_entry}", exc_info=True)
|
||||
@ -1,319 +0,0 @@
|
||||
# utils/terminal_factory.py - 跨平台终端工厂(修改为Windows优先使用CMD)
|
||||
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
from typing import Optional, Dict, List
|
||||
from pathlib import Path
|
||||
|
||||
class TerminalFactory:
|
||||
"""跨平台终端工厂,用于创建合适的终端进程"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化终端工厂"""
|
||||
self.platform = sys.platform
|
||||
self.available_shells = self._detect_available_shells()
|
||||
|
||||
def _detect_available_shells(self) -> Dict[str, str]:
|
||||
"""检测系统中可用的shell"""
|
||||
shells = {}
|
||||
|
||||
if self.platform == "win32":
|
||||
# Windows系统
|
||||
# 检查cmd(优先)
|
||||
if shutil.which("cmd.exe"):
|
||||
shells["cmd"] = "cmd.exe"
|
||||
|
||||
# 检查PowerShell(备用)
|
||||
if shutil.which("powershell.exe"):
|
||||
shells["powershell"] = "powershell.exe"
|
||||
|
||||
# 检查Windows Terminal(新版Windows)
|
||||
if shutil.which("wt.exe"):
|
||||
shells["wt"] = "wt.exe"
|
||||
|
||||
# 检查Git Bash
|
||||
git_bash_paths = [
|
||||
r"C:\Program Files\Git\bin\bash.exe",
|
||||
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
||||
os.path.expanduser("~/AppData/Local/Programs/Git/bin/bash.exe")
|
||||
]
|
||||
for path in git_bash_paths:
|
||||
if os.path.exists(path):
|
||||
shells["git-bash"] = path
|
||||
break
|
||||
|
||||
# 检查WSL
|
||||
if shutil.which("wsl.exe"):
|
||||
shells["wsl"] = "wsl.exe"
|
||||
|
||||
else:
|
||||
# Unix-like系统(Linux, macOS)
|
||||
# 检查bash
|
||||
if shutil.which("bash"):
|
||||
shells["bash"] = "/bin/bash"
|
||||
|
||||
# 检查zsh(macOS默认)
|
||||
if shutil.which("zsh"):
|
||||
shells["zsh"] = "/bin/zsh"
|
||||
|
||||
# 检查sh
|
||||
if shutil.which("sh"):
|
||||
shells["sh"] = "/bin/sh"
|
||||
|
||||
# 检查fish
|
||||
if shutil.which("fish"):
|
||||
shells["fish"] = shutil.which("fish")
|
||||
|
||||
return shells
|
||||
|
||||
def get_shell_command(self, preferred: Optional[str] = None) -> str:
|
||||
"""
|
||||
获取合适的shell命令
|
||||
|
||||
Args:
|
||||
preferred: 首选的shell类型
|
||||
|
||||
Returns:
|
||||
shell命令路径
|
||||
"""
|
||||
# 如果指定了首选shell且可用
|
||||
if preferred and preferred in self.available_shells:
|
||||
return self.available_shells[preferred]
|
||||
|
||||
# 根据平台选择默认shell
|
||||
if self.platform == "win32":
|
||||
# Windows优先级:CMD优先!(修改这里)
|
||||
if "cmd" in self.available_shells:
|
||||
return self.available_shells["cmd"]
|
||||
elif "powershell" in self.available_shells:
|
||||
return self.available_shells["powershell"]
|
||||
elif "git-bash" in self.available_shells:
|
||||
return self.available_shells["git-bash"]
|
||||
else:
|
||||
# 最后的默认选项
|
||||
return "cmd.exe"
|
||||
|
||||
elif self.platform == "darwin":
|
||||
# macOS优先级:zsh (默认) > bash > sh
|
||||
if "zsh" in self.available_shells:
|
||||
return self.available_shells["zsh"]
|
||||
elif "bash" in self.available_shells:
|
||||
return self.available_shells["bash"]
|
||||
else:
|
||||
return "/bin/sh"
|
||||
|
||||
else:
|
||||
# Linux优先级:bash > zsh > sh
|
||||
if "bash" in self.available_shells:
|
||||
return self.available_shells["bash"]
|
||||
elif "zsh" in self.available_shells:
|
||||
return self.available_shells["zsh"]
|
||||
else:
|
||||
return "/bin/sh"
|
||||
|
||||
def get_clear_command(self) -> str:
|
||||
"""获取清屏命令"""
|
||||
if self.platform == "win32":
|
||||
return "cls"
|
||||
else:
|
||||
return "clear"
|
||||
|
||||
def get_list_command(self) -> str:
|
||||
"""获取列出文件命令"""
|
||||
if self.platform == "win32":
|
||||
return "dir"
|
||||
else:
|
||||
return "ls -la"
|
||||
|
||||
def get_change_dir_command(self, path: str) -> str:
|
||||
"""获取切换目录命令"""
|
||||
return f"cd {path}"
|
||||
|
||||
def get_python_command(self) -> str:
|
||||
"""获取Python命令"""
|
||||
# Windows优先顺序调整
|
||||
if self.platform == "win32":
|
||||
# Windows: 优先python,然后py,最后python3
|
||||
if shutil.which("python"):
|
||||
return "python"
|
||||
elif shutil.which("py"):
|
||||
return "py"
|
||||
elif shutil.which("python3"):
|
||||
return "python3"
|
||||
else:
|
||||
return "python"
|
||||
else:
|
||||
# Unix-like: 优先python3
|
||||
if shutil.which("python3"):
|
||||
return "python3"
|
||||
elif shutil.which("python"):
|
||||
return "python"
|
||||
else:
|
||||
return "python3"
|
||||
|
||||
def get_pip_command(self) -> str:
|
||||
"""获取pip命令"""
|
||||
python_cmd = self.get_python_command()
|
||||
return f"{python_cmd} -m pip"
|
||||
|
||||
def get_env_activation_command(self, venv_path: str) -> str:
|
||||
"""
|
||||
获取虚拟环境激活命令
|
||||
|
||||
Args:
|
||||
venv_path: 虚拟环境路径
|
||||
|
||||
Returns:
|
||||
激活命令
|
||||
"""
|
||||
venv_path = Path(venv_path)
|
||||
|
||||
if self.platform == "win32":
|
||||
# Windows
|
||||
activate_script = venv_path / "Scripts" / "activate.bat"
|
||||
if activate_script.exists():
|
||||
return str(activate_script)
|
||||
|
||||
# PowerShell脚本(备用)
|
||||
ps_script = venv_path / "Scripts" / "Activate.ps1"
|
||||
if ps_script.exists():
|
||||
return f"& '{ps_script}'"
|
||||
|
||||
else:
|
||||
# Unix-like
|
||||
activate_script = venv_path / "bin" / "activate"
|
||||
if activate_script.exists():
|
||||
return f"source {activate_script}"
|
||||
|
||||
return ""
|
||||
|
||||
def format_command_with_timeout(self, command: str, timeout_seconds: int) -> str:
|
||||
"""
|
||||
格式化带超时的命令
|
||||
|
||||
Args:
|
||||
command: 原始命令
|
||||
timeout_seconds: 超时秒数
|
||||
|
||||
Returns:
|
||||
带超时的命令
|
||||
"""
|
||||
if self.platform == "win32":
|
||||
# Windows没有内置的timeout命令用于限制其他命令
|
||||
# 需要使用PowerShell或其他方法
|
||||
return command
|
||||
else:
|
||||
# Unix-like系统使用timeout命令
|
||||
return f"timeout {timeout_seconds} {command}"
|
||||
|
||||
def get_process_list_command(self) -> str:
|
||||
"""获取进程列表命令"""
|
||||
if self.platform == "win32":
|
||||
return "tasklist"
|
||||
elif self.platform == "darwin":
|
||||
return "ps aux"
|
||||
else:
|
||||
return "ps aux"
|
||||
|
||||
def get_kill_command(self, process_id: int) -> str:
|
||||
"""
|
||||
获取终止进程命令
|
||||
|
||||
Args:
|
||||
process_id: 进程ID
|
||||
|
||||
Returns:
|
||||
终止命令
|
||||
"""
|
||||
if self.platform == "win32":
|
||||
return f"taskkill /PID {process_id} /F"
|
||||
else:
|
||||
return f"kill -9 {process_id}"
|
||||
|
||||
def get_system_info(self) -> Dict:
|
||||
"""获取系统信息"""
|
||||
info = {
|
||||
"platform": self.platform,
|
||||
"platform_name": self._get_platform_name(),
|
||||
"available_shells": list(self.available_shells.keys()),
|
||||
"default_shell": self.get_shell_command(),
|
||||
"python_command": self.get_python_command(),
|
||||
"pip_command": self.get_pip_command()
|
||||
}
|
||||
|
||||
# 添加系统版本信息
|
||||
try:
|
||||
import platform
|
||||
info["system"] = platform.system()
|
||||
info["release"] = platform.release()
|
||||
info["version"] = platform.version()
|
||||
info["machine"] = platform.machine()
|
||||
info["processor"] = platform.processor()
|
||||
except:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
def _get_platform_name(self) -> str:
|
||||
"""获取友好的平台名称"""
|
||||
if self.platform == "win32":
|
||||
return "Windows"
|
||||
elif self.platform == "darwin":
|
||||
return "macOS"
|
||||
elif self.platform.startswith("linux"):
|
||||
return "Linux"
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
def create_terminal_config(self, working_dir: str = None) -> Dict:
|
||||
"""
|
||||
创建终端配置
|
||||
|
||||
Args:
|
||||
working_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
终端配置字典
|
||||
"""
|
||||
config = {
|
||||
"shell": self.get_shell_command(), # 这里会使用cmd.exe
|
||||
"working_dir": working_dir or os.getcwd(),
|
||||
"env": os.environ.copy(),
|
||||
"platform": self.platform
|
||||
}
|
||||
|
||||
# Windows特殊配置
|
||||
if self.platform == "win32":
|
||||
# 设置代码页为UTF-8
|
||||
config["env"]["PYTHONIOENCODING"] = "utf-8"
|
||||
config["startup_commands"] = ["chcp 65001"] # UTF-8代码页
|
||||
else:
|
||||
# Unix-like特殊配置
|
||||
config["env"]["TERM"] = "xterm-256color"
|
||||
config["startup_commands"] = []
|
||||
|
||||
return config
|
||||
|
||||
def test_shell(self, shell_path: str) -> bool:
|
||||
"""
|
||||
测试shell是否可用
|
||||
|
||||
Args:
|
||||
shell_path: shell路径
|
||||
|
||||
Returns:
|
||||
是否可用
|
||||
"""
|
||||
try:
|
||||
# 尝试运行一个简单命令
|
||||
result = subprocess.run(
|
||||
[shell_path, "/c" if self.platform == "win32" else "-c", "echo test"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
except:
|
||||
return False
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user