feat: gracefully stop tool execution on user request

- Remove direct task.cancel() calls, use stop flag instead
- Monitor stop flag every 100ms during tool execution
- Cancel tool task immediately when stop flag is detected
- Return "命令执行被用户取消" as tool result with role=tool
- Save cancellation result to conversation history
- Prevent abrupt task termination, allow graceful shutdown

Changes:
- server/socket_handlers.py: Comment out pending_task.cancel()
- server/tasks.py: Comment out entry['task'].cancel()
- server/chat_flow_tool_loop.py: Add stop flag monitoring loop

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-03-08 03:50:34 +08:00
parent 141f6116fd
commit 07be7a1061
12 changed files with 1222 additions and 15 deletions

107
BUG_FIX_CHANGELOG.md Normal file
View File

@ -0,0 +1,107 @@
# Bug 修复说明
## 修复的问题
### 1. 内容显示两遍
**原因**: WebSocket 和轮询同时工作,导致事件被处理了两次
**解决方案**:
- 在 `app/state.ts` 中添加 `usePollingMode: true` 标志
- 在所有关键的 WebSocket 事件处理器开头添加检查:
```javascript
if (ctx.usePollingMode) {
return; // 跳过 WebSocket 事件
}
```
- 修改的事件包括:
- `ai_message_start`
- `thinking_start/chunk/end`
- `text_start/chunk/end`
- `tool_preparing/intent/status/start`
### 2. 刷新后内容消失
**原因**: `restoreTaskState()` 清空了 `messages` 数组,导致已加载的历史记录丢失
**解决方案**:
- 修改 `app/methods/taskPolling.ts` 中的 `restoreTaskState()` 方法
- 不再清空 `messages` 数组
- 只标记状态为进行中(`streamingMessage = true`, `taskInProgress = true`
- 轮询只处理新事件,不重建历史记录
## 修改的文件
1. **static/src/app/state.ts**
- 添加 `usePollingMode: true` 标志
2. **static/src/app/methods/taskPolling.ts**
- 修改 `restoreTaskState()` 方法,不清空消息
3. **static/src/composables/useLegacySocket.ts**
- 在所有关键事件处理器开头添加轮询模式检查
- 修改的事件:
- `ai_message_start`
- `thinking_start`
- `thinking_chunk`
- `thinking_end`
- `text_start`
- `text_chunk`
- `text_end`
- `tool_preparing`
- `tool_intent`
- `tool_status`
- `tool_start`
## 工作流程
### 正常发送消息
1. 用户输入消息并发送
2. 调用 REST API 创建任务
3. 后端在独立线程中运行任务
4. 前端每秒轮询一次,获取新事件
5. 通过 `handleTaskEvent()` 处理事件,更新界面
6. WebSocket 事件被 `usePollingMode` 标志跳过
### 页面刷新恢复
1. 页面加载完成
2. 延迟 1 秒后调用 `restoreTaskState()`
3. 查找运行中的任务
4. 如果找到,标记状态为进行中
5. 启动轮询,只处理新事件
6. 已加载的历史记录保持不变
7. 新事件追加到现有消息后面
## 测试建议
1. **基本功能**
- 发送消息,观察是否只显示一次
- 刷新页面,观察历史记录是否保留
- 观察新内容是否正常追加
2. **边界情况**
- 任务执行中多次刷新
- 任务完成后刷新
- 快速连续发送多条消息
3. **性能**
- 长时间运行任务
- 大量事件(观察是否有重复)
## 构建
```bash
cd static
npm run build
```
构建成功!
- `static/dist/assets/main.js` (687.87 kB)
- `static/dist/assets/task.js` (3.68 kB)
## 下一步
现在可以启动服务器测试:
```bash
python web_server.py
```
访问 http://localhost:8091 测试功能。

191
BUG_FIX_V2_CHANGELOG.md Normal file
View File

@ -0,0 +1,191 @@
# Bug 修复 v2 - 工具块和轮询优化
## 修复的问题
### 1. 工具块无法显示
**原因**: 工具事件处理逻辑不完整,缺少关键的工具创建和更新逻辑
**解决方案**:
- 完整实现 `handleToolPreparing()` - 创建工具准备块
- 完整实现 `handleToolStart()` - 工具开始执行
- 完整实现 `handleToolUpdateAction()` - 工具状态更新和完成
- 添加 `update_action` 事件处理(工具完成的关键事件)
- 在 WebSocket 中跳过 `update_action` 事件
### 2. 轮询频率太慢1秒
**原因**: 1秒的轮询间隔无法提供流式输出的体验
**解决方案**:
- 将轮询间隔从 1000ms 改为 **150ms**
- 接近流式输出的效果(每秒约 6-7 次更新)
- 在 `stores/task.ts` 中修改 `startPolling()` 方法
### 3. 刷新后加载两遍内容
**原因**:
- `restoreTaskState()` 没有检查是否已在流式输出中
- `handleTaskComplete()` 会重新加载历史记录
**解决方案**:
- 在 `restoreTaskState()` 中添加状态检查,避免重复恢复
- 检查最后一条消息是否是 assistant 消息
- 在 `handleTaskComplete()` 中移除 `fetchAndDisplayHistory()`,只更新统计
## 修改的文件
### 1. static/src/app/methods/taskPolling.ts
**工具处理逻辑**:
```javascript
handleToolPreparing(data) {
// 创建工具准备块
const action = {
id: data.id,
type: 'tool',
tool: {
status: 'preparing',
name: data.name,
// ... 完整的工具属性
}
};
msg.actions.push(action);
this.preparingTools.set(data.id, action);
}
handleToolStart(data) {
// 从 preparing 转为 running
let action = this.preparingTools.get(data.preparing_id);
action.tool.status = 'running';
action.tool.arguments = data.arguments;
// ... 更新工具状态
}
handleToolUpdateAction(data) {
// 更新工具状态(包括完成)
let targetAction = this.toolFindAction(data.id, ...);
targetAction.tool.status = data.status;
targetAction.tool.result = data.result;
// ... 更新工具结果
}
```
**恢复逻辑优化**:
```javascript
async restoreTaskState() {
// 检查是否已在流式输出中
if (this.streamingMessage || this.taskInProgress) {
return;
}
// 检查是否已有 assistant 消息
const lastMessage = this.messages[this.messages.length - 1];
const hasAssistantMessage = lastMessage && lastMessage.role === 'assistant';
// 只标记状态,不清空消息
this.streamingMessage = true;
this.taskInProgress = true;
}
```
**任务完成优化**:
```javascript
handleTaskComplete(data) {
// 只更新统计,不重新加载历史
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
// 移除了 fetchAndDisplayHistory()
}
```
### 2. static/src/stores/task.ts
**轮询频率优化**:
```javascript
startPolling(eventHandler) {
// 150ms 间隔,接近流式输出效果
this.pollingInterval = window.setInterval(() => {
this.pollTaskEvents(handler);
}, 150);
}
```
### 3. static/src/composables/useLegacySocket.ts
**跳过 update_action 事件**:
```javascript
ctx.socket.on('update_action', (data) => {
if (ctx.usePollingMode) {
return; // 跳过 WebSocket 事件
}
// ... 原有逻辑
});
```
## 事件处理流程
### 工具执行流程
1. `tool_preparing` → 创建工具准备块
2. `tool_start` → 工具开始执行,更新状态为 running
3. `update_action` → 工具完成,更新状态为 completed显示结果
### 轮询流程
1. 每 150ms 轮询一次
2. 获取新事件(通过 `from` 参数)
3. 处理事件,更新界面
4. 任务完成后停止轮询
### 刷新恢复流程
1. 页面加载完成
2. 延迟 1 秒后调用 `restoreTaskState()`
3. 检查是否已在流式输出中(避免重复)
4. 检查是否已有 assistant 消息(历史已加载)
5. 只标记状态,启动轮询
6. 轮询只处理新事件,不重建历史
## 性能优化
- **轮询频率**: 150ms每秒约 6-7 次)
- **事件增量获取**: 通过 `from` 参数只获取新事件
- **避免重复加载**: 检查状态和消息,避免重复恢复
- **避免重复显示**: 任务完成后不重新加载历史
## 测试建议
1. **工具块显示**
- 发送需要调用工具的消息
- 观察工具块是否正常显示
- 观察工具状态变化preparing → running → completed
- 观察工具结果是否正确显示
2. **流式输出效果**
- 发送消息,观察输出是否流畅
- 观察思考块、文本块的更新频率
- 对比之前 1 秒轮询的卡顿感
3. **刷新恢复**
- 任务执行中刷新页面
- 观察是否只显示一遍内容
- 观察新内容是否正常追加
- 观察工具块是否保留
4. **任务完成**
- 等待任务完成
- 观察是否有重复内容
- 观察统计是否正确更新
## 构建
```bash
cd static
npm run build
```
构建成功!
- `static/dist/assets/main.js` (689.89 kB)
- `static/dist/assets/task.js` (3.68 kB)
## 已知改进
- ✅ 工具块正常显示
- ✅ 轮询频率提升到 150ms接近流式效果
- ✅ 刷新后不重复显示内容
- ✅ 任务完成后不重复加载历史
- ✅ 状态检查避免重复恢复
现在可以启动服务器测试了!

