Compare commits

...

2 Commits

Author SHA1 Message Date
87ceaad92b feat: improve ui feedback 2025-11-30 00:09:05 +08:00
09654b7d4b fix: stabilize conversation loading 2025-11-29 23:13:11 +08:00
11 changed files with 609 additions and 122 deletions

228
CLAUDE.md Normal file
View File

@ -0,0 +1,228 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
多用户 AI Agent 系统,为每个登录用户提供独立的 Docker 容器环境。支持终端交互、文件操作、对话管理和实时监控。主要用于学习和实验"智能体 + 真实 Dev Workflow",代码大量由 AI 生成。
## Core Architecture
### Entry Points
- **CLI模式**: `python main.py` - 启动命令行交互式 Agent
- **Web模式**: `python web_server.py` - 启动 Flask + Socket.IO Web 服务器 (默认端口 8091)
### Key Components
**core/**
- `MainTerminal` - CLI 主终端,处理用户输入和 AI 对话循环
- `WebTerminal` - Web 终端,继承 MainTerminal增加实时推送和对话持久化
- `tool_config.py` - 定义所有工具的配置和分类
**modules/**
- `user_container_manager.py` - 单用户-单容器管理,负责容器生命周期
- `container_file_proxy.py` - 容器内文件代理,通过 `docker exec` 沙箱化文件操作
- `terminal_manager.py` - 管理多个持久化终端会话
- `file_manager.py` - 文件 CRUD包含搜索、替换和 append/modify streaming 功能
- `conversation_manager.py` - 对话历史持久化
- `context_manager.py` - 上下文构建、token统计、对话管理
- `memory_manager.py` - 长期记忆管理
- `todo_manager.py` - 待办事项管理
- `sub_agent_manager.py` - 子智能体任务调度
- `user_manager.py` - 用户认证和工作区管理
- `upload_security.py` - 上传文件隔离扫描
**utils/**
- `api_client.py` - 与 OpenAI-compatible API 交互,支持 thinking mode首次思考后续快速
- `terminal_factory.py` - 终端类型工厂
**static/**
- Vue + Socket.IO 前端,包含对话流、终端输出、资源监控面板
**docker/**
- `terminal.Dockerfile` - 终端容器镜像构建配置
## Development Commands
### Build & Run
```bash
# 安装依赖(需要在项目根目录创建 requirements.txt
pip install flask flask-socketio flask-cors openai python-dotenv tiktoken lxml
# 构建终端镜像
docker build -f docker/terminal.Dockerfile -t my-agent-shell:latest .
# CLI 模式
python main.py
# Web 模式
python web_server.py
# 或指定端口和思考模式
python web_server.py --port 8091 --thinking-mode
```
### Testing & Debugging
```bash
# 查看容器状态
docker ps -a | grep agent-term
# 查看调试日志
tail -f logs/debug_stream.log
# 查看容器统计
tail -f logs/container_stats.log
```
## Configuration
主要配置文件:`.env`(复制 `.env.example`
**必填变量**:
- `AGENT_API_BASE_URL` - OpenAI-compatible API 端点
- `AGENT_API_KEY` - API 密钥
- `AGENT_MODEL_ID` - 模型 ID (例如 deepseek-chat)
- `WEB_SECRET_KEY` - Flask session 密钥
**容器配置** (`config/terminal.py`):
- `TERMINAL_SANDBOX_MODE` - docker/host
- `TERMINAL_SANDBOX_IMAGE` - 容器镜像 (默认 python:3.11-slim)
- `TERMINAL_SANDBOX_NETWORK` - 网络模式 (bridge/none/host)
- `TERMINAL_SANDBOX_CPUS` - CPU 限制
- `TERMINAL_SANDBOX_MEMORY` - 内存限制
**资源限制**:
- `PROJECT_MAX_STORAGE_MB` - 单用户磁盘配额 (默认 2048MB)
- `MAX_ACTIVE_USER_CONTAINERS` - 并发容器数量 (默认 8)
## Important Implementation Details
### File Operations with Streaming
**append_to_file** 和 **modify_file** 使用特殊的流式输出格式:
```
<<<APPEND:path/to/file>>>
文件内容...
<<<END_APPEND>>>
<<<MODIFY:path/to/file>>>
[replace:1]
<<OLD>>原文内容<<END>>
<<NEW>>新内容<<END>>
[/replace]
<<<END_MODIFY>>>
```
处理逻辑在 `web_server.py``handle_task_with_sender` 中,通过检测标记并在流式输出中即时执行。
### Container Architecture
每个用户登录时:
1. `UserContainerManager` 创建专属容器
2. 容器挂载 `users/<username>/project/``/workspace`
3. 所有文件操作通过 `ContainerFileProxy` 在容器内执行
4. 终端会话通过 `docker exec` 运行
5. 空闲超时或登出后自动销毁容器
### Context Management
`ContextManager` 负责:
- 动态构建上下文(系统提示 + 工具 + 对话历史 + 聚焦文件 + 记忆)
- Token 统计和限制检查
- 对话历史保存和加载
- 自动保存机制(每次 API 调用后增量保存)
### Thinking Mode
支持"思考模式"(需配置 `AGENT_THINKING_MODEL_ID`
- 首次调用使用 reasoning 模型(如 deepseek-reasoner
- 后续调用使用快速模型
- 可通过 `THINKING_FAST_INTERVAL` 控制切换频率
- 失败时自动回退到思考模式
### Web Socket Events
主要事件:
- `send_message` - 发送用户消息
- `thinking_start/chunk/end` - 思考过程
- `text_start/chunk/end` - 文本响应
- `tool_preparing/start/update_action` - 工具执行状态
- `conversation_list_update` - 对话列表更新
- `terminal_output` - 终端输出
## Common Patterns
### Adding New Tools
1. 在 `MainTerminal` 添加 `handle_<tool_name>` 方法
2. 在 `core/tool_config.py``define_tools()` 中注册工具
3. 如果需要在流式输出中处理,在 `web_server.py` 添加特殊逻辑
### Working with Containers
使用 `ContainerHandle`:
```python
# 文件操作
result = container_handle.execute_file_op("read", path="/workspace/file.txt")
# 执行命令
result = container_handle.run_command("ls -la /workspace")
# 终端会话
result = container_handle.start_terminal(session_name="main", working_dir="/workspace")
```
### Managing Conversations
```python
# 创建新对话
terminal.create_new_conversation(thinking_mode=True)
# 加载对话
terminal.load_conversation(conversation_id)
# 搜索对话
terminal.search_conversations(query="bug fix")
# 删除对话
terminal.delete_conversation(conversation_id)
```
## Security Considerations
- 所有用户操作在独立容器中执行,与宿主机隔离
- 文件操作路径验证在 `FileManager._validate_path`
- 上传文件经过 `UploadQuarantineManager` 隔离和扫描
- CSRF 保护在 Web 端启用
- 登录失败次数限制和临时锁定
## Known Limitations
- 代码大量由 AI 生成,未达到生产级别
- 容器资源配额依赖 Docker cgroups
- 前端处于改造中,部分功能可能不稳定
- Token 统计基于 tiktoken可能与实际 API 有偏差
## Directory Structure
```
agents/
├── core/ # 核心终端和工具配置
├── modules/ # 独立功能模块
├── utils/ # 辅助工具
├── config/ # 配置文件
├── static/ # Web 前端
├── docker/ # 容器镜像
├── prompts/ # 系统提示词
├── users/ # 用户工作区
├── data/ # 全局数据
└── logs/ # 日志文件
```
## Development Tips
- 修改系统提示词:编辑 `prompts/main_system.txt`
- 调整工具限制:修改 `config/limits.py`
- 容器镜像定制:编辑 `docker/terminal.Dockerfile` 并重新构建
- 前端开发:`npm install && npm run dev` (在 `static/` 目录)
- 查看详细执行流程:监控 `logs/debug_stream.log`

View File

@ -69,6 +69,7 @@
@toggle-panel-menu="togglePanelMenu"
@select-panel="selectPanelMode"
@open-file-manager="openGuiFileManager"
@toggle-thinking-mode="handleQuickModeToggle"
/>
<div

View File

@ -96,6 +96,14 @@ if (window.visualViewport) {
window.visualViewport.addEventListener('scroll', updateViewportHeightVar);
}
const ENABLE_APP_DEBUG_LOGS = false;
function debugLog(...args) {
if (!ENABLE_APP_DEBUG_LOGS) {
return;
}
debugLog(...args);
}
const appOptions = {
data() {
return {
@ -149,7 +157,7 @@ const appOptions = {
},
async mounted() {
console.log('Vue应用已挂载');
debugLog('Vue应用已挂载');
if (window.ensureCsrfToken) {
window.ensureCsrfToken().catch((err) => {
console.warn('CSRF token 初始化失败:', err);
@ -289,7 +297,7 @@ const appOptions = {
currentConversationId: {
immediate: false,
handler(newValue, oldValue) {
console.log('currentConversationId 变化', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload });
debugLog('currentConversationId 变化', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload });
this.logMessageState('watch:currentConversationId', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload });
if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) {
return;
@ -593,7 +601,7 @@ const appOptions = {
},
logMessageState(action, extra = {}) {
const count = Array.isArray(this.messages) ? this.messages.length : 'N/A';
console.log('[Messages]', {
debugLog('[Messages]', {
action,
count,
conversationId: this.currentConversationId,
@ -913,9 +921,49 @@ const appOptions = {
this.toolActionIndex.clear();
},
hasPendingToolActions() {
const mapHasEntries = map => map && typeof map.size === 'number' && map.size > 0;
if (mapHasEntries(this.preparingTools) || mapHasEntries(this.activeTools)) {
return true;
}
if (!Array.isArray(this.messages)) {
return false;
}
return this.messages.some(msg => {
if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.actions)) {
return false;
}
return msg.actions.some(action => {
if (!action || action.type !== 'tool' || !action.tool) {
return false;
}
if (action.tool.awaiting_content) {
return true;
}
const status = typeof action.tool.status === 'string'
? action.tool.status.toLowerCase()
: '';
return !status || ['preparing', 'running', 'pending', 'queued'].includes(status);
});
});
},
maybeResetStreamingState(reason = 'unspecified') {
if (!this.streamingMessage) {
return false;
}
if (this.hasPendingToolActions()) {
return false;
}
this.streamingMessage = false;
this.stopRequested = false;
debugLog('流式状态已结束', { reason });
return true;
},
// 完整重置所有状态
resetAllStates(reason = 'unspecified') {
console.log('重置所有前端状态', { reason, conversationId: this.currentConversationId });
debugLog('重置所有前端状态', { reason, conversationId: this.currentConversationId });
this.logMessageState('resetAllStates:before-cleanup', { reason });
this.fileHideContextMenu();
@ -960,7 +1008,7 @@ const appOptions = {
this.toolSetSettingsLoading(false);
this.toolSetSettings([]);
console.log('前端状态重置完成');
debugLog('前端状态重置完成');
this._scrollListenerReady = false;
this.$nextTick(() => {
this.ensureScrollListener();
@ -983,7 +1031,7 @@ const appOptions = {
async loadInitialData() {
try {
console.log('加载初始数据...');
debugLog('加载初始数据...');
await this.fileFetchTree();
await this.focusFetchFiles();
@ -1003,26 +1051,28 @@ const appOptions = {
if (!this.currentConversationId) {
this.skipConversationHistoryReload = true;
this.currentConversationId = statusConversationId;
}
// 如果有当前对话尝试获取标题和Token统计
try {
const convResponse = await fetch(`/api/conversations/current`);
const convData = await convResponse.json();
if (convData.success && convData.data) {
this.currentConversationTitle = convData.data.title;
// 如果有当前对话,尝试获取标题和历史
try {
const convResponse = await fetch(`/api/conversations/current`);
const convData = await convResponse.json();
if (convData.success && convData.data) {
this.currentConversationTitle = convData.data.title;
}
// 初始化时调用一次,因为 skipConversationHistoryReload 会阻止 watch 触发
await this.fetchAndDisplayHistory();
// 获取当前对话的Token统计
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
} catch (e) {
console.warn('获取当前对话标题失败:', e);
}
await this.fetchAndDisplayHistory();
// 获取当前对话的Token统计
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
} catch (e) {
console.warn('获取当前对话标题失败:', e);
}
}
await this.loadToolSettings(true);
console.log('初始数据加载完成');
debugLog('初始数据加载完成');
} catch (error) {
console.error('加载初始数据失败:', error);
}
@ -1108,7 +1158,7 @@ const appOptions = {
this.promoteConversationToTop(this.currentConversationId);
}
this.hasMoreConversations = data.data.has_more;
console.log(`已加载 ${this.conversations.length} 个对话`);
debugLog(`已加载 ${this.conversations.length} 个对话`);
if (this.conversationsOffset === 0 && !this.currentConversationId && this.conversations.length > 0) {
const latestConversation = this.conversations[0];
@ -1136,11 +1186,11 @@ const appOptions = {
},
async loadConversation(conversationId) {
console.log('加载对话:', conversationId);
debugLog('加载对话:', conversationId);
this.logMessageState('loadConversation:start', { conversationId });
if (conversationId === this.currentConversationId) {
console.log('已是当前对话,跳过加载');
debugLog('已是当前对话,跳过加载');
return;
}
@ -1152,7 +1202,7 @@ const appOptions = {
const result = await response.json();
if (result.success) {
console.log('对话加载API成功:', result);
debugLog('对话加载API成功:', result);
// 2. 更新当前对话信息
this.skipConversationHistoryReload = true;
@ -1166,17 +1216,13 @@ const appOptions = {
this.resetAllStates(`loadConversation:${conversationId}`);
this.subAgentFetch();
this.fetchTodoList();
// 4. 延迟获取并显示历史对话内容(关键功能)
setTimeout(() => {
this.fetchAndDisplayHistory();
}, 300);
// 5. 获取Token统计重点加载历史累计统计+当前上下文)
setTimeout(() => {
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
}, 500);
// 4. 历史对话内容和Token统计由后端的 conversation_loaded 事件触发
// 不在此处重复调用,避免双重加载
// Socket.IO 的 conversation_loaded 事件会处理:
// - fetchAndDisplayHistory()
// - fetchConversationTokenStatistics()
// - updateCurrentContextTokens()
} else {
console.error('对话加载失败:', result.message);
@ -1212,16 +1258,16 @@ const appOptions = {
// ==========================================
async fetchAndDisplayHistory() {
if (this.historyLoading) {
console.log('历史消息正在加载,跳过重复请求');
debugLog('历史消息正在加载,跳过重复请求');
return;
}
this.historyLoading = true;
try {
console.log('开始获取历史对话内容...');
debugLog('开始获取历史对话内容...');
this.logMessageState('fetchAndDisplayHistory:start', { conversationId: this.currentConversationId });
if (!this.currentConversationId || this.currentConversationId.startsWith('temp_')) {
console.log('没有当前对话ID跳过历史加载');
debugLog('没有当前对话ID跳过历史加载');
return;
}
@ -1234,7 +1280,7 @@ const appOptions = {
// 备用方案通过状态API获取
const statusResponse = await fetch('/api/status');
const status = await statusResponse.json();
console.log('系统状态:', status);
debugLog('系统状态:', status);
this.applyStatusSnapshot(status);
// 如果状态中有对话历史字段
@ -1243,16 +1289,16 @@ const appOptions = {
return;
}
console.log('备用方案也无法获取历史消息');
debugLog('备用方案也无法获取历史消息');
return;
}
const messagesData = await messagesResponse.json();
console.log('获取到消息数据:', messagesData);
debugLog('获取到消息数据:', messagesData);
if (messagesData.success && messagesData.data && messagesData.data.messages) {
const messages = messagesData.data.messages;
console.log(`发现 ${messages.length} 条历史消息`);
debugLog(`发现 ${messages.length} 条历史消息`);
if (messages.length > 0) {
// 清空当前显示的消息
@ -1268,15 +1314,15 @@ const appOptions = {
this.scrollToBottom();
});
console.log('历史对话内容显示完成');
debugLog('历史对话内容显示完成');
} else {
console.log('对话存在但没有历史消息');
debugLog('对话存在但没有历史消息');
this.logMessageState('fetchAndDisplayHistory:no-history-clear');
this.messages = [];
this.logMessageState('fetchAndDisplayHistory:no-history-cleared');
}
} else {
console.log('消息数据格式不正确:', messagesData);
debugLog('消息数据格式不正确:', messagesData);
this.logMessageState('fetchAndDisplayHistory:invalid-data-clear');
this.messages = [];
this.logMessageState('fetchAndDisplayHistory:invalid-data-cleared');
@ -1284,7 +1330,7 @@ const appOptions = {
} catch (error) {
console.error('获取历史对话失败:', error);
console.log('尝试不显示错误弹窗,仅在控制台记录');
debugLog('尝试不显示错误弹窗,仅在控制台记录');
// 不显示alert避免打断用户体验
this.logMessageState('fetchAndDisplayHistory:error-clear', { error: error?.message || String(error) });
this.messages = [];
@ -1299,8 +1345,8 @@ const appOptions = {
// 关键功能:渲染历史消息
// ==========================================
renderHistoryMessages(historyMessages) {
console.log('开始渲染历史消息...', historyMessages);
console.log('历史消息数量:', historyMessages.length);
debugLog('开始渲染历史消息...', historyMessages);
debugLog('历史消息数量:', historyMessages.length);
this.logMessageState('renderHistoryMessages:start', { historyCount: historyMessages.length });
if (!Array.isArray(historyMessages)) {
@ -1311,7 +1357,7 @@ const appOptions = {
let currentAssistantMessage = null;
historyMessages.forEach((message, index) => {
console.log(`处理消息 ${index + 1}/${historyMessages.length}:`, message.role, message);
debugLog(`处理消息 ${index + 1}/${historyMessages.length}:`, message.role, message);
if (message.role === 'user') {
// 用户消息 - 先结束之前的assistant消息
@ -1324,7 +1370,7 @@ const appOptions = {
role: 'user',
content: message.content || ''
});
console.log('添加用户消息:', message.content?.substring(0, 50) + '...');
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
} else if (message.role === 'assistant') {
// AI消息 - 如果没有当前assistant消息创建一个
@ -1335,7 +1381,9 @@ const appOptions = {
streamingThinking: '',
streamingText: '',
currentStreamingType: null,
activeThinkingId: null
activeThinkingId: null,
awaitingFirstContent: false,
generatingLabel: ''
};
}
@ -1368,7 +1416,7 @@ const appOptions = {
timestamp: Date.now(),
blockId
});
console.log('添加思考内容:', reasoningText.substring(0, 50) + '...');
debugLog('添加思考内容:', reasoningText.substring(0, 50) + '...');
}
// 处理普通文本内容(移除思考标签后的内容)
@ -1402,7 +1450,7 @@ const appOptions = {
},
timestamp: Date.now()
});
console.log('添加append占位信息:', appendPayloadMeta.path);
debugLog('添加append占位信息:', appendPayloadMeta.path);
} else if (modifyPayloadMeta) {
currentAssistantMessage.actions.push({
id: `history-modify-payload-${Date.now()}-${Math.random()}`,
@ -1417,7 +1465,7 @@ const appOptions = {
},
timestamp: Date.now()
});
console.log('添加modify占位信息:', modifyPayloadMeta.path);
debugLog('添加modify占位信息:', modifyPayloadMeta.path);
}
if (textContent && !appendPayloadMeta && !modifyPayloadMeta && !isAppendMessage && !isModifyMessage && !containsAppendMarkers) {
@ -1428,7 +1476,7 @@ const appOptions = {
streaming: false,
timestamp: Date.now()
});
console.log('添加文本内容:', textContent.substring(0, 50) + '...');
debugLog('添加文本内容:', textContent.substring(0, 50) + '...');
}
// 处理工具调用
@ -1456,7 +1504,7 @@ const appOptions = {
},
timestamp: Date.now()
});
console.log('添加工具调用:', toolCall.function.name);
debugLog('添加工具调用:', toolCall.function.name);
});
}
@ -1509,7 +1557,7 @@ const appOptions = {
if (message.name === 'append_to_file' && result && result.message) {
toolAction.tool.message = result.message;
}
console.log(`更新工具结果: ${message.name} -> ${message.content?.substring(0, 50)}...`);
debugLog(`更新工具结果: ${message.name} -> ${message.content?.substring(0, 50)}...`);
// append_to_file 的摘要在 append_payload 占位中呈现,此处无需重复
} else {
@ -1524,7 +1572,7 @@ const appOptions = {
currentAssistantMessage = null;
}
console.log('处理其他类型消息:', message.role);
debugLog('处理其他类型消息:', message.role);
this.messages.push({
role: message.role,
content: message.content || ''
@ -1537,7 +1585,7 @@ const appOptions = {
this.messages.push(currentAssistantMessage);
}
console.log(`历史消息渲染完成,共 ${this.messages.length} 条消息`);
debugLog(`历史消息渲染完成,共 ${this.messages.length} 条消息`);
this.logMessageState('renderHistoryMessages:after-render');
// 强制更新视图
@ -1550,7 +1598,7 @@ const appOptions = {
const blockCount = this.$el && this.$el.querySelectorAll
? this.$el.querySelectorAll('.message-block').length
: 'N/A';
console.log('[Messages] DOM 渲染统计', {
debugLog('[Messages] DOM 渲染统计', {
blocks: blockCount,
conversationId: this.currentConversationId
});
@ -1559,7 +1607,7 @@ const appOptions = {
},
async createNewConversation() {
console.log('创建新对话...');
debugLog('创建新对话...');
this.logMessageState('createNewConversation:start');
try {
@ -1576,7 +1624,7 @@ const appOptions = {
const result = await response.json();
if (result.success) {
console.log('新对话创建成功:', result.conversation_id);
debugLog('新对话创建成功:', result.conversation_id);
// 清空当前消息
this.logMessageState('createNewConversation:before-clear');
@ -1625,7 +1673,7 @@ const appOptions = {
return;
}
console.log('删除对话:', conversationId);
debugLog('删除对话:', conversationId);
this.logMessageState('deleteConversation:start', { conversationId });
try {
@ -1636,7 +1684,7 @@ const appOptions = {
const result = await response.json();
if (result.success) {
console.log('对话删除成功');
debugLog('对话删除成功');
// 如果删除的是当前对话,清空界面
if (conversationId === this.currentConversationId) {
@ -1673,7 +1721,7 @@ const appOptions = {
},
async duplicateConversation(conversationId) {
console.log('复制对话:', conversationId);
debugLog('复制对话:', conversationId);
try {
const response = await fetch(`/api/conversations/${conversationId}/duplicate`, {
method: 'POST'
@ -1715,7 +1763,7 @@ const appOptions = {
this.searchTimer = setTimeout(() => {
if (this.searchQuery.trim()) {
console.log('搜索对话:', this.searchQuery);
debugLog('搜索对话:', this.searchQuery);
// TODO: 实现搜索API调用
// this.searchConversationsAPI(this.searchQuery);
} else {
@ -1865,7 +1913,7 @@ const appOptions = {
if (this.streamingMessage && !this.stopRequested) {
this.socket.emit('stop_task');
this.stopRequested = true;
console.log('发送停止请求');
debugLog('发送停止请求');
}
},
@ -1919,7 +1967,7 @@ const appOptions = {
if (newId) {
this.currentConversationId = newId;
}
console.log('对话压缩完成:', result);
debugLog('对话压缩完成:', result);
} else {
const message = result.message || result.error || '压缩失败';
this.uiPushToast({
@ -2082,7 +2130,7 @@ const appOptions = {
enabled: !!item.enabled,
tools: Array.isArray(item.tools) ? item.tools : []
}));
console.log('[ToolSettings] Snapshot applied', {
debugLog('[ToolSettings] Snapshot applied', {
received: categories.length,
normalized,
anyEnabled: normalized.some(cat => cat.enabled),
@ -2094,23 +2142,23 @@ const appOptions = {
async loadToolSettings(force = false) {
if (!this.isConnected && !force) {
console.log('[ToolSettings] Skip load: disconnected & not forced');
debugLog('[ToolSettings] Skip load: disconnected & not forced');
return;
}
if (this.toolSettingsLoading) {
console.log('[ToolSettings] Skip load: already loading');
debugLog('[ToolSettings] Skip load: already loading');
return;
}
if (!force && this.toolSettings.length > 0) {
console.log('[ToolSettings] Skip load: already have settings');
debugLog('[ToolSettings] Skip load: already have settings');
return;
}
console.log('[ToolSettings] Fetch start', { force, hasConnection: this.isConnected });
debugLog('[ToolSettings] Fetch start', { force, hasConnection: this.isConnected });
this.toolSetSettingsLoading(true);
try {
const response = await fetch('/api/tool-settings');
const data = await response.json();
console.log('[ToolSettings] Fetch response', { status: response.status, data });
debugLog('[ToolSettings] Fetch response', { status: response.status, data });
if (response.ok && data.success && Array.isArray(data.categories)) {
this.applyToolSettingsSnapshot(data.categories);
} else {

View File

@ -14,6 +14,27 @@
<span class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span>
<span>AI Assistant</span>
</div>
<div
v-if="msg.awaitingFirstContent"
class="action-item streaming-content immediate-show assistant-generating-block"
>
<div class="text-output">
<div
class="text-content assistant-generating-placeholder"
role="status"
aria-live="polite"
>
<span
v-for="(letter, letterIndex) in getGeneratingLetters(msg)"
:key="letterIndex"
class="assistant-generating-letter"
:style="{ animationDelay: `${letterIndex * 0.08}s` }"
>
{{ letter }}
</span>
</div>
</div>
</div>
<div
v-for="(action, actionIndex) in msg.actions || []"
:key="action.id || `${index}-${actionIndex}`"
@ -181,6 +202,7 @@ const props = defineProps<{
formatSearchTime: (filters: Record<string, any>) => string;
}>();
const DEFAULT_GENERATING_TEXT = '生成中…';
const rootEl = ref<HTMLElement | null>(null);
const thinkingRefs = new Map<string, HTMLElement | null>();
@ -203,6 +225,14 @@ function iconStyleSafe(key: string, size?: string) {
return {};
}
function getGeneratingLetters(message: any) {
const label =
typeof message?.generatingLabel === 'string' && message.generatingLabel.trim()
? message.generatingLabel.trim()
: DEFAULT_GENERATING_TEXT;
return Array.from(label);
}
defineExpose({
rootEl,
getThinkingRef

View File

@ -11,7 +11,13 @@
</div>
</div>
<div class="status-indicators">
<span class="mode-indicator" :class="{ thinking: thinkingMode, fast: !thinkingMode }">
<button
type="button"
class="mode-indicator"
:class="{ thinking: thinkingMode, fast: !thinkingMode }"
:title="thinkingMode ? '思考模式(点击切换)' : '快速模式(点击切换)'"
@click="$emit('toggle-thinking-mode')"
>
<transition name="mode-icon" mode="out-in">
<span
class="icon icon-sm"
@ -20,7 +26,7 @@
aria-hidden="true"
></span>
</transition>
</span>
</button>
<span class="connection-dot" :class="{ active: isConnected }" :title="isConnected ? '已连接' : '未连接'"></span>
</div>
</div>
@ -145,6 +151,7 @@ defineEmits<{
(event: 'toggle-panel-menu'): void;
(event: 'select-panel', mode: 'files' | 'todo' | 'subAgents'): void;
(event: 'open-file-manager'): void;
(event: 'toggle-thinking-mode'): void;
}>();
const panelMenuWrapper = ref<HTMLElement | null>(null);

View File

@ -4,7 +4,15 @@ import { renderLatexInRealtime } from './useMarkdownRenderer';
export async function initializeLegacySocket(ctx: any) {
try {
console.log('初始化WebSocket连接...');
const SOCKET_DEBUG_LOGS_ENABLED = false;
const socketLog = (...args: any[]) => {
if (!SOCKET_DEBUG_LOGS_ENABLED) {
return;
}
console.log(...args);
};
socketLog('初始化WebSocket连接...');
const socketOptions = {
transports: ['websocket', 'polling'],
@ -15,7 +23,7 @@ export async function initializeLegacySocket(ctx: any) {
const STREAMING_CHAR_DELAY = 22;
const STREAMING_FINALIZE_DELAY = 1000;
const STREAMING_DEBUG = true;
const STREAMING_DEBUG = false;
const STREAMING_DEBUG_HISTORY_LIMIT = 2000;
const streamingState = {
buffer: [] as string[],
@ -169,6 +177,35 @@ export async function initializeLegacySocket(ctx: any) {
}
};
const markStreamingIdleIfPossible = (source: string) => {
try {
if (!ctx) {
return;
}
if (typeof ctx.maybeResetStreamingState === 'function') {
const reset = ctx.maybeResetStreamingState(source);
if (reset) {
logStreamingDebug('streaming_idle_reset', { source });
}
return;
}
if (!ctx.streamingMessage) {
return;
}
const hasPending =
typeof ctx.hasPendingToolActions === 'function'
? ctx.hasPendingToolActions()
: false;
if (!hasPending) {
ctx.streamingMessage = false;
ctx.stopRequested = false;
logStreamingDebug('streaming_idle_reset:fallback', { source });
}
} catch (error) {
console.warn('自动结束流式状态失败:', error);
}
};
const stopStreamingTimer = () => {
if (streamingState.timer !== null) {
clearTimeout(streamingState.timer);
@ -242,10 +279,12 @@ export async function initializeLegacySocket(ctx: any) {
ctx.chatCompleteTextAction(finalText || '');
completeActiveTextAction(finalText || '');
ctx.$forceUpdate();
ctx.streamingMessage = false;
// 注意:不在这里重置 streamingMessage因为可能还有工具调用在进行
// streamingMessage 只在 task_complete 事件时重置
logStreamingDebug('finalizeStreamingText:complete', snapshotStreamingState());
streamingState.activeMessageIndex = null;
streamingState.activeTextAction = null;
markStreamingIdleIfPossible('finalizeStreamingText');
return true;
};
@ -400,7 +439,7 @@ export async function initializeLegacySocket(ctx: any) {
// 连接事件
ctx.socket.on('connect', () => {
ctx.isConnected = true;
console.log('WebSocket已连接');
socketLog('WebSocket已连接');
// 连接时重置所有状态并刷新当前对话
ctx.resetAllStates('socket:connect');
scheduleHistoryReload(200);
@ -408,7 +447,7 @@ export async function initializeLegacySocket(ctx: any) {
ctx.socket.on('disconnect', () => {
ctx.isConnected = false;
console.log('WebSocket已断开');
socketLog('WebSocket已断开');
// 断线时也重置状态,防止状态混乱
ctx.resetAllStates('socket:disconnect');
});
@ -451,7 +490,7 @@ export async function initializeLegacySocket(ctx: any) {
// ==========================================
ctx.socket.on('token_update', (data) => {
console.log('收到token更新事件:', data);
socketLog('收到token更新事件:', data);
// 只处理当前对话的token更新
if (data.conversation_id === ctx.currentConversationId) {
@ -460,7 +499,7 @@ export async function initializeLegacySocket(ctx: any) {
ctx.currentConversationTokens.cumulative_output_tokens = data.cumulative_output_tokens || 0;
ctx.currentConversationTokens.cumulative_total_tokens = data.cumulative_total_tokens || 0;
console.log(`累计Token统计更新: 输入=${data.cumulative_input_tokens}, 输出=${data.cumulative_output_tokens}, 总计=${data.cumulative_total_tokens}`);
socketLog(`累计Token统计更新: 输入=${data.cumulative_input_tokens}, 输出=${data.cumulative_output_tokens}, 总计=${data.cumulative_total_tokens}`);
const hasContextTokens = typeof data.current_context_tokens === 'number';
if (hasContextTokens && typeof ctx.resourceSetCurrentContextTokens === 'function') {
@ -475,7 +514,7 @@ export async function initializeLegacySocket(ctx: any) {
});
ctx.socket.on('todo_updated', (data) => {
console.log('收到todo更新事件:', data);
socketLog('收到todo更新事件:', data);
if (data && data.conversation_id) {
ctx.currentConversationId = data.conversation_id;
}
@ -487,14 +526,14 @@ export async function initializeLegacySocket(ctx: any) {
ctx.projectPath = data.project_path || '';
ctx.agentVersion = data.version || ctx.agentVersion;
ctx.thinkingMode = !!data.thinking_mode;
console.log('系统就绪:', data);
socketLog('系统就绪:', data);
// 系统就绪后立即加载对话列表
ctx.loadConversationsList();
});
ctx.socket.on('tool_settings_updated', (data) => {
console.log('收到工具设置更新:', data);
socketLog('收到工具设置更新:', data);
if (data && Array.isArray(data.categories)) {
ctx.applyToolSettingsSnapshot(data.categories);
}
@ -506,7 +545,7 @@ export async function initializeLegacySocket(ctx: any) {
// 监听对话变更事件
ctx.socket.on('conversation_changed', (data) => {
console.log('对话已切换:', data);
socketLog('对话已切换:', data);
ctx.currentConversationId = data.conversation_id;
ctx.currentConversationTitle = data.title || '';
ctx.promoteConversationToTop(data.conversation_id);
@ -550,10 +589,10 @@ export async function initializeLegacySocket(ctx: any) {
// 监听对话加载事件
ctx.socket.on('conversation_loaded', (data) => {
console.log('对话已加载:', data);
socketLog('对话已加载:', data);
if (ctx.skipConversationLoadedEvent) {
ctx.skipConversationLoadedEvent = false;
console.log('跳过重复的 conversation_loaded 处理');
socketLog('跳过重复的 conversation_loaded 处理');
return;
}
if (data.clear_ui) {
@ -576,7 +615,7 @@ export async function initializeLegacySocket(ctx: any) {
// 监听对话列表更新事件
ctx.socket.on('conversation_list_update', (data) => {
console.log('对话列表已更新:', data);
socketLog('对话列表已更新:', data);
// 刷新对话列表
ctx.loadConversationsList();
});
@ -594,7 +633,7 @@ export async function initializeLegacySocket(ctx: any) {
// AI消息开始
ctx.socket.on('ai_message_start', () => {
console.log('AI消息开始');
socketLog('AI消息开始');
logStreamingDebug('socket:ai_message_start');
finalizeStreamingText({ force: true });
resetStreamingBuffer();
@ -604,13 +643,14 @@ export async function initializeLegacySocket(ctx: any) {
typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null;
streamingState.activeTextAction = null;
ctx.stopRequested = false;
ctx.streamingMessage = true; // 确保设置为流式状态
ctx.chatEnableAutoScroll();
ctx.scrollToBottom();
});
// 思考流开始
ctx.socket.on('thinking_start', () => {
console.log('思考开始');
socketLog('思考开始');
const result = ctx.chatStartThinkingAction();
if (result && result.blockId) {
const blockId = result.blockId;
@ -639,7 +679,7 @@ export async function initializeLegacySocket(ctx: any) {
// 思考结束
ctx.socket.on('thinking_end', (data) => {
console.log('思考结束');
socketLog('思考结束');
const blockId = ctx.chatCompleteThinkingAction(data.full_content);
if (blockId) {
setTimeout(() => {
@ -654,7 +694,7 @@ export async function initializeLegacySocket(ctx: any) {
// 文本流开始
ctx.socket.on('text_start', () => {
console.log('文本开始');
socketLog('文本开始');
logStreamingDebug('socket:text_start');
finalizeStreamingText({ force: true });
resetStreamingBuffer();
@ -692,7 +732,7 @@ export async function initializeLegacySocket(ctx: any) {
// 文本结束
ctx.socket.on('text_end', (data) => {
console.log('文本结束');
socketLog('文本结束');
logStreamingDebug('socket:text_end', {
finalLength: (data?.full_content || '').length,
snapshot: snapshotStreamingState()
@ -708,13 +748,13 @@ export async function initializeLegacySocket(ctx: any) {
// 工具提示事件(可选)
ctx.socket.on('tool_hint', (data) => {
console.log('工具提示:', data.name);
socketLog('工具提示:', data.name);
// 可以在这里添加提示UI
});
// 工具准备中事件 - 实时显示
ctx.socket.on('tool_preparing', (data) => {
console.log('工具准备中:', data.name);
socketLog('工具准备中:', data.name);
const msg = ctx.chatEnsureAssistantMessage();
if (!msg) {
return;
@ -744,7 +784,7 @@ export async function initializeLegacySocket(ctx: any) {
// 工具状态更新事件 - 实时显示详细状态
ctx.socket.on('tool_status', (data) => {
console.log('工具状态:', data);
socketLog('工具状态:', data);
const target = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id);
if (target) {
target.tool.statusDetail = data.detail;
@ -763,7 +803,7 @@ export async function initializeLegacySocket(ctx: any) {
// 工具开始(从准备转为执行)
ctx.socket.on('tool_start', (data) => {
console.log('工具开始执行:', data.name);
socketLog('工具开始执行:', data.name);
let action = null;
if (data.preparing_id && ctx.preparingTools.has(data.preparing_id)) {
action = ctx.preparingTools.get(data.preparing_id);
@ -807,7 +847,7 @@ export async function initializeLegacySocket(ctx: any) {
// 更新action工具完成
ctx.socket.on('update_action', (data) => {
console.log('更新action:', data.id, 'status:', data.status);
socketLog('更新action:', data.id, 'status:', data.status);
let targetAction = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id);
if (!targetAction && data.preparing_id && ctx.preparingTools.has(data.preparing_id)) {
targetAction = ctx.preparingTools.get(data.preparing_id);
@ -870,18 +910,19 @@ export async function initializeLegacySocket(ctx: any) {
}
ctx.$forceUpdate();
ctx.conditionalScrollToBottom();
markStreamingIdleIfPossible('update_action');
}
// 关键修复每个工具完成后都更新当前上下文Token
if (data.status === 'completed') {
setTimeout(() => {
ctx.updateCurrentContextTokens();
}, 500);
}
});
if (data.status === 'completed') {
setTimeout(() => {
ctx.updateCurrentContextTokens();
}, 500);
}
});
ctx.socket.on('append_payload', (data) => {
console.log('收到append_payload事件:', data);
socketLog('收到append_payload事件:', data);
ctx.chatAddAppendPayloadAction({
path: data.path || '未知文件',
forced: !!data.forced,
@ -894,7 +935,7 @@ export async function initializeLegacySocket(ctx: any) {
});
ctx.socket.on('modify_payload', (data) => {
console.log('收到modify_payload事件:', data);
socketLog('收到modify_payload事件:', data);
ctx.chatAddModifyPayloadAction({
path: data.path || '未知文件',
total: data.total ?? null,
@ -908,19 +949,19 @@ export async function initializeLegacySocket(ctx: any) {
// 停止请求确认
ctx.socket.on('stop_requested', (data) => {
console.log('停止请求已接收:', data.message);
socketLog('停止请求已接收:', data.message);
// 可以显示提示信息
});
// 任务停止
ctx.socket.on('task_stopped', (data) => {
console.log('任务已停止:', data.message);
socketLog('任务已停止:', data.message);
ctx.resetAllStates('socket:task_stopped');
});
// 任务完成重点更新Token统计
ctx.socket.on('task_complete', (data) => {
console.log('任务完成', data);
socketLog('任务完成', data);
ctx.resetAllStates('socket:task_complete');
// 任务完成后立即更新Token统计关键修复

View File

@ -15,6 +15,31 @@ interface ChatState {
thinkingScrollLocks: Map<string, boolean>;
}
const GENERATING_LABELS = [
'正在构思…',
'稍候AI 正在准备',
'准备工具中',
'容我三思…',
'答案马上就来',
'灵感加载中',
'思路拼装中',
'琢磨最佳方案',
'脑内开会中',
'整理资料中',
'润色回复中',
'调配上下文',
'搜刮记忆中',
'快敲完了,别急'
];
function randomGeneratingLabel() {
if (!GENERATING_LABELS.length) {
return '';
}
const index = Math.floor(Math.random() * GENERATING_LABELS.length);
return GENERATING_LABELS[index];
}
function createAssistantMessage() {
return {
role: 'assistant',
@ -22,7 +47,9 @@ function createAssistantMessage() {
streamingThinking: '',
streamingText: '',
currentStreamingType: null,
activeThinkingId: null
activeThinkingId: null,
awaitingFirstContent: false,
generatingLabel: randomGeneratingLabel()
};
}
@ -38,6 +65,12 @@ function cloneMap<K, V>(source: Map<K, V>) {
return new Map<K, V>(Array.from(source.entries()));
}
function clearAwaitingFirstContent(message: any) {
if (message && message.awaitingFirstContent) {
message.awaitingFirstContent = false;
}
}
export const useChatStore = defineStore('chat', {
state: (): ChatState => ({
messages: [],
@ -142,10 +175,12 @@ export const useChatStore = defineStore('chat', {
this.messages.push(message);
this.currentMessageIndex = this.messages.length - 1;
this.streamingMessage = true;
message.awaitingFirstContent = true;
return message;
},
startThinkingAction() {
const msg = this.ensureAssistantMessage();
clearAwaitingFirstContent(msg);
msg.streamingThinking = '';
msg.currentStreamingType = 'thinking';
const actionId = randomId('thinking');
@ -189,6 +224,10 @@ export const useChatStore = defineStore('chat', {
},
startTextAction() {
const msg = this.ensureAssistantMessage();
if (!msg) {
return null;
}
clearAwaitingFirstContent(msg);
msg.streamingText = '';
msg.currentStreamingType = 'text';
const action = {
@ -204,6 +243,12 @@ export const useChatStore = defineStore('chat', {
appendTextChunk(content: string) {
if (this.currentMessageIndex < 0) return null;
const msg = this.messages[this.currentMessageIndex];
if (!msg) {
return null;
}
if (typeof msg.streamingText !== 'string') {
msg.streamingText = '';
}
msg.streamingText += content;
const lastAction = msg.actions[msg.actions.length - 1];
if (lastAction && lastAction.type === 'text') {
@ -228,6 +273,7 @@ export const useChatStore = defineStore('chat', {
},
addSystemMessage(content: string) {
const msg = this.ensureAssistantMessage();
clearAwaitingFirstContent(msg);
msg.actions.push({
id: randomId('system'),
type: 'system',
@ -237,6 +283,7 @@ export const useChatStore = defineStore('chat', {
},
addAppendPayloadAction(data: any) {
const msg = this.ensureAssistantMessage();
clearAwaitingFirstContent(msg);
msg.actions.push({
id: `append-payload-${Date.now()}-${Math.random()}`,
type: 'append_payload',
@ -246,6 +293,7 @@ export const useChatStore = defineStore('chat', {
},
addModifyPayloadAction(data: any) {
const msg = this.ensureAssistantMessage();
clearAwaitingFirstContent(msg);
msg.actions.push({
id: `modify-payload-${Date.now()}-${Math.random()}`,
type: 'modify_payload',

View File

@ -1,5 +1,13 @@
import { defineStore } from 'pinia';
const FILE_STORE_DEBUG_LOGS = false;
function fileDebugLog(...args: unknown[]) {
if (!FILE_STORE_DEBUG_LOGS) {
return;
}
console.log(...args);
}
interface FileNode {
type: 'folder' | 'file';
name: string;
@ -80,7 +88,7 @@ export const useFileStore = defineStore('file', {
try {
const response = await fetch('/api/files');
const data = await response.json();
console.log('[FileTree] fetch result', data);
fileDebugLog('[FileTree] fetch result', data);
this.setFileTreeFromResponse(data);
} catch (error) {
console.error('获取文件树失败:', error);
@ -127,7 +135,7 @@ export const useFileStore = defineStore('file', {
return;
}
const current = !!this.expandedFolders[path];
console.log('[FileTree] toggle folder', path, '=>', !current);
fileDebugLog('[FileTree] toggle folder', path, '=>', !current);
this.expandedFolders = {
...this.expandedFolders,
[path]: !current

View File

@ -147,6 +147,49 @@
border-left: 4px solid var(--claude-accent);
}
.assistant-generating-block {
width: 100%;
}
.text-content.assistant-generating-placeholder {
display: flex;
width: 100%;
align-items: center;
gap: 0.08em;
padding: 8px 0 16px;
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--claude-text);
letter-spacing: 0.08em;
background: transparent;
border: none;
box-shadow: none;
}
.assistant-generating-letter {
display: inline-block;
opacity: 0.35;
transform: translateY(0);
animation: assistant-generating-wave 1.6s ease-in-out infinite;
}
@keyframes assistant-generating-wave {
0%,
100% {
opacity: 0.35;
transform: translateY(0);
}
20% {
opacity: 1;
transform: translateY(-3px) scale(1.05);
}
40% {
opacity: 0.65;
transform: translateY(0);
}
}
.thinking-content {
white-space: pre-wrap;
word-wrap: break-word;
@ -322,6 +365,34 @@
padding: 0 20px 0 15px;
}
.text-output .text-content table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
background: rgba(255, 255, 255, 0.92);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 20px rgba(61, 57, 41, 0.06);
}
.text-output .text-content thead {
background: rgba(218, 119, 86, 0.1);
}
.text-output .text-content th,
.text-output .text-content td {
border: 1px solid rgba(118, 103, 84, 0.18);
padding: 10px 14px;
text-align: left;
vertical-align: middle;
font-size: 14px;
}
.text-output .text-content th {
font-weight: 600;
color: var(--claude-text);
}
.system-action {
margin: 12px 0;
padding: 10px 14px;

View File

@ -108,6 +108,10 @@
}
.mode-indicator {
border: none;
cursor: pointer;
outline: none;
padding: 0;
width: 36px;
height: 36px;
border-radius: 18px;
@ -120,6 +124,14 @@
transition: background 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
}
.mode-indicator:hover {
transform: translateY(-1px);
}
.mode-indicator:focus-visible {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 8px 20px rgba(189, 93, 58, 0.35);
}
.mode-indicator.fast {
background: #ffcc4d;
box-shadow: 0 8px 20px rgba(255, 204, 77, 0.35);

View File

@ -561,14 +561,7 @@
// 初始化WebSocket连接
async function initWebSocket() {
const usePollingOnly = window.location.hostname !== 'localhost' &&
window.location.hostname !== '127.0.0.1';
const socketOptions = usePollingOnly ? {
transports: ['polling'],
upgrade: false,
autoConnect: false
} : {
const socketOptions = {
transports: ['websocket', 'polling'],
autoConnect: false
};