feat(web): remove socket dependency from main chat flow

This commit is contained in:
JOJO 2026-04-03 11:34:42 +08:00
parent 7ae29180b6
commit 860fa7b539
6 changed files with 208 additions and 66 deletions

View File

@ -11,6 +11,7 @@ from collections import defaultdict, Counter, deque
from typing import Dict, Any, Optional, List, Tuple from typing import Dict, Any, Optional, List, Tuple
from flask import Blueprint, request, jsonify, session from flask import Blueprint, request, jsonify, session
from flask_socketio import emit
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import zipfile import zipfile
@ -51,7 +52,7 @@ from utils.conversation_manager import ConversationManager
from utils.api_client import DeepSeekClient from utils.api_client import DeepSeekClient
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username 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 ( from .utils_common import (
build_review_lines, build_review_lines,
debug_log, debug_log,
@ -73,6 +74,7 @@ from .state import (
container_manager, container_manager,
get_last_active_ts, get_last_active_ts,
) )
from .usage import record_user_activity
from .conversation_stats import ( from .conversation_stats import (
build_admin_dashboard_snapshot, build_admin_dashboard_snapshot,
compute_workspace_storage, compute_workspace_storage,
@ -813,53 +815,82 @@ def handle_command(data):
return return
record_user_activity(username) 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('/'): if command.startswith('/'):
command = command[1:] command = command[1:]
parts = command.split(maxsplit=1) parts = command.split(maxsplit=1)
cmd = parts[0].lower() cmd = parts[0].lower() if parts else ''
if cmd == "clear": if cmd == "clear":
terminal.context_manager.conversation_history.clear() terminal.context_manager.conversation_history.clear()
if terminal.thinking_mode: if terminal.thinking_mode:
terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode) terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode)
emit('command_result', { return {
'command': cmd, 'command': cmd,
'success': True, 'success': True,
'message': '对话已清除' 'message': '对话已清除'
}) }
elif cmd == "status":
if cmd == "status":
status = terminal.get_status() status = terminal.get_status()
# 添加终端状态
if terminal.terminal_manager: if terminal.terminal_manager:
terminal_status = terminal.terminal_manager.list_terminals() status['terminals'] = terminal.terminal_manager.list_terminals()
status['terminals'] = terminal_status return {
emit('command_result', {
'command': cmd, 'command': cmd,
'success': True, 'success': True,
'data': status 'data': status
}) }
elif cmd == "terminals":
# 列出终端会话 if cmd == "terminals":
if terminal.terminal_manager: if terminal.terminal_manager:
result = terminal.terminal_manager.list_terminals() return {
emit('command_result', {
'command': cmd, 'command': cmd,
'success': True, 'success': True,
'data': result 'data': terminal.terminal_manager.list_terminals()
}) }
else: return {
emit('command_result', {
'command': cmd,
'success': False,
'message': '终端系统未初始化'
})
else:
emit('command_result', {
'command': cmd, 'command': cmd,
'success': False, '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/<conversation_id>/token-statistics', methods=['GET']) @conversation_bp.route('/api/conversations/<conversation_id>/token-statistics', methods=['GET'])
@api_login_required @api_login_required

View File

@ -174,7 +174,8 @@ const appOptions = {
monitorShowPendingReply: 'showPendingReply', monitorShowPendingReply: 'showPendingReply',
monitorPreviewTool: 'previewToolIntent', monitorPreviewTool: 'previewToolIntent',
monitorQueueTool: 'enqueueToolEvent', monitorQueueTool: 'enqueueToolEvent',
monitorResolveTool: 'resolveToolResult' monitorResolveTool: 'resolveToolResult',
forceUnlockMonitor: 'resetVisualState'
}), }),
...mapActions(useSubAgentStore, { ...mapActions(useSubAgentStore, {
subAgentFetch: 'fetchSubAgents', subAgentFetch: 'fetchSubAgents',

View File

@ -16,7 +16,7 @@ export function created() {
} }
}, },
isConnected: () => this.isConnected, isConnected: () => this.isConnected,
getSocket: () => this.socket, executeCommand: async (command) => this.executeSystemCommand(command, { showToast: false }),
downloadResource: (url, filename) => this.downloadResource(url, filename) downloadResource: (url, filename) => this.downloadResource(url, filename)
}); });
} }
@ -28,11 +28,9 @@ export async function mounted() {
console.warn('CSRF token 初始化失败:', err); console.warn('CSRF token 初始化失败:', err);
}); });
} }
// 并行启动路由解析与实时连接,避免等待路由加载阻塞思考模式同步 // 并行启动路由解析与初始化数据,网页端不再依赖 WebSocket 初始化
const routePromise = this.bootstrapRoute(); const routePromise = this.bootstrapRoute();
const socketPromise = this.initSocket();
await routePromise; await routePromise;
await socketPromise;
this.$nextTick(() => { this.$nextTick(() => {
this.ensureScrollListener(); this.ensureScrollListener();
// 刷新后若无输出,自动解锁滚动锁定 // 刷新后若无输出,自动解锁滚动锁定

View File

@ -2,6 +2,105 @@
import { debugLog } from './common'; import { debugLog } from './common';
export const messageMethods = { 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() { handleSendOrStop() {
if (this.composerBusy) { if (this.composerBusy) {
this.stopTask(); this.stopTask();
@ -79,9 +178,7 @@ export const messageMethods = {
const message = text; const message = text;
const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/'); const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/');
if (isCommand) { if (isCommand) {
if (this.socket && this.isConnected) { await this.executeSystemCommand(message, { showToast: false });
this.socket.emit('send_command', { command: message });
}
this.inputClearMessage(); this.inputClearMessage();
this.inputClearSelectedImages(); this.inputClearSelectedImages();
this.inputClearSelectedVideos(); this.inputClearSelectedVideos();
@ -177,12 +274,6 @@ export const messageMethods = {
console.error('[Message] 取消任务失败:', error); console.error('[Message] 取消任务失败:', error);
} }
// 兼容 WebSocket 模式
if (this.socket && this.isConnected) {
this.socket.emit('stop_task');
debugLog('发送停止请求WebSocket');
}
// 立即清理前端状态,避免出现"不可输入也不可停止"的卡死状态 // 立即清理前端状态,避免出现"不可输入也不可停止"的卡死状态
this.clearPendingTools('user_stop'); this.clearPendingTools('user_stop');
this.streamingMessage = false; this.streamingMessage = false;
@ -198,7 +289,7 @@ export const messageMethods = {
cancelText: '取消' cancelText: '取消'
}); });
if (confirmed) { 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(); const message = (text || '').trim();
if (!message || !this.isConnected) { if (!message || !this.isConnected) {
return false; return false;
@ -277,12 +368,19 @@ export const messageMethods = {
} }
this.taskInProgress = true; this.taskInProgress = true;
this.chatAddUserMessage(message, []); this.chatAddUserMessage(message, []);
if (this.socket) { try {
this.socket.emit('send_message', { const { useTaskStore } = await import('../../stores/task');
message, const taskStore = useTaskStore();
images: [], await taskStore.createTask(message, [], [], this.currentConversationId);
conversation_id: 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') { if (typeof this.monitorShowPendingReply === 'function') {
this.monitorShowPendingReply(); this.monitorShowPendingReply();

View File

@ -2,7 +2,6 @@
import { usePolicyStore } from '../../stores/policy'; import { usePolicyStore } from '../../stores/policy';
import { useModelStore } from '../../stores/model'; import { useModelStore } from '../../stores/model';
import { usePersonalizationStore } from '../../stores/personalization'; import { usePersonalizationStore } from '../../stores/personalization';
import { initializeLegacySocket } from '../../composables/useLegacySocket';
import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer'; import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer';
import { import {
scrollToBottom as scrollToBottomHelper, scrollToBottom as scrollToBottomHelper,
@ -608,7 +607,7 @@ export const uiMethods = {
: '建议使用 read 工具进行搜索或分段阅读。'; : '建议使用 read 工具进行搜索或分段阅读。';
if (this.reviewSendToModel) { if (this.reviewSendToModel) {
const message = `帮我继续这个任务,对话文件在 ${path},文件长 ${count || '未知'} 字符,${suggestion} 请阅读文件了解后,不要直接继续工作,而是向我汇报你的理解,然后等我做出指示。`; const message = `帮我继续这个任务,对话文件在 ${path},文件长 ${count || '未知'} 字符,${suggestion} 请阅读文件了解后,不要直接继续工作,而是向我汇报你的理解,然后等我做出指示。`;
const sent = this.sendAutoUserMessage(message); const sent = await this.sendAutoUserMessage(message);
if (sent) { if (sent) {
this.reviewDialogOpen = false; this.reviewDialogOpen = false;
} }
@ -1084,7 +1083,8 @@ export const uiMethods = {
}, },
async initSocket() { async initSocket() {
await initializeLegacySocket(this); // 主网页端已切换为 REST + 轮询模式,这里保留空实现用于兼容旧调用
this.socket = null;
}, },
openRealtimeTerminal() { openRealtimeTerminal() {
@ -1098,7 +1098,12 @@ export const uiMethods = {
debugLog('加载初始数据...'); debugLog('加载初始数据...');
const statusResponse = await fetch('/api/status'); const statusResponse = await fetch('/api/status');
if (!statusResponse.ok) {
throw new Error(`状态接口请求失败: ${statusResponse.status}`);
}
const statusData = await statusResponse.json(); const statusData = await statusResponse.json();
this.isConnected = true;
this.socket = null;
this.projectPath = statusData.project_path || ''; this.projectPath = statusData.project_path || '';
this.agentVersion = statusData.version || this.agentVersion; this.agentVersion = statusData.version || this.agentVersion;
this.thinkingMode = !!statusData.thinking_mode; this.thinkingMode = !!statusData.thinking_mode;
@ -1186,6 +1191,7 @@ export const uiMethods = {
debugLog('初始数据加载完成'); debugLog('初始数据加载完成');
} catch (error) { } catch (error) {
console.error('加载初始数据失败:', error); console.error('加载初始数据失败:', error);
this.isConnected = false;
} }
}, },

View File

@ -12,7 +12,7 @@ interface ChatActionDependencies {
autoResizeInput: () => void; autoResizeInput: () => void;
focusComposer: () => void; focusComposer: () => void;
isConnected: () => boolean; 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<void>; downloadResource: (url: string, filename: string) => Promise<void>;
} }
@ -21,7 +21,7 @@ const defaultDependencies: ChatActionDependencies = {
autoResizeInput: () => {}, autoResizeInput: () => {},
focusComposer: () => {}, focusComposer: () => {},
isConnected: () => false, isConnected: () => false,
getSocket: () => null, executeCommand: async () => ({ success: false, message: '命令通道不可用' }),
downloadResource: async () => {} downloadResource: async () => {}
}; };
@ -139,7 +139,7 @@ export const useChatActionStore = defineStore('chatActions', {
}); });
return true; return true;
}, },
runCommand(action: any) { async runCommand(action: any) {
const command = (action && (action.command || action.content))?.toString(); const command = (action && (action.command || action.content))?.toString();
if (!command) { if (!command) {
this.dependencies.pushToast({ this.dependencies.pushToast({
@ -157,22 +157,30 @@ export const useChatActionStore = defineStore('chatActions', {
}); });
return false; return false;
} }
const socket = this.dependencies.getSocket(); try {
if (!socket || typeof socket.emit !== 'function') { 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({ this.dependencies.pushToast({
title: '无法执行', title: '命令已执行',
message: '命令通道不可用,请稍后重试。', message: command,
type: 'success'
});
return true;
} catch (error) {
this.dependencies.pushToast({
title: '命令执行失败',
message: (error as Error)?.message || '请稍后重试',
type: 'error' type: 'error'
}); });
return false; return false;
} }
socket.emit('send_command', { command });
this.dependencies.pushToast({
title: '命令已发送',
message: command,
type: 'success'
});
return true;
}, },
async downloadActionAttachment(action: any) { async downloadActionAttachment(action: any) {
if (!action || !action.path) { if (!action || !action.path) {