187
BUG_FIX_V3_CHANGELOG.md Normal file
View File

@ -0,0 +1,187 @@
# Bug 修复 v3 - 刷新后重复加载问题
## 修复的问题
### 刷新后加载两遍内容
**原因**:
1. `loadInitialData()` 会调用 `fetchAndDisplayHistory()` 加载历史记录
2. 1 秒后 `restoreTaskState()` 启动轮询
3. `loadRunningTask()` 设置 `lastEventIndex = 0`,从头开始获取所有事件
4. 导致已经在历史中的事件被重复处理
**解决方案**:
#### 1. 等待历史加载完成
`restoreTaskState()` 中检查历史是否已加载:
```javascript
async restoreTaskState() {
// 检查历史是否已加载
const hasMessages = Array.isArray(this.messages) && this.messages.length > 0;
if (!hasMessages) {
// 等待历史加载完成后再恢复
setTimeout(() => {
this.restoreTaskState();
}, 500);
return;
}
// 历史已加载,启动轮询
taskStore.startPolling(...);
}
```
#### 2. 计算正确的事件偏移量
`loadRunningTask()` 中获取任务详情,计算已处理的事件数量:
```javascript
async loadRunningTask(conversationId) {
// 查找运行中的任务
const runningTask = result.data.find(...);
if (runningTask) {
// 获取任务详情,计算已处理的事件数量
const detailResponse = await fetch(`/api/tasks/${runningTask.task_id}`);
const detailResult = await detailResponse.json();
// 设置为当前事件数量,只获取新事件
this.lastEventIndex = detailResult.data.next_offset || detailResult.data.events.length;
debugLog('[Task] 设置起始偏移量:', this.lastEventIndex);
}
}
```
## 工作流程
### 页面刷新恢复流程(修复后)
1. **页面加载** (`mounted()`)
- 调用 `loadInitialData()`
- 加载历史记录(`fetchAndDisplayHistory()`
- 1 秒后调用 `restoreTaskState()`
2. **任务恢复** (`restoreTaskState()`)
- 检查是否已在流式输出中 → 跳过
- 查找运行中的任务
- **检查历史是否已加载** → 如果未加载,等待 500ms 后重试
- 历史已加载 → 继续
3. **加载任务详情** (`loadRunningTask()`)
- 查找运行中的任务
- **获取任务详情**
- **计算已处理的事件数量**
- 设置 `lastEventIndex` 为当前事件数量
4. **启动轮询** (`startPolling()`)
- 从 `lastEventIndex` 开始轮询
- **只获取新事件**
- 每 150ms 轮询一次
5. **处理新事件**
- 只处理新产生的事件
- 追加到已有的历史记录后面
- 不重复显示已有内容
## 关键改进
### 1. 等待历史加载
- 在 `restoreTaskState()` 中检查 `messages` 是否已加载
- 如果未加载,延迟 500ms 后重试
- 确保历史加载完成后再启动轮询
### 2. 正确的事件偏移量
- 在 `loadRunningTask()` 中获取任务详情
- 计算已处理的事件数量(`next_offset` 或 `events.length`
- 设置 `lastEventIndex` 为当前事件数量
- 轮询只获取新事件(`from=lastEventIndex`
### 3. 避免重复处理
- 历史记录由 `fetchAndDisplayHistory()` 加载
- 轮询只处理新事件
- 不会重复显示已有内容
## 修改的文件
### 1. static/src/app/methods/taskPolling.ts
**等待历史加载**:
```javascript
async restoreTaskState() {
// 检查历史是否已加载
const hasMessages = Array.isArray(this.messages) && this.messages.length > 0;
if (!hasMessages) {
debugLog('[TaskPolling] 历史未加载,等待历史加载完成');
setTimeout(() => {
this.restoreTaskState();
}, 500);
return;
}
debugLog('[TaskPolling] 历史已加载,启动轮询');
// ... 启动轮询
}
```
### 2. static/src/stores/task.ts
**计算事件偏移量**:
```javascript
async loadRunningTask(conversationId) {
const runningTask = result.data.find(...);
if (runningTask) {
// 获取任务详情
const detailResponse = await fetch(`/api/tasks/${runningTask.task_id}`);
const detailResult = await detailResponse.json();
// 设置为当前事件数量
this.lastEventIndex = detailResult.data.next_offset || detailResult.data.events.length;
debugLog('[Task] 设置起始偏移量:', this.lastEventIndex);
}
}
```
## 测试场景
### 1. 正常发送消息
- 发送消息
- 观察输出是否正常
- 观察是否只显示一次
### 2. 刷新页面(任务进行中)
- 任务执行中刷新页面
- 观察历史记录是否正确显示
- 观察是否只显示一次
- 观察新内容是否正常追加
- 观察工具块是否保留
### 3. 刷新页面(任务完成后)
- 任务完成后刷新页面
- 观察历史记录是否完整
- 观察是否没有重复内容
### 4. 多次刷新
- 任务执行中多次刷新
- 观察每次刷新是否正常
- 观察是否有累积的重复内容
## 构建
```bash
cd static
npm run build
```
构建成功!
- `static/dist/assets/main.js` (690.04 kB)
- `static/dist/assets/task.js` (4.01 kB)
## 最终效果
- ✅ 刷新后不重复显示内容
- ✅ 历史记录正确加载
- ✅ 新事件正常追加
- ✅ 工具块正确显示
- ✅ 流畅的输出效果150ms 轮询)
现在可以测试了!刷新页面应该只显示一次内容,新内容会正常追加。

186
BUG_FIX_V4_CHANGELOG.md Normal file
View File

@ -0,0 +1,186 @@
# Bug 修复 v4 - 刷新后后端停止问题
## 修复的问题
### 刷新后后端直接停止,不继续执行
**原因**:
1. 页面刷新时 WebSocket 断开
2. `handle_disconnect` 检测到断开且没有其他连接
3. 设置 `stop_flags[f"user:{username}"]``True`
4. 任务检查停止标志时,会查找 `stop_flags[task_id]``stop_flags[f"user:{username}"]`
5. 发现用户级别的停止标志,任务停止执行
**解决方案**:
`handle_disconnect` 中检查是否有通过 REST API 创建的运行中任务。如果有,说明使用轮询模式,不应该停止任务。
## 修改的文件
### server/socket_handlers.py
**修改前**:
```python
@socketio.on('disconnect')
def handle_disconnect():
username = connection_users.pop(request.sid, None)
has_other_connection = False
if username:
for sid, user in connection_users.items():
if user == username:
has_other_connection = True
break
task_info = get_stop_flag(request.sid, username)
if isinstance(task_info, dict) and not has_other_connection:
task_info['stop'] = True # 设置停止标志
# ... 取消任务
clear_stop_flag(request.sid, None) # 清理所有停止标志
```
**修改后**:
```python
@socketio.on('disconnect')
def handle_disconnect():
username = connection_users.pop(request.sid, None)
has_other_connection = False
if username:
for sid, user in connection_users.items():
if user == username:
has_other_connection = True
break
# 检查是否有通过 REST API 创建的运行中任务
has_rest_api_task = False
if username and not has_other_connection:
try:
from .tasks import task_manager
running_tasks = [t for t in task_manager.list_tasks(username) if t.status == "running"]
if running_tasks:
has_rest_api_task = True
debug_log(f"[WebSocket] 用户 {username} 有运行中的 REST API 任务,不停止")
except Exception as e:
debug_log(f"[WebSocket] 检查 REST API 任务失败: {e}")
task_info = get_stop_flag(request.sid, username)
# 只有在没有其他连接且没有 REST API 任务时才停止
if isinstance(task_info, dict) and not has_other_connection and not has_rest_api_task:
task_info['stop'] = True
# ... 取消任务
# 清理停止标志(只清理 sid 级别的,不清理 user 级别的)
if request.sid in stop_flags:
stop_flags.pop(request.sid, None)
```
## 工作流程
### 停止标志机制
**stop_flags 结构**:
```python
stop_flags = {
"client_sid": {"stop": bool, "task": asyncio.Task, "terminal": WebTerminal},
"user:{username}": {"stop": bool, "task": asyncio.Task, "terminal": WebTerminal},
"task_id": {"stop": bool, "task": None, "terminal": None}
}
```
**查找顺序**:
1. 先查找 `stop_flags[client_sid]`WebSocket sid 或 task_id
2. 再查找 `stop_flags[f"user:{username}"]`(用户级别)
### WebSocket 断开流程(修复后)
1. **检测断开**
- 移除 `connection_users[sid]`
- 检查是否有其他连接
2. **检查 REST API 任务**
- 查询 `task_manager.list_tasks(username)`
- 检查是否有 `status == "running"` 的任务
- 如果有,设置 `has_rest_api_task = True`
3. **决定是否停止**
- 如果有其他连接 → 不停止
- 如果有 REST API 任务 → 不停止
- 否则 → 停止任务
4. **清理停止标志**
- 只清理 `stop_flags[sid]`sid 级别)
- 不清理 `stop_flags[f"user:{username}"]`(用户级别)
- 避免影响 REST API 任务
### 页面刷新流程(修复后)
1. **WebSocket 断开**
- 检测到有运行中的 REST API 任务
- 不设置停止标志
- 任务继续执行
2. **页面重新加载**
- 加载历史记录
- 恢复任务状态
- 启动轮询
3. **任务继续执行**
- 后端任务不受影响
- 继续生成事件
- 前端轮询获取新事件
## 关键改进
### 1. 检测 REST API 任务
- 在 WebSocket 断开时检查是否有运行中的 REST API 任务
- 如果有,说明使用轮询模式,不应该停止
### 2. 保护 REST API 任务
- 只有在没有其他连接且没有 REST API 任务时才停止
- 避免 WebSocket 断开影响 REST API 任务
### 3. 精确清理停止标志
- 只清理 sid 级别的停止标志
- 不清理用户级别的停止标志
- 避免误清理 REST API 任务的停止标志
## 测试场景
### 1. 正常发送消息
- 发送消息
- 观察任务是否正常执行
- 观察输出是否正常
### 2. 刷新页面(任务进行中)
- 任务执行中刷新页面
- **观察后端是否继续执行**
- 观察前端是否正常恢复
- 观察新内容是否正常追加
### 3. 多次刷新
- 任务执行中多次刷新
- 观察每次刷新后任务是否继续
- 观察是否有累积的问题
### 4. 停止任务
- 点击停止按钮
- 观察任务是否立即停止
- 观察前端状态是否正确
## 构建
```bash
cd static
npm run build
```
构建成功!
## 最终效果
- ✅ 刷新后后端继续执行
- ✅ 前端正常恢复状态
- ✅ 新内容正常追加
- ✅ 工具块正确显示
- ✅ 流畅的输出效果150ms 轮询)
- ✅ 不重复显示内容
现在可以测试了!刷新页面后,后端应该继续执行,前端会正常恢复并显示新内容。

