diff --git a/BUG_FIX_CHANGELOG.md b/BUG_FIX_CHANGELOG.md new file mode 100644 index 0000000..4a69531 --- /dev/null +++ b/BUG_FIX_CHANGELOG.md @@ -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 测试功能。 diff --git a/BUG_FIX_V2_CHANGELOG.md b/BUG_FIX_V2_CHANGELOG.md new file mode 100644 index 0000000..1e1ee24 --- /dev/null +++ b/BUG_FIX_V2_CHANGELOG.md @@ -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(接近流式效果) +- ✅ 刷新后不重复显示内容 +- ✅ 任务完成后不重复加载历史 +- ✅ 状态检查避免重复恢复 + +现在可以启动服务器测试了! diff --git a/BUG_FIX_V3_CHANGELOG.md b/BUG_FIX_V3_CHANGELOG.md new file mode 100644 index 0000000..a156ef1 --- /dev/null +++ b/BUG_FIX_V3_CHANGELOG.md @@ -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 轮询) + +现在可以测试了!刷新页面应该只显示一次内容,新内容会正常追加。 diff --git a/BUG_FIX_V4_CHANGELOG.md b/BUG_FIX_V4_CHANGELOG.md new file mode 100644 index 0000000..3058f30 --- /dev/null +++ b/BUG_FIX_V4_CHANGELOG.md @@ -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 轮询) +- ✅ 不重复显示内容 + +现在可以测试了!刷新页面后,后端应该继续执行,前端会正常恢复并显示新内容。 diff --git a/POLLING_MODE_CHANGELOG.md b/POLLING_MODE_CHANGELOG.md new file mode 100644 index 0000000..20e955f --- /dev/null +++ b/POLLING_MODE_CHANGELOG.md @@ -0,0 +1,180 @@ +# 轮询模式改造完成 + +## 改造内容 + +已成功将前端从 WebSocket 实时推送模式改造为 REST API + 轮询模式。 + +### 主要变更 + +#### 1. 后端改造 +- **server/tasks.py**: 已有完整的任务管理系统 + - `POST /api/tasks` - 创建任务 + - `GET /api/tasks/?from=` - 轮询任务事件 + - `POST /api/tasks//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) diff --git a/agentskills/agent-build-standard/SKILL.md b/agentskills/agent-build-standard/SKILL.md new file mode 100644 index 0000000..a5bcf54 --- /dev/null +++ b/agentskills/agent-build-standard/SKILL.md @@ -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` + diff --git a/agentskills/agent-build-standard/references/models.md b/agentskills/agent-build-standard/references/models.md new file mode 100644 index 0000000..b687761 --- /dev/null +++ b/agentskills/agent-build-standard/references/models.md @@ -0,0 +1,20 @@ +# 模型清单(示例) + +## 使用说明 +- 仅作为示例清单,不代表最新可用版本 +- 如需最新信息,请在运行环境中检索或询问产品官方文档 + +## 闭源/商用托管 +- OpenAI: ChatGPT / GPT 系列 +- Anthropic: Claude 系列 +- Google: Gemini 系列 +- Moonshot: Kimi 系列 +- MiniMax: MiniMax 系列 + +## 开源/开放权重(示例) +- Qwen 系列 +- DeepSeek 系列 +- Llama 系列 +- Mistral 系列 +- Yi 系列 + diff --git a/agentskills/agent-build-standard/references/repo-guidelines.md b/agentskills/agent-build-standard/references/repo-guidelines.md new file mode 100644 index 0000000..2d0bdbd --- /dev/null +++ b/agentskills/agent-build-standard/references/repo-guidelines.md @@ -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_.py` +- 涉及网络请使用 `test/api_interceptor_server.py` 代理 + diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 1f61261..61989c7 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -213,9 +213,87 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie await asyncio.sleep(0.3) start_time = time.time() - # 执行工具 - tool_result = await web_terminal.handle_tool_call(function_name, arguments) - debug_log(f"工具结果: {tool_result[:200]}...") + # 执行工具,同时监听停止标志 + 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]}...") execution_time = time.time() - start_time if execution_time < 1.5: diff --git a/server/socket_handlers.py b/server/socket_handlers.py index 3418363..e667ff4 100644 --- a/server/socket_handlers.py +++ b/server/socket_handlers.py @@ -131,18 +131,20 @@ def handle_stop_task(): task_info = get_stop_flag(request.sid, username) if not isinstance(task_info, dict): task_info = {'stop': False, 'task': None, 'terminal': None} - # 标记停止并尝试取消任务 + # 标记停止标志,让任务内部检测并优雅停止 task_info['stop'] = True - pending_task = task_info.get('task') - if pending_task and not pending_task.done(): - debug_log(f"正在取消任务: {request.sid}") - pending_task.cancel() + # 注释掉直接取消任务,改为通过停止标志让任务内部处理 + # pending_task = task_info.get('task') + # if pending_task and not pending_task.done(): + # debug_log(f"正在取消任务: {request.sid}") + # pending_task.cancel() + debug_log(f"设置停止标志: {request.sid}") if task_info.get('terminal'): reset_system_state(task_info['terminal']) set_stop_flag(request.sid, username, task_info) - + emit('stop_requested', { - 'message': '停止请求已接收,正在取消任务...' + 'message': '停止请求已接收,正在停止任务...' }) @socketio.on('terminal_subscribe') diff --git a/server/tasks.py b/server/tasks.py index 3b06ca6..3d8f074 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -147,11 +147,12 @@ class TaskManager: entry = {'stop': False, 'task': None, 'terminal': None} stop_flags[task_id] = entry entry['stop'] = True - try: - if entry.get('task') and hasattr(entry['task'], "cancel"): - entry['task'].cancel() - except Exception: - pass + # 注释掉直接取消任务,改为通过停止标志让任务内部处理 + # try: + # if entry.get('task') and hasattr(entry['task'], "cancel"): + # entry['task'].cancel() + # except Exception: + # pass with self._lock: rec.status = "cancel_requested" rec.updated_at = time.time() diff --git a/static/icons/演示文稿1 [自动保存].pptx b/static/icons/演示文稿1 [自动保存].pptx new file mode 100644 index 0000000..f23a785 Binary files /dev/null and b/static/icons/演示文稿1 [自动保存].pptx differ