diff --git a/server/conversation.py b/server/conversation.py index cdd4aa5..3b3a2f2 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -11,6 +11,7 @@ from collections import defaultdict, Counter, deque from typing import Dict, Any, Optional, List, Tuple from flask import Blueprint, request, jsonify, session +from flask_socketio import emit from werkzeug.utils import secure_filename import zipfile @@ -51,7 +52,7 @@ from utils.conversation_manager import ConversationManager from utils.api_client import DeepSeekClient from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username -from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker +from .context import with_terminal, get_terminal_for_sid, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker from .utils_common import ( build_review_lines, debug_log, @@ -73,6 +74,7 @@ from .state import ( container_manager, get_last_active_ts, ) +from .usage import record_user_activity from .conversation_stats import ( build_admin_dashboard_snapshot, compute_workspace_storage, @@ -813,53 +815,82 @@ def handle_command(data): return record_user_activity(username) + result = _execute_system_command(terminal, command) + emit('command_result', result) + + +def _execute_system_command(terminal: WebTerminal, command: str) -> Dict[str, Any]: + """执行系统命令,供 WebSocket 与 REST API 复用。""" + command = (command or '').strip() if command.startswith('/'): command = command[1:] - + parts = command.split(maxsplit=1) - cmd = parts[0].lower() - + cmd = parts[0].lower() if parts else '' + if cmd == "clear": terminal.context_manager.conversation_history.clear() if terminal.thinking_mode: terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode) - emit('command_result', { + return { 'command': cmd, 'success': True, 'message': '对话已清除' - }) - elif cmd == "status": + } + + if cmd == "status": status = terminal.get_status() - # 添加终端状态 if terminal.terminal_manager: - terminal_status = terminal.terminal_manager.list_terminals() - status['terminals'] = terminal_status - emit('command_result', { + status['terminals'] = terminal.terminal_manager.list_terminals() + return { 'command': cmd, 'success': True, 'data': status - }) - elif cmd == "terminals": - # 列出终端会话 + } + + if cmd == "terminals": if terminal.terminal_manager: - result = terminal.terminal_manager.list_terminals() - emit('command_result', { + return { 'command': cmd, 'success': True, - 'data': result - }) - else: - emit('command_result', { - 'command': cmd, - 'success': False, - 'message': '终端系统未初始化' - }) - else: - emit('command_result', { + 'data': terminal.terminal_manager.list_terminals() + } + return { 'command': cmd, 'success': False, - 'message': f'未知命令: {cmd}' - }) + 'message': '终端系统未初始化' + } + + return { + 'command': cmd or command, + 'success': False, + 'message': f'未知命令: {cmd or command}' + } + + +@conversation_bp.route('/api/commands', methods=['POST']) +@api_login_required +@with_terminal +def execute_command_api(terminal: WebTerminal, workspace: UserWorkspace, username: str): + """通过 REST API 执行系统命令(用于无 WebSocket 前端)。""" + try: + payload = request.get_json(silent=True) or {} + command = (payload.get('command') or '').strip() + if not command: + return jsonify({ + "success": False, + "message": "命令不能为空" + }), 400 + + record_user_activity(username) + result = _execute_system_command(terminal, command) + status_code = 200 if result.get('success') else 400 + return jsonify(result), status_code + except Exception as exc: + return jsonify({ + "success": False, + "message": f"命令执行异常: {exc}" + }), 500 @conversation_bp.route('/api/conversations//token-statistics', methods=['GET']) @api_login_required diff --git a/static/src/app.ts b/static/src/app.ts index 0cfb11c..057c56f 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -174,7 +174,8 @@ const appOptions = { monitorShowPendingReply: 'showPendingReply', monitorPreviewTool: 'previewToolIntent', monitorQueueTool: 'enqueueToolEvent', - monitorResolveTool: 'resolveToolResult' + monitorResolveTool: 'resolveToolResult', + forceUnlockMonitor: 'resetVisualState' }), ...mapActions(useSubAgentStore, { subAgentFetch: 'fetchSubAgents', diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index 6fbfa3e..b72378e 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -16,7 +16,7 @@ export function created() { } }, isConnected: () => this.isConnected, - getSocket: () => this.socket, + executeCommand: async (command) => this.executeSystemCommand(command, { showToast: false }), downloadResource: (url, filename) => this.downloadResource(url, filename) }); } @@ -28,11 +28,9 @@ export async function mounted() { console.warn('CSRF token 初始化失败:', err); }); } - // 并行启动路由解析与实时连接,避免等待路由加载阻塞思考模式同步 + // 并行启动路由解析与初始化数据,网页端不再依赖 WebSocket 初始化 const routePromise = this.bootstrapRoute(); - const socketPromise = this.initSocket(); await routePromise; - await socketPromise; this.$nextTick(() => { this.ensureScrollListener(); // 刷新后若无输出,自动解锁滚动锁定 diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index 6b6b7c6..0997227 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -2,6 +2,105 @@ import { debugLog } from './common'; export const messageMethods = { + async executeSystemCommand(rawCommand, options = {}) { + const command = (rawCommand || '').toString().trim(); + if (!command) { + return { success: false, message: '命令不能为空' }; + } + + if (!this.isConnected) { + if (options.showToast !== false) { + this.uiPushToast({ + title: '连接不可用', + message: '当前无法执行命令,请稍后重试。', + type: 'error' + }); + } + return { success: false, message: '连接不可用' }; + } + + try { + const response = await fetch('/api/commands', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ command }) + }); + + const payload = await response.json().catch(() => ({})); + const result = { + command: payload.command || command.replace(/^\//, ''), + success: !!payload.success, + message: payload.message, + data: payload.data + }; + this.handleSystemCommandResult(result, options); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : '命令执行失败'; + const result = { + command: command.replace(/^\//, ''), + success: false, + message + }; + this.handleSystemCommandResult(result, options); + return result; + } + }, + + handleSystemCommandResult(data, options = {}) { + const showToast = options.showToast !== false; + if (data.command === 'clear' && data.success) { + this.logMessageState?.('command_result-clear', { data }); + this.messages = []; + this.logMessageState?.('command_result-cleared', { data }); + this.currentMessageIndex = -1; + this.chatClearExpandedBlocks(); + this.resetTokenStatistics(); + if (showToast) { + this.uiPushToast({ + title: '已清除', + message: data.message || '对话已清除', + type: 'success' + }); + } + return; + } + + if (data.command === 'status' && data.success) { + this.addSystemMessage(`系统状态:\n${JSON.stringify(data.data || {}, null, 2)}`); + if (showToast) { + this.uiPushToast({ + title: '状态已更新', + message: '已获取系统状态', + type: 'success' + }); + } + return; + } + + if (!data.success) { + this.addSystemMessage(`命令失败: ${data.message || '未知错误'}`); + if (showToast) { + this.uiPushToast({ + title: '命令执行失败', + message: data.message || '请稍后重试', + type: 'error' + }); + } + return; + } + + if (showToast) { + this.uiPushToast({ + title: '命令已执行', + message: data.command || '完成', + type: 'success' + }); + } + }, + handleSendOrStop() { if (this.composerBusy) { this.stopTask(); @@ -79,9 +178,7 @@ export const messageMethods = { const message = text; const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/'); if (isCommand) { - if (this.socket && this.isConnected) { - this.socket.emit('send_command', { command: message }); - } + await this.executeSystemCommand(message, { showToast: false }); this.inputClearMessage(); this.inputClearSelectedImages(); this.inputClearSelectedVideos(); @@ -177,12 +274,6 @@ export const messageMethods = { console.error('[Message] 取消任务失败:', error); } - // 兼容 WebSocket 模式 - if (this.socket && this.isConnected) { - this.socket.emit('stop_task'); - debugLog('发送停止请求(WebSocket)'); - } - // 立即清理前端状态,避免出现"不可输入也不可停止"的卡死状态 this.clearPendingTools('user_stop'); this.streamingMessage = false; @@ -198,7 +289,7 @@ export const messageMethods = { cancelText: '取消' }); if (confirmed) { - this.socket.emit('send_command', { command: '/clear' }); + await this.executeSystemCommand('/clear', { showToast: false }); } }, @@ -265,7 +356,7 @@ export const messageMethods = { } }, - sendAutoUserMessage(text) { + async sendAutoUserMessage(text) { const message = (text || '').trim(); if (!message || !this.isConnected) { return false; @@ -277,12 +368,19 @@ export const messageMethods = { } this.taskInProgress = true; this.chatAddUserMessage(message, []); - if (this.socket) { - this.socket.emit('send_message', { - message, - images: [], - conversation_id: this.currentConversationId + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + await taskStore.createTask(message, [], [], this.currentConversationId); + } catch (error) { + console.error('[Message] 自动消息创建任务失败:', error); + this.uiPushToast({ + title: '发送失败', + message: error?.message || '创建任务失败,请重试', + type: 'error' }); + this.taskInProgress = false; + return false; } if (typeof this.monitorShowPendingReply === 'function') { this.monitorShowPendingReply(); diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index d673de5..3bc9701 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -2,7 +2,6 @@ import { usePolicyStore } from '../../stores/policy'; import { useModelStore } from '../../stores/model'; import { usePersonalizationStore } from '../../stores/personalization'; -import { initializeLegacySocket } from '../../composables/useLegacySocket'; import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer'; import { scrollToBottom as scrollToBottomHelper, @@ -608,7 +607,7 @@ export const uiMethods = { : '建议使用 read 工具进行搜索或分段阅读。'; if (this.reviewSendToModel) { const message = `帮我继续这个任务,对话文件在 ${path},文件长 ${count || '未知'} 字符,${suggestion} 请阅读文件了解后,不要直接继续工作,而是向我汇报你的理解,然后等我做出指示。`; - const sent = this.sendAutoUserMessage(message); + const sent = await this.sendAutoUserMessage(message); if (sent) { this.reviewDialogOpen = false; } @@ -1084,7 +1083,8 @@ export const uiMethods = { }, async initSocket() { - await initializeLegacySocket(this); + // 主网页端已切换为 REST + 轮询模式,这里保留空实现用于兼容旧调用 + this.socket = null; }, openRealtimeTerminal() { @@ -1098,7 +1098,12 @@ export const uiMethods = { debugLog('加载初始数据...'); const statusResponse = await fetch('/api/status'); + if (!statusResponse.ok) { + throw new Error(`状态接口请求失败: ${statusResponse.status}`); + } const statusData = await statusResponse.json(); + this.isConnected = true; + this.socket = null; this.projectPath = statusData.project_path || ''; this.agentVersion = statusData.version || this.agentVersion; this.thinkingMode = !!statusData.thinking_mode; @@ -1186,6 +1191,7 @@ export const uiMethods = { debugLog('初始数据加载完成'); } catch (error) { console.error('加载初始数据失败:', error); + this.isConnected = false; } }, diff --git a/static/src/stores/chatActions.ts b/static/src/stores/chatActions.ts index 8cbb1f1..b4d5528 100644 --- a/static/src/stores/chatActions.ts +++ b/static/src/stores/chatActions.ts @@ -12,7 +12,7 @@ interface ChatActionDependencies { autoResizeInput: () => void; focusComposer: () => void; isConnected: () => boolean; - getSocket: () => { emit: (event: string, payload: any) => void } | null; + executeCommand: (command: string) => Promise<{ success?: boolean; message?: string } | void>; downloadResource: (url: string, filename: string) => Promise; } @@ -21,7 +21,7 @@ const defaultDependencies: ChatActionDependencies = { autoResizeInput: () => {}, focusComposer: () => {}, isConnected: () => false, - getSocket: () => null, + executeCommand: async () => ({ success: false, message: '命令通道不可用' }), downloadResource: async () => {} }; @@ -139,7 +139,7 @@ export const useChatActionStore = defineStore('chatActions', { }); return true; }, - runCommand(action: any) { + async runCommand(action: any) { const command = (action && (action.command || action.content))?.toString(); if (!command) { this.dependencies.pushToast({ @@ -157,22 +157,30 @@ export const useChatActionStore = defineStore('chatActions', { }); return false; } - const socket = this.dependencies.getSocket(); - if (!socket || typeof socket.emit !== 'function') { + try { + const result = await this.dependencies.executeCommand(command); + if (result && result.success === false) { + this.dependencies.pushToast({ + title: '命令执行失败', + message: result.message || '请稍后重试', + type: 'error' + }); + return false; + } this.dependencies.pushToast({ - title: '无法执行', - message: '命令通道不可用,请稍后重试。', + title: '命令已执行', + message: command, + type: 'success' + }); + return true; + } catch (error) { + this.dependencies.pushToast({ + title: '命令执行失败', + message: (error as Error)?.message || '请稍后重试', type: 'error' }); return false; } - socket.emit('send_command', { command }); - this.dependencies.pushToast({ - title: '命令已发送', - message: command, - type: 'success' - }); - return true; }, async downloadActionAttachment(action: any) { if (!action || !action.path) {