180
POLLING_MODE_CHANGELOG.md Normal file
View File

@ -0,0 +1,180 @@
# 轮询模式改造完成
## 改造内容
已成功将前端从 WebSocket 实时推送模式改造为 REST API + 轮询模式。
### 主要变更
#### 1. 后端改造
- **server/tasks.py**: 已有完整的任务管理系统
- `POST /api/tasks` - 创建任务
- `GET /api/tasks/<task_id>?from=<offset>` - 轮询任务事件
- `POST /api/tasks/<task_id>/cancel` - 取消任务
- 支持 videos 和 run_mode 参数
#### 2. 前端改造
- **stores/task.ts** (新建): 任务轮询状态管理
- `createTask()` - 创建任务并启动轮询
- `pollTaskEvents()` - 轮询任务事件
- `startPolling()` / `stopPolling()` - 控制轮询
- `loadRunningTask()` - 加载运行中的任务(用于页面刷新恢复)
- **app/methods/taskPolling.ts** (新建): 事件处理器
- `handleTaskEvent()` - 统一事件分发
- `handleAiMessageStart()` - AI 消息开始
- `handleThinkingStart/Chunk/End()` - 思考过程
- `handleTextStart/Chunk/End()` - 文本输出
- `handleToolPreparing/Start/UpdateAction()` - 工具执行
- `handleAppendPayload/ModifyPayload()` - 文件操作
- `handleTaskComplete()` - 任务完成
- `restoreTaskState()` - 恢复任务状态(页面刷新后)
- **app/methods/message.ts**: 修改消息发送逻辑
- 改为调用 REST API 创建任务
- 停止任务改为调用 REST API 取消任务
- 保留 WebSocket 兼容性(命令模式)
- **app/lifecycle.ts**: 修改生命周期
- `mounted()` 中注册全局事件处理器
- `mounted()` 中延迟调用 `restoreTaskState()` 恢复任务
- `beforeUnmount()` 中停止轮询
- **app.ts**: 集成任务轮询方法
## 功能特性
### ✅ 已实现
1. **后端独立运行**
- 任务创建后在后台线程运行
- 不依赖 WebSocket 连接
- 事件完整记录到内存队列(最多 1000 条)
2. **前端轮询更新**
- 1 秒间隔轮询任务事件
- 增量获取新事件(通过 `from` 参数)
- 任务完成后自动停止轮询
3. **页面刷新恢复**
- 页面加载时自动查找运行中的任务
- 从事件流重建前端状态
- 恢复思考块、文本块、工具块的展开状态
- 继续轮询更新新事件
4. **状态完整恢复**
- 正在输出的思考内容 → 展开的思考块 + 已输出内容
- 正在输出的文本内容 → 文本块 + 已输出内容
- 正在执行的工具 → 工具块 + 执行状态
- 文件操作 → append/modify payload 块
5. **任务取消**
- 支持通过 REST API 取消任务
- 前端立即清理状态
- 后端标记停止标志
## 使用方式
### 正常使用
1. 输入消息并发送
2. 后端自动创建任务并开始执行
3. 前端每秒轮询一次,实时更新界面
4. 任务完成后自动停止轮询
### 页面刷新
1. 刷新页面
2. 前端自动检测运行中的任务
3. 从事件流重建状态(思考块、文本块、工具块)
4. 继续轮询更新新事件
5. 显示提示:"检测到进行中的任务,已恢复连接"
### 停止任务
1. 点击停止按钮
2. 调用 REST API 取消任务
3. 前端立即清理状态
4. 后端收到停止信号后终止执行
## 技术细节
### 轮询机制
- 间隔1 秒
- 增量获取:通过 `from` 参数指定起始事件索引
- 自动停止:任务状态为 `succeeded/failed/canceled` 时停止
### 事件流格式
```json
{
"idx": 0,
"type": "ai_message_start",
"data": {},
"ts": 1234567890.123
}
```
### 任务状态
- `pending` - 待执行
- `running` - 执行中
- `succeeded` - 成功完成
- `failed` - 执行失败
- `canceled` - 已取消
### 事件类型
- `ai_message_start` - AI 消息开始
- `thinking_start/chunk/end` - 思考过程
- `text_start/chunk/end` - 文本输出
- `tool_preparing/start/update_action` - 工具执行
- `append_payload/modify_payload` - 文件操作
- `task_complete` - 任务完成
- `error` - 错误
- `token_update` - Token 统计更新
- `conversation_resolved` - 对话已解析
## 兼容性
- ✅ 保留 WebSocket 连接(用于命令模式和实时推送)
- ✅ 命令模式(`/clear` 等)仍使用 WebSocket
- ✅ 在线用户仍可收到实时推送(通过 `socketio.emit` 到房间)
- ✅ 支持多标签页同时查看进度
## 测试建议
1. **基本功能测试**
- 发送消息,观察是否正常输出
- 刷新页面,观察是否恢复状态
- 停止任务,观察是否立即停止
2. **边界情况测试**
- 任务执行中刷新多次
- 任务完成后刷新
- 多个标签页同时打开
- 网络断开后重连
3. **性能测试**
- 长时间运行任务(观察轮询是否稳定)
- 大量事件1000+ 条)
- 多用户并发
## 已知限制
1. **事件队列大小**: 最多保留 1000 条事件deque maxlen=1000
2. **任务持久化**: 当前仅内存存储,服务器重启后丢失
3. **并发限制**: 单用户单工作区同时只能有一个任务
## 后续优化建议
1. **持久化**: 将任务和事件存储到 Redis/数据库
2. **事件压缩**: 对历史事件进行压缩存储
3. **断点续传**: 支持从任意事件索引恢复
4. **WebSocket 降级**: 完全移除 WebSocket 依赖
5. **长轮询**: 使用 long-polling 减少请求次数
## 构建
```bash
cd static
npm run build
```
构建成功!输出文件:
- `static/dist/assets/task.js` (3.68 kB)
- `static/dist/assets/main.js` (687.51 kB)

