diff --git a/ReportEngine/agent.py b/ReportEngine/agent.py index ab86841..dccb90b 100644 --- a/ReportEngine/agent.py +++ b/ReportEngine/agent.py @@ -233,14 +233,54 @@ class ReportAgent: - 确保日志目录存在; - 使用独立的 loguru sink 写入 Report Engine 专属 log 文件, 避免与其他子系统混淆。 + - 【修复】配置实时日志写入,禁用缓冲,确保前端实时看到日志 + - 【修复】防止重复添加handler """ # 确保日志目录存在 log_dir = os.path.dirname(self.config.LOG_FILE) os.makedirs(log_dir, exist_ok=True) - # 创建专用的logger,避免与其他模块冲突 - # 修改日志级别为DEBUG,确保DEBUG、INFO、WARNING、ERROR级别的日志都能被记录 - logger.add(self.config.LOG_FILE, level="DEBUG") + # 【修复】检查是否已经添加过这个文件的handler,避免重复 + # loguru会自动去重,但显式检查更安全 + log_file_path = str(Path(self.config.LOG_FILE).resolve()) + + # 检查现有的handlers + handler_exists = False + for handler_id, handler_config in logger._core.handlers.items(): + if hasattr(handler_config, 'sink'): + sink = handler_config.sink + # 检查是否是文件sink且路径相同 + if hasattr(sink, '_name') and sink._name == log_file_path: + handler_exists = True + logger.debug(f"日志handler已存在,跳过添加: {log_file_path}") + break + + if not handler_exists: + # 【修复】创建专用的logger,配置实时写入 + # - enqueue=False: 禁用异步队列,立即写入 + # - buffering=1: 行缓冲,每条日志立即刷新到文件 + # - level="DEBUG": 记录所有级别的日志 + # - encoding="utf-8": 明确指定UTF-8编码 + # - mode="a": 追加模式,保留历史日志 + handler_id = logger.add( + self.config.LOG_FILE, + level="DEBUG", + enqueue=False, # 禁用异步队列,同步写入 + buffering=1, # 行缓冲,每行立即写入 + serialize=False, # 普通文本格式,不序列化为JSON + encoding="utf-8", # 明确UTF-8编码 + mode="a" # 追加模式 + ) + logger.debug(f"已添加日志handler (ID: {handler_id}): {self.config.LOG_FILE}") + + # 【修复】验证日志文件可写 + try: + with open(self.config.LOG_FILE, 'a', encoding='utf-8') as f: + f.write('') # 尝试写入空字符串验证权限 + f.flush() # 立即刷新 + except Exception as e: + logger.error(f"日志文件无法写入: {self.config.LOG_FILE}, 错误: {e}") + raise def _initialize_file_baseline(self): """ diff --git a/ReportEngine/flask_interface.py b/ReportEngine/flask_interface.py index 75e4533..5b0a526 100644 --- a/ReportEngine/flask_interface.py +++ b/ReportEngine/flask_interface.py @@ -957,9 +957,22 @@ def clear_report_log(): """ try: log_file = settings.LOG_FILE - with open(log_file, 'w', encoding='utf-8') as f: - f.write('') + + # 【修复】使用truncate而非重新打开,避免与logger的文件句柄冲突 + # 追加模式打开,然后truncate,保持文件句柄有效 + with open(log_file, 'r+', encoding='utf-8') as f: + f.truncate(0) # 清空文件内容但不关闭文件 + f.flush() # 立即刷新 + logger.info(f"已清空日志文件: {log_file}") + except FileNotFoundError: + # 文件不存在,创建空文件 + try: + with open(log_file, 'w', encoding='utf-8') as f: + f.write('') + logger.info(f"创建日志文件: {log_file}") + except Exception as e: + logger.exception(f"创建日志文件失败: {str(e)}") except Exception as e: logger.exception(f"清空日志文件失败: {str(e)}") @@ -969,29 +982,58 @@ def get_report_log(): """ 获取report.log内容,并按行去除空白返回。 + 【修复】优化大文件读取,添加错误处理和文件锁 + 返回: Response: JSON,包含最新日志行数组。 """ try: log_file = settings.LOG_FILE - + if not os.path.exists(log_file): return jsonify({ 'success': True, 'log_lines': [] }) - - with open(log_file, 'r', encoding='utf-8') as f: - lines = f.readlines() - - # 清理行尾的换行符 + + # 【修复】检查文件大小,避免读取过大文件导致内存问题 + file_size = os.path.getsize(log_file) + max_size = 10 * 1024 * 1024 # 10MB限制 + + if file_size > max_size: + # 文件过大,只读取最后10MB + with open(log_file, 'rb') as f: + f.seek(-max_size, 2) # 从文件末尾往前10MB + # 跳过可能不完整的第一行 + f.readline() + content = f.read().decode('utf-8', errors='replace') + lines = content.splitlines() + logger.warning(f"日志文件过大 ({file_size} bytes),仅返回最后 {max_size} bytes") + else: + # 正常大小,完整读取 + with open(log_file, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + + # 清理行尾的换行符和空行 log_lines = [line.rstrip('\n\r') for line in lines if line.strip()] - + return jsonify({ 'success': True, 'log_lines': log_lines }) - + + except PermissionError as e: + logger.error(f"读取日志权限不足: {str(e)}") + return jsonify({ + 'success': False, + 'error': '读取日志权限不足' + }), 403 + except UnicodeDecodeError as e: + logger.error(f"日志文件编码错误: {str(e)}") + return jsonify({ + 'success': False, + 'error': '日志文件编码错误' + }), 500 except Exception as e: logger.exception(f"读取日志失败: {str(e)}") return jsonify({ diff --git a/app.py b/app.py index 9a20ed8..c60f3e2 100644 --- a/app.py +++ b/app.py @@ -4,6 +4,12 @@ Flask主应用 - 统一管理三个Streamlit应用 import os import sys + +# 【修复】尽早设置环境变量,确保所有模块都使用无缓冲模式 +os.environ['PYTHONIOENCODING'] = 'utf-8' +os.environ['PYTHONUTF8'] = '1' +os.environ['PYTHONUNBUFFERED'] = '1' # 禁用Python输出缓冲,确保日志实时输出 + import subprocess import time import threading @@ -37,10 +43,6 @@ if REPORT_ENGINE_AVAILABLE: else: logger.info("ReportEngine不可用,跳过接口注册") -# 设置UTF-8编码环境 -os.environ['PYTHONIOENCODING'] = 'utf-8' -os.environ['PYTHONUTF8'] = '1' - # 创建日志目录 LOG_DIR = Path('logs') LOG_DIR.mkdir(exist_ok=True) diff --git a/templates/index.html b/templates/index.html index bb6b54d..ab3bd4f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4467,21 +4467,53 @@ } function refreshReportLog() { - fetch('/api/report/log') - .then(response => response.json()) + // 【修复】添加超时控制和完整错误处理 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时 + + fetch('/api/report/log', { signal: controller.signal }) + .then(response => { + clearTimeout(timeoutId); + // 【修复】检查HTTP状态 + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) .then(data => { - if (data.success && data.log_lines.length > reportLogLineCount) { + // 【修复】检查返回的成功状态 + if (!data.success) { + console.error('[Report日志] 刷新失败:', data.error || '未知错误'); + return; + } + + if (data.log_lines && data.log_lines.length > reportLogLineCount) { // 只添加新的行 const newLines = data.log_lines.slice(reportLogLineCount); + + // 【调试日志】记录实时读取的日志数量 + if (newLines.length > 0) { + console.log(`[Report日志] 读取 ${newLines.length} 条新日志(总计 ${data.log_lines.length})`); + } + newLines.forEach(line => { + // 直接添加,使用LogVirtualList的默认批处理机制 appendConsoleTextLine('report', line); }); - + reportLogLineCount = data.log_lines.length; } }) .catch(error => { - console.error('刷新Report日志失败:', error); + clearTimeout(timeoutId); + // 【修复】区分错误类型 + if (error.name === 'AbortError') { + console.warn('[Report日志] 刷新超时(5秒)'); + } else if (error.message.includes('Failed to fetch')) { + console.error('[Report日志] 刷新失败: 网络连接错误'); + } else { + console.error('[Report日志] 刷新失败:', error.message || error); + } }); } @@ -5113,9 +5145,6 @@ reportTaskId = data.task_id; showMessage('报告生成已启动', 'success'); - // 【修复】立即启动日志实时刷新,确保日志实时显示 - startReportLogRefresh(); - // 更新任务状态显示 updateTaskProgressStatus({ task_id: data.task_id, @@ -5126,12 +5155,13 @@ updated_at: new Date().toISOString() }); - // 立即刷新一次日志以确保同步 - setTimeout(() => { - refreshReportLog(); - }, 500); - appendReportStreamLine('任务创建成功,正在建立流式连接...', 'info', { force: true }); + + // 【优化】先启动日志轮询,再建立SSE连接 + // 确保从任务开始就能读取日志 + startReportLogRefresh(); + console.log('[Report日志] 任务创建成功,启动日志轮询'); + if (window.EventSource) { openReportStream(reportTaskId); } else { @@ -5163,18 +5193,25 @@ // 【修复】启动Report Engine日志实时刷新 function startReportLogRefresh() { - // 清除旧的定时器 + // 防重复:如果已经在运行,直接返回 if (reportLogRefreshInterval) { - clearInterval(reportLogRefreshInterval); - reportLogRefreshInterval = null; + console.log('[Report日志] 日志轮询已在运行,跳过重复启动'); + return; } - // 启动新的日志刷新定时器(1秒刷新一次,保证实时性) + // 立即刷新一次,获取当前所有日志 + refreshReportLog(); + + // 启动定时器,每秒刷新一次 reportLogRefreshInterval = setInterval(() => { if (currentApp === 'report') { refreshReportLog(); + } else { + // 如果切换到其他应用,自动停止轮询 + console.log('[Report日志] 检测到应用切换,停止日志轮询'); + stopReportLogRefresh(); } - }, 1000); // 1秒刷新,确保日志实时显示 + }, 1000); // 每秒刷新一次 console.log('[Report日志] 启动实时日志刷新,频率: 1秒'); } @@ -5421,9 +5458,10 @@ appendReportStreamLine(isRetry ? 'SSE重连成功' : 'Report Engine流式连接已建立', 'success', { badge: 'SSE' }); startStreamHeartbeat(); - // 【优化Phase 1.1】删除定时刷新日志的轮询 - // SSE事件流已提供实时推送,无需1秒轮询 - // 这减少了大量不必要的HTTP请求 + // 【修复】启动日志轮询,读取logger.info/debug/warning/error + // SSE只推送显式事件(stage/chapter_status等),logger日志需要轮询读取 + startReportLogRefresh(); + console.log('[Report日志] SSE连接建立,同步启动日志轮询'); }; reportEventSource.onerror = () => { appendReportStreamLine('检测到网络抖动,SSE正在等待自动重连...', 'warn', { badge: 'SSE' }); @@ -5454,6 +5492,7 @@ if (reportLogRefreshInterval) { clearInterval(reportLogRefreshInterval); reportLogRefreshInterval = null; + console.log('[Report日志] SSE连接关闭,停止日志轮询'); } clearStreamHeartbeat(); if (!keepIndicator) { @@ -5560,7 +5599,15 @@ break; case 'completed': appendReportStreamLine(payload.message || '任务完成', 'success'); - safeCloseReportStream(); + + // 【修复】任务完成前最后一次刷新日志,确保所有日志都被读取 + refreshReportLog(); + + // 延迟500ms后关闭SSE,确保最后一次日志刷新完成 + setTimeout(() => { + safeCloseReportStream(); + }, 500); + reportTaskId = null; setGenerateButtonState(false); if (task) {