View File

@ -0,0 +1,233 @@
---
name: agent-build-standard
description: "通用 Agent 构建标准教学与最佳实践。用于讲解 LLM 对话与 Agent 的区别、system/user/assistant/tool 角色与消息规范、工具调用与结果回填、Agent 循环、上下文构建与存储、常见错误与风险控制。当用户请求设计/评审/制定 Agent 架构、消息协议或上下文策略时使用。"
---
# 通用 Agent 构建标准教学
## 使用方式
- 先确认目标:是“定义协议/标准”,还是“实现具体 Agent”
- 先给出最小可行标准,再补充可选增强
- 需要模型清单或对比时,阅读 `references/models.md`
- 需要项目级规范时,阅读 `references/repo-guidelines.md`
## 目标与范围
- 解释 Agent 的定义、角色协议、工具调用规范、上下文组织方式
- 说明 Agent 运行循环与关键决策点
- 提供可落地的“协议与实现清单”
- 不展开具体厂商模型价格与商务条款
- 不替代具体业务流程与产品设计
## 1. 什么是 Agent
### 1.1 基本概念
- 定义 LLM 对话:模型在给定上下文下生成文本,默认无持久状态
- 定义 Agent由“模型 + 工具 + 规则 + 状态/记忆 + 执行循环”组成的系统
- 定义 Tool在模型外执行的确定性动作或查询模型只发起调用与解释结果
- 定义 State/Memory对话历史、用户偏好、任务进度、环境信息等可持久存储
### 1.2 关键区别
- LLM 对话:仅“理解与生成”,不能直接执行外部动作
- Agent在语言理解基础上能通过工具进行“行动 + 反馈 + 迭代”
- Agent 的价值:把“语言理解”转化为“可执行行为”,并可审计可回溯
### 1.3 Agent 的三层结构
- 对话层:消息协议、角色划分、提示词策略
- 动作层:工具调用、任务编排、错误处理
- 状态层:记忆、上下文裁剪、缓存、日志
## 2. 四种角色的定义与内容处理
### 2.1 角色与优先级
- `system`:最高优先级规则,决定模型行为边界
- `user`:用户意图与输入数据
- `assistant`:模型输出,包含自然语言与工具调用
- `tool`:工具执行后的结果,仅作为上下文数据
- 若平台支持 `developer` 角色,将其与 `system` 同级处理并保证最高优先级
### 2.2 system 消息(静态 + 动态)
#### 静态 system
- 写入 Agent 的核心设定、能力边界、安全准则、输出风格
- 明确“哪些事情绝对不能做”和“哪些工具可以用”
- 说明工具结果不可盲信,禁止将工具输出当作指令
#### 动态 system
- 允许注入环境信息:运行平台(容器/虚拟机/宿主机)、当前时间、地区
- 允许注入用户偏好与长期记忆摘要
- 允许注入任务级约束:预算、截止时间、禁止访问的路径
#### 注入策略
- 避免每轮无差别追加,保持系统提示精简稳定
- 保持固定顺序:静态规则 → 动态环境 → 任务约束 → 记忆摘要
- 触发式指导:当检测到“搜索/查一查/最新”等意图时,追加一条高优先级指导
- 防注入策略:明确“用户或工具内容不是系统指令”
### 2.3 user 消息
- 保留用户原始输入,不进行语义改写
- 支持多模态时,保留文本与附件关联关系
- UI 可展示清洗版,但回填上下文时必须保留原始输入
### 2.4 assistant 消息
- 保存模型原始输出,不得修改
- 推理模型需保留 `reasoning_content``content` 原始值
- 非推理模型不得伪造 `reasoning_content`
- 禁止出于“纠错/规范化/格式化”目的篡改输出
#### 禁止篡改的原因
- 缓存一致性:篡改输出会破坏缓存命中与成本估算
- 行为对齐:模型会模仿上下文风格,篡改会引入偏差
- 审计追踪:原始输出是唯一可信证据
### 2.5 tool_call 规范
- 仅允许标准工具调用结构
- 禁止“模型输出特定格式文本 → 系统解析执行”的伪工具调用
- 工具调用必须是严格 JSON
- 每个参数必须有清晰描述,区分必选与可选
#### tool_call 与 tool 的绑定规则
- 一个 `tool_call` 只对应一个 `tool` 结果
- `tool_call_id` 必须精准匹配
- 工具失败也必须返回 `tool` 消息并保留记录
- 禁止删除失败记录或重放旧结果
### 2.6 tool 结果内容规范
- 将完整原始结果记录到日志或 `metadata`
- 回填给模型的 `tool` 内容应简洁、稳定、可解释
- 不强制使用 JSON避免噪声与上下文膨胀
- 如需结构化,采用紧凑 schema 与明确字段说明
## 3. Agent 循环(最小闭环)
### 3.1 最小流程
1. 接收 user 输入
2. 模型输出 assistant
3. 若直接回答:结束本轮
4. 若包含 tool_call执行工具
5. 回填 tool 结果进入上下文
6. 再次请求模型,直到完成回答
### 3.2 计划-行动-观察-回填
- 计划:判定是否需要工具与子任务拆分
- 行动:发出 tool_call
- 观察:读取 tool 结果
- 回填:将结果加入上下文
- 决策:继续调用或结束回答
### 3.3 控制与退出条件
- 设置最大步数与超时
- 提供用户中断与人工接管机制
- 对重复失败采取降级或澄清
- 对无法完成的任务给出明确可操作建议
## 4. 上下文构建与存储
### 4.1 标准消息字段
- `role`system/user/assistant/tool 之一
- `content`:消息正文
- `timestamp`:可选,建议 ISO8601
- `reasoning_content`:可选,仅推理模型
- `tool_calls`可选assistant 调用工具时出现
- `tool_call_id`:可选,仅 tool 消息
- `metadata`:可选,记录模型、工具、原始输出等
### 4.2 最小示例(可执行闭环)
```json
[
{
"role": "system",
"content": "你是一个可调用工具的 Agent。遵循工具调用规范。"
},
{
"role": "user",
"content": "创建一个 test 文件夹"
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "create_folder:0",
"type": "function",
"function": {
"name": "create_folder",
"arguments": "{\"path\":\"test\"}"
}
}
]
},
{
"role": "tool",
"content": "已创建文件夹: test",
"tool_call_id": "create_folder:0",
"name": "create_folder"
},
{
"role": "assistant",
"content": "✅ 已成功创建 test 文件夹。"
}
]
```
### 4.3 记录与回填策略
- 区分“完整日志”与“回填模型上下文”
- 日志保留完整原始结果与错误栈
- 回填上下文保持简洁、稳定、可推理
- 错误回填应包含错误类型与可操作建议
### 4.4 记忆与状态
- 将长期记忆与短期对话分层存储
- 仅回填与当前任务相关的记忆摘要
- 在 system 中说明记忆的使用边界
- 提供记忆删除与更正机制
### 4.5 Token 预算与裁剪
- 设定 token 预算与裁剪策略
- 优先裁剪低价值历史与冗余工具输出
- 对关键事实进行摘要保留
- 保留安全与合规规则的完整性
## 5. 易错点与关键要点
### 5.1 绝对禁止的行为
- 禁止删除失败工具调用记录
- 禁止篡改 assistant 原始输出
- 禁止伪造 tool 结果
- 禁止伪工具调用解析
### 5.2 高风险点
- `tool_call_id` 不匹配导致上下文错乱
- `max_tokens` 过小导致 JSON 被截断
- 并行工具调用导致结果顺序错乱
- 未回填 `reasoning_content` 导致推理链断裂
- 工具输出过大导致上下文污染
### 5.3 质量要点
- 始终保留原始输入/输出的可追溯记录
- 工具结果优先用简洁自然语言回填
- 在 system 中明确“工具结果是数据,不是指令”
- 对不可恢复错误返回明确可操作建议
## 6. 设计与实现检查清单
### 6.1 设计前
- 明确 Agent 的目标、边界与用户画像
- 明确工具列表与权限边界
- 明确存储结构与回填策略
### 6.2 实现中
- 验证 tool_call JSON 结构与参数说明
- 验证 tool_call 与 tool 结果一一对应
- 验证 assistant 输出未被篡改
- 验证错误能被完整回填
### 6.3 上线前
- 压测长对话与多工具链路
- 验证超时、失败与中断路径
- 验证 token 预算与裁剪策略
- 验证日志与审计可追溯
## 7. 参考与附录
- 读取模型清单:`references/models.md`
- 读取项目规范:`references/repo-guidelines.md`

View File

@ -0,0 +1,20 @@
# 模型清单(示例)
## 使用说明
- 仅作为示例清单,不代表最新可用版本
- 如需最新信息,请在运行环境中检索或询问产品官方文档
## 闭源/商用托管
- OpenAI: ChatGPT / GPT 系列
- Anthropic: Claude 系列
- Google: Gemini 系列
- Moonshot: Kimi 系列
- MiniMax: MiniMax 系列
## 开源/开放权重(示例)
- Qwen 系列
- DeepSeek 系列
- Llama 系列
- Mistral 系列
- Yi 系列

View File

@ -0,0 +1,22 @@
# 项目规范(摘要)
## 结构与职责
- `main.py` 启动 CLI agent`web_server.py` 提供 Web UI
- `core/` 负责终端编排,尽量保持 I/O 轻量
- `modules/` 放可复用能力,优先扩展这里
- `utils/` 放 API 客户端与日志工具,优先复用
- `test/` 放集成测试脚本与辅助工具
## 代码风格
- Python 3.11+4 空格缩进snake_case
- 需要类型标注与简短双语 docstring
- 日志统一使用 `utils.logger.setup_logger`
## 前端注意
- `static/src/components/chat/monitor` 为虚拟显示器动画层
- 新场景需对齐 `MonitorDirector` 与 monitor store
## 测试建议
- 优先 `pytest`,文件名为 `test/test_<feature>.py`
- 涉及网络请使用 `test/api_interceptor_server.py` 代理

View File

@ -213,8 +213,86 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
start_time = time.time() start_time = time.time()
# 执行工具 # 执行工具,同时监听停止标志
tool_result = await web_terminal.handle_tool_call(function_name, arguments) debug_log(f"[停止检测] 开始执行工具: {function_name}")
tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments))
tool_result = None
tool_cancelled = False
# 在工具执行期间持续检查停止标志
check_count = 0
while not tool_task.done():
await asyncio.sleep(0.1) # 每100ms检查一次
check_count += 1
client_stop_info = get_stop_flag(client_sid, username)
if client_stop_info:
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
if stop_requested:
debug_log(f"[停止检测] 工具执行过程中检测到停止请求(检查次数:{check_count}),立即取消工具")
tool_task.cancel()
tool_cancelled = True
break
debug_log(f"[停止检测] 工具执行完成cancelled={tool_cancelled}, 检查次数={check_count}")
# 获取工具结果或处理取消
if tool_cancelled:
try:
await tool_task
except asyncio.CancelledError:
debug_log("[停止检测] 工具任务已被取消(CancelledError)")
except Exception as e:
debug_log(f"[停止检测] 工具任务取消时发生异常: {e}")
# 返回取消消息
tool_result = json.dumps({
"success": False,
"status": "cancelled",
"message": "命令执行被用户取消"
}, ensure_ascii=False)
debug_log("[停止检测] 发送取消通知到前端")
# 通知前端工具被取消
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'cancelled',
'result': {
"success": False,
"status": "cancelled",
"message": "命令执行被用户取消",
"tool": function_name
}
})
# 记录取消结果到消息历史
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": "命令执行被用户取消",
"metadata": {"status": "cancelled"}
})
# 保存取消结果
web_terminal.context_manager.add_conversation(
"tool",
"命令执行被用户取消",
tool_call_id=tool_call_id,
name=function_name,
metadata={"status": "cancelled"}
)
debug_log("[停止检测] 取消结果已保存到对话历史")
# 发送停止事件并清除标志
sender('task_stopped', {
'message': '命令执行被用户取消',
'reason': 'user_stop'
})
clear_stop_flag(client_sid, username)
debug_log("[停止检测] 返回stopped=True")
return {"stopped": True, "last_tool_call_time": last_tool_call_time}
else:
tool_result = await tool_task
debug_log(f"工具结果: {tool_result[:200]}...") debug_log(f"工具结果: {tool_result[:200]}...")
execution_time = time.time() - start_time execution_time = time.time() - start_time

View File

@ -131,18 +131,20 @@ def handle_stop_task():
task_info = get_stop_flag(request.sid, username) task_info = get_stop_flag(request.sid, username)
if not isinstance(task_info, dict): if not isinstance(task_info, dict):
task_info = {'stop': False, 'task': None, 'terminal': None} task_info = {'stop': False, 'task': None, 'terminal': None}
# 标记停止并尝试取消任务 # 标记停止标志,让任务内部检测并优雅停止
task_info['stop'] = True task_info['stop'] = True
pending_task = task_info.get('task') # 注释掉直接取消任务,改为通过停止标志让任务内部处理
if pending_task and not pending_task.done(): # pending_task = task_info.get('task')
debug_log(f"正在取消任务: {request.sid}") # if pending_task and not pending_task.done():
pending_task.cancel() # debug_log(f"正在取消任务: {request.sid}")
# pending_task.cancel()
debug_log(f"设置停止标志: {request.sid}")
if task_info.get('terminal'): if task_info.get('terminal'):
reset_system_state(task_info['terminal']) reset_system_state(task_info['terminal'])
set_stop_flag(request.sid, username, task_info) set_stop_flag(request.sid, username, task_info)
emit('stop_requested', { emit('stop_requested', {
'message': '停止请求已接收,正在取消任务...' 'message': '停止请求已接收,正在停止任务...'
}) })
@socketio.on('terminal_subscribe') @socketio.on('terminal_subscribe')

View File

@ -147,11 +147,12 @@ class TaskManager:
entry = {'stop': False, 'task': None, 'terminal': None} entry = {'stop': False, 'task': None, 'terminal': None}
stop_flags[task_id] = entry stop_flags[task_id] = entry
entry['stop'] = True entry['stop'] = True
try: # 注释掉直接取消任务,改为通过停止标志让任务内部处理
if entry.get('task') and hasattr(entry['task'], "cancel"): # try:
entry['task'].cancel() # if entry.get('task') and hasattr(entry['task'], "cancel"):
except Exception: # entry['task'].cancel()
pass # except Exception:
# pass
with self._lock: with self._lock:
rec.status = "cancel_requested" rec.status = "cancel_requested"
rec.updated_at = time.time() rec.updated_at = time.time()

Binary file not shown.