From f004407f4ba0e26465ffd17ffe285b323d018584 Mon Sep 17 00:00:00 2001 From: BaiFu <670939375@qq.com> Date: Thu, 13 Nov 2025 00:48:52 +0800 Subject: [PATCH] Add final report download button (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(app): 改进应用健康检查机制并更新默认配置 添加专用的健康检查路径和代理配置,重构健康检查URL构建逻辑 增加健康检查失败时的日志记录 延长应用启动等待时间至90秒 * style(templates): 统一CSS选择器缩进格式并修复空格问题 * feat(报告下载): 实现报告文件下载功能并增强任务状态管理 - 在ReportAgent中修改generate_report返回包含文件路径的字典 - 在ReportTask中添加文件路径相关字段 - 新增/download接口用于下载报告文件 - 在前端添加下载按钮及相关控制逻辑 - 完善任务状态显示,增加文件路径信息 * feat(report): 添加报告下载功能并优化状态管理 - 在ReportAgent中返回报告文件保存路径信息 - 新增Flask接口/download/用于下载报告文件 - 在前端添加下载按钮及相关控制逻辑 - 修复报告生成状态重置问题 - 优化健康检查URL构建和代理设置 - 统一CSS样式中的空格和缩进 --------- Co-authored-by: HKLHaoBin Co-authored-by: Zhang Yuxiang <51037789+NTFago@users.noreply.github.com> --- BettaFish.bat | 13 ++ ReportEngine/agent.py | 35 +++- ReportEngine/flask_interface.py | 66 +++++++- app.py | 37 +++-- templates/index.html | 276 ++++++++++++++++++++++++++------ 5 files changed, 358 insertions(+), 69 deletions(-) create mode 100644 BettaFish.bat diff --git a/BettaFish.bat b/BettaFish.bat new file mode 100644 index 0000000..e851f51 --- /dev/null +++ b/BettaFish.bat @@ -0,0 +1,13 @@ +@echo off + +REM лǰ bat ļĿ¼· +cd /d "%~dp0" + +REM ⻷ע⣺ activate.bat Activate.ps1 +call .\myenv\Scripts\activate.bat + +REM ij +python app.py + +REM ֹ˫󴰿һҪɾһУ +pause diff --git a/ReportEngine/agent.py b/ReportEngine/agent.py index 82c67eb..b1a0005 100644 --- a/ReportEngine/agent.py +++ b/ReportEngine/agent.py @@ -190,10 +190,16 @@ class ReportAgent: save_report: 是否保存报告到文件 Returns: - 最终HTML报告内容 + dict: 包含HTML内容与保存文件信息 """ start_time = datetime.now() + # 为新的查询重置状态,确保文件命名信息完整 + self.state = ReportState(query=query) + self.state.metadata.query = query + self.state.query = query + self.state.mark_processing() + logger.info(f"开始生成报告: {query}") logger.info(f"输入数据 - 报告数量: {len(reports)}, 论坛日志长度: {len(forum_logs)}") @@ -205,8 +211,9 @@ class ReportAgent: html_report = self._generate_html_report(query, reports, forum_logs, template_result) # Step 3: 保存报告 + saved_files = {} if save_report: - self._save_report(html_report) + saved_files = self._save_report(html_report) # 更新生成时间 end_time = datetime.now() @@ -215,7 +222,10 @@ class ReportAgent: logger.info(f"报告生成完成,耗时: {generation_time:.2f} 秒") - return html_report + return { + 'html_content': html_report, + **saved_files + } except Exception as e: logger.exception(f"报告生成过程中发生错误: {str(e)}") @@ -357,13 +367,26 @@ class ReportAgent: with open(filepath, 'w', encoding='utf-8') as f: f.write(html_content) - logger.info(f"报告已保存到: {filepath}") + abs_report_path = os.path.abspath(filepath) + rel_report_path = os.path.relpath(abs_report_path, os.getcwd()) + logger.info(f"报告已保存到: {abs_report_path}") # 保存状态 state_filename = f"report_state_{query_safe}_{timestamp}.json" state_filepath = os.path.join(self.config.OUTPUT_DIR, state_filename) self.state.save_to_file(state_filepath) - logger.info(f"状态已保存到: {state_filepath}") + abs_state_path = os.path.abspath(state_filepath) + rel_state_path = os.path.relpath(abs_state_path, os.getcwd()) + logger.info(f"状态已保存到: {abs_state_path}") + + return { + 'report_filename': filename, + 'report_filepath': abs_report_path, + 'report_relative_path': rel_report_path, + 'state_filename': state_filename, + 'state_filepath': abs_state_path, + 'state_relative_path': rel_state_path + } def get_progress_summary(self) -> Dict[str, Any]: """获取进度摘要""" @@ -492,4 +515,4 @@ def create_agent(config_file: Optional[str] = None) -> ReportAgent: """ config = Settings() # 以空配置初始化,而从从环境变量初始化 - return ReportAgent(config) + return ReportAgent(config) \ No newline at end of file diff --git a/ReportEngine/flask_interface.py b/ReportEngine/flask_interface.py index 75f71d4..73b9375 100644 --- a/ReportEngine/flask_interface.py +++ b/ReportEngine/flask_interface.py @@ -8,7 +8,7 @@ import json import threading import time from datetime import datetime -from flask import Blueprint, request, jsonify, Response +from flask import Blueprint, request, jsonify, Response, send_file from typing import Dict, Any from loguru import logger from .agent import ReportAgent, create_agent @@ -50,6 +50,11 @@ class ReportTask: self.created_at = datetime.now() self.updated_at = datetime.now() self.html_content = "" + self.report_file_path = "" + self.report_file_relative_path = "" + self.report_file_name = "" + self.state_file_path = "" + self.state_file_relative_path = "" def update_status(self, status: str, progress: int = None, error_message: str = ""): """更新任务状态""" @@ -70,7 +75,10 @@ class ReportTask: 'error_message': self.error_message, 'created_at': self.created_at.isoformat(), 'updated_at': self.updated_at.isoformat(), - 'has_result': bool(self.html_content) + 'has_result': bool(self.html_content), + 'report_file_ready': bool(self.report_file_path), + 'report_file_name': self.report_file_name, + 'report_file_path': self.report_file_relative_path } @@ -119,7 +127,7 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " task.update_status("running", 50) # 生成报告 - html_report = report_agent.generate_report( + generation_result = report_agent.generate_report( query=query, reports=content['reports'], forum_logs=content['forum_logs'], @@ -127,10 +135,17 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " save_report=True ) + html_report = generation_result.get('html_content', '') + task.update_status("running", 90) # 保存结果 task.html_content = html_report + task.report_file_path = generation_result.get('report_filepath', '') + task.report_file_relative_path = generation_result.get('report_relative_path', '') + task.report_file_name = generation_result.get('report_filename', '') + task.state_file_path = generation_result.get('state_filepath', '') + task.state_file_relative_path = generation_result.get('state_relative_path', '') task.update_status("completed", 100) except Exception as e: @@ -251,7 +266,10 @@ def get_progress(task_id: str): 'status': 'completed', 'progress': 100, 'error_message': '', - 'has_result': True + 'has_result': True, + 'report_file_ready': False, + 'report_file_name': '', + 'report_file_path': '' } }) @@ -329,6 +347,44 @@ def get_result_json(task_id: str): }), 500 +@report_bp.route('/download/', methods=['GET']) +def download_report(task_id: str): + """下载已生成的报告HTML文件""" + try: + if not current_task or current_task.task_id != task_id: + return jsonify({ + 'success': False, + 'error': '任务不存在' + }), 404 + + if current_task.status != "completed" or not current_task.report_file_path: + return jsonify({ + 'success': False, + 'error': '报告尚未完成或尚未保存' + }), 400 + + if not os.path.exists(current_task.report_file_path): + return jsonify({ + 'success': False, + 'error': '报告文件不存在或已被删除' + }), 404 + + download_name = current_task.report_file_name or os.path.basename(current_task.report_file_path) + return send_file( + current_task.report_file_path, + mimetype='text/html', + as_attachment=True, + download_name=download_name + ) + + except Exception as e: + logger.exception(f"下载报告失败: {str(e)}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + @report_bp.route('/cancel/', methods=['POST']) def cancel_task(task_id: str): """取消报告生成任务""" @@ -478,4 +534,4 @@ def clear_log(): return jsonify({ 'success': False, 'error': f'清空日志失败: {str(e)}' - }), 500 + }), 500 \ No newline at end of file diff --git a/app.py b/app.py index 3134e5f..5b2d979 100644 --- a/app.py +++ b/app.py @@ -656,6 +656,14 @@ def stop_streamlit_app(app_name): except Exception as e: return False, f"停止失败: {str(e)}" +HEALTHCHECK_PATH = "/_stcore/health" +HEALTHCHECK_PROXIES = {'http': None, 'https': None} + + +def _build_healthcheck_url(port): + return f"http://127.0.0.1:{port}{HEALTHCHECK_PATH}" + + def check_app_status(): """检查应用状态""" for app_name, info in processes.items(): @@ -663,21 +671,24 @@ def check_app_status(): if info['process'].poll() is None: # 进程仍在运行,检查端口是否可访问 try: - response = requests.get(f"http://localhost:{info['port']}", timeout=2) + response = requests.get( + _build_healthcheck_url(info['port']), + timeout=2, + proxies=HEALTHCHECK_PROXIES + ) if response.status_code == 200: info['status'] = 'running' else: info['status'] = 'starting' - except requests.exceptions.RequestException: - info['status'] = 'starting' - except Exception: + except Exception as exc: + logger.warning(f"{app_name} 健康检查失败: {exc}") info['status'] = 'starting' else: # 进程已结束 info['process'] = None info['status'] = 'stopped' -def wait_for_app_startup(app_name, max_wait_time=30): +def wait_for_app_startup(app_name, max_wait_time=90): """等待应用启动完成""" import time start_time = time.time() @@ -690,15 +701,19 @@ def wait_for_app_startup(app_name, max_wait_time=30): return False, "进程启动失败" try: - response = requests.get(f"http://localhost:{info['port']}", timeout=2) + response = requests.get( + _build_healthcheck_url(info['port']), + timeout=2, + proxies=HEALTHCHECK_PROXIES + ) if response.status_code == 200: info['status'] = 'running' return True, "启动成功" - except: - pass - + except Exception as exc: + logger.warning(f"{app_name} 健康检查失败: {exc}") + time.sleep(1) - + return False, "启动超时" def cleanup_processes(): @@ -1042,4 +1057,4 @@ if __name__ == '__main__': logger.info("\n正在关闭应用...") cleanup_processes() - + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 2bd05b8..eb39fcb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -699,6 +699,13 @@ font-size: 13px; } + .task-actions { + margin-top: 15px; + display: flex; + gap: 12px; + flex-wrap: wrap; + } + @keyframes spin { to { transform: rotate(360deg); } } @@ -801,51 +808,51 @@ } /* 不同Engine的颜色区分 */ -        .forum-message.agent:has(.forum-message-header:contains("Query Engine")) { -            background-color: #eaf1f8; -            border-color: #608ab1; -        } + .forum-message.agent:has(.forum-message-header:contains("Query Engine")) { + background-color: #eaf1f8; + border-color: #608ab1; + } .forum-message.agent:has(.forum-message-header:contains("QUERY Engine")) { -            background-color: #eaf1f8; -            border-color: #608ab1; -     } + background-color: #eaf1f8; + border-color: #608ab1; + } -        .forum-message.agent:has(.forum-message-header:contains("Insight Engine")) { -            background-color: #f2ebf3; -            border-color: #8e6a9f; -        } + .forum-message.agent:has(.forum-message-header:contains("Insight Engine")) { + background-color: #f2ebf3; + border-color: #8e6a9f; + } .forum-message.agent:has(.forum-message-header:contains("INSIGHT Engine")) { -            background-color: #f2ebf3; -            border-color: #8e6a9f; -        } + background-color: #f2ebf3; + border-color: #8e6a9f; + } -        .forum-message.agent:has(.forum-message-header:contains("Media Engine")) { -            background-color: #ebf2ea; -            border-color: #6a9a6e; -        } + .forum-message.agent:has(.forum-message-header:contains("Media Engine")) { + background-color: #ebf2ea; + border-color: #6a9a6e; + } .forum-message.agent:has(.forum-message-header:contains("MEDIA Engine")) { -            background-color: #ebf2ea; -            border-color: #6a9a6e; -        } + background-color: #ebf2ea; + border-color: #6a9a6e; + } -        /* 备用方案:通过JavaScript添加的类 */ -        .forum-message.query-engine { -            background-color: #eaf1f8; -            border-color: #608ab1; -        } + /* 备用方案:通过JavaScript添加的类 */ + .forum-message.query-engine { + background-color: #eaf1f8; + border-color: #608ab1; + } -        .forum-message.insight-engine { -            background-color: #f2ebf3; -            border-color: #8e6a9f; -        } + .forum-message.insight-engine { + background-color: #f2ebf3; + border-color: #8e6a9f; + } -        .forum-message.media-engine { -            background-color: #ebf2ea; -            border-color: #6a9a6e; -        } + .forum-message.media-engine { + background-color: #ebf2ea; + border-color: #6a9a6e; + } .forum-message.agent.QUERY { background-color: #eaf1f8; @@ -857,10 +864,10 @@ border-color: #608ab1; } -        .forum-message.agent.MEDIA { -            background-color: #ebf2ea; -            border-color: #6a9a6e; -        } + .forum-message.agent.MEDIA { + background-color: #ebf2ea; + border-color: #6a9a6e; + } .forum-message.agent.media-engine { background-color: #ebf2ea; @@ -2378,6 +2385,7 @@ // Report Engine 相关函数 let reportLogLineCount = 0; let reportLockCheckInterval = null; + let lastCompletedReportTask = null; // 实时刷新论坛消息(适用于所有页面) function refreshForumMessages() { @@ -2783,6 +2791,12 @@
正在初始化...
+ + +
+ + +
@@ -2796,19 +2810,146 @@ `; reportContent.innerHTML = interfaceHTML; + initializeReportControls(); // 立即更新状态信息 updateEngineStatusDisplay(statusData); // 如果有当前任务,显示任务状态 if (statusData.current_task) { - const taskArea = document.getElementById('taskProgressArea'); - if (taskArea) { - taskArea.innerHTML = renderTaskStatus(statusData.current_task); + updateTaskProgressStatus(statusData.current_task); + } else { + updateDownloadButtonState(null); + } + } + + function initializeReportControls() { + const generateButton = document.getElementById('generateReportButton'); + if (generateButton && !generateButton.dataset.bound) { + generateButton.dataset.bound = 'true'; + generateButton.addEventListener('click', () => { + if (reportTaskId) { + showMessage('已有报告生成任务在运行', 'info'); + return; + } + const reportButton = document.querySelector('[data-app="report"]'); + if (reportButton && reportButton.classList.contains('locked')) { + showMessage('需等待三个Agent完成最新分析后才能生成最终报告', 'error'); + return; + } + generateReport(); + }); + } + + const downloadButton = document.getElementById('downloadReportButton'); + if (downloadButton && !downloadButton.dataset.bound) { + downloadButton.dataset.bound = 'true'; + downloadButton.addEventListener('click', () => downloadReport()); + } + + if (reportTaskId) { + setGenerateButtonState(true); + } else { + setGenerateButtonState(false); + } + + if (lastCompletedReportTask) { + updateDownloadButtonState(lastCompletedReportTask); + } + } + + function setGenerateButtonState(forceLoading = false) { + const generateButton = document.getElementById('generateReportButton'); + if (!generateButton) return; + + if (forceLoading || reportTaskId) { + if (!generateButton.dataset.originalText) { + generateButton.dataset.originalText = generateButton.textContent || '生成最终报告'; + } + generateButton.disabled = true; + generateButton.textContent = '生成中...'; + } else { + const originalText = generateButton.dataset.originalText || '生成最终报告'; + generateButton.disabled = false; + generateButton.textContent = originalText; + } + } + + function updateDownloadButtonState(task) { + const downloadButton = document.getElementById('downloadReportButton'); + if (!downloadButton) return; + + if (task && task.status === 'completed' && (task.report_file_ready || task.report_file_path)) { + downloadButton.disabled = false; + downloadButton.dataset.taskId = task.task_id; + downloadButton.dataset.filename = task.report_file_name || ''; + const label = task.report_file_name ? `下载HTML (${task.report_file_name})` : '下载HTML'; + downloadButton.textContent = label; + lastCompletedReportTask = task; + } else if (!lastCompletedReportTask || (task && task.status !== 'completed')) { + downloadButton.disabled = true; + downloadButton.dataset.taskId = ''; + downloadButton.dataset.filename = ''; + downloadButton.textContent = '下载HTML'; + if (!reportTaskId) { + lastCompletedReportTask = null; } } } + function downloadReport(taskId = null) { + const downloadButton = document.getElementById('downloadReportButton'); + const targetTaskId = taskId || (downloadButton ? downloadButton.dataset.taskId : ''); + + if (!targetTaskId) { + showMessage('暂无可下载的报告,请先生成最终报告', 'error'); + return; + } + + let preferredFileName = ''; + if (downloadButton && downloadButton.dataset.filename) { + preferredFileName = downloadButton.dataset.filename; + } else if (lastCompletedReportTask && lastCompletedReportTask.task_id === targetTaskId) { + preferredFileName = lastCompletedReportTask.report_file_name || ''; + } + + fetch(`/api/report/download/${targetTaskId}`) + .then(response => { + if (!response.ok) { + const contentType = response.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { + return response.json().then(err => { + throw new Error(err.error || '下载失败'); + }); + } + throw new Error('下载失败'); + } + const disposition = response.headers.get('Content-Disposition') || ''; + return response.blob().then(blob => ({ blob, disposition })); + }) + .then(({ blob, disposition }) => { + let filename = preferredFileName; + if (!filename) { + const match = disposition.match(/filename="?([^";]+)"?/i); + filename = match ? match[1] : `final_report_${targetTaskId}.html`; + } + + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename || 'final_report.html'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + showMessage('报告文件已开始下载', 'success'); + }) + .catch(error => { + console.error('下载报告失败:', error); + showMessage('下载报告失败: ' + error.message, 'error'); + }); + } + // 渲染任务状态(使用新的进度条样式) function renderTaskStatus(task) { // 状态文本的中文映射 @@ -2863,7 +3004,18 @@ `; - + + if (task.report_file_path) { + statusHTML += ` +
+
+ 保存路径: + ${task.report_file_path} +
+
+ `; + } + if (task.error_message) { statusHTML += `
@@ -2871,13 +3023,33 @@
`; } - + + if (task.status === 'completed') { + statusHTML += ` +
+ + ${task.report_file_ready ? `` : ''} +
+ `; + } + statusHTML += ''; return statusHTML; } // 生成报告 function generateReport() { + if (reportTaskId) { + showMessage('已有报告生成任务在运行', 'info'); + return; + } + + const reportButton = document.querySelector('[data-app="report"]'); + if (reportButton && reportButton.classList.contains('locked')) { + showMessage('需等待三个Agent完成最新分析后才能生成最终报告', 'error'); + return; + } + const query = document.getElementById('searchInput').value.trim() || '智能舆情分析报告'; // 重置日志计数器,因为后台会清空日志文件 @@ -2887,8 +3059,8 @@ const consoleOutput = document.getElementById('consoleOutput'); consoleOutput.innerHTML = '
[系统] 开始生成报告,日志已重置
'; - // 按钮已移除,无需操作按钮状态 - + setGenerateButtonState(true); + // 在现有状态信息后添加任务进度状态,而不是替换 addTaskProgressStatus('正在启动报告生成任务...', 'loading'); @@ -2934,6 +3106,7 @@ // 重置标志允许重新尝试 autoGenerateTriggered = false; reportTaskId = null; + setGenerateButtonState(false); } }) .catch(error => { @@ -2942,6 +3115,7 @@ // 重置标志允许重新尝试 autoGenerateTriggered = false; reportTaskId = null; + setGenerateButtonState(false); }); } @@ -2977,6 +3151,7 @@ // 重置自动生成标志,允许下次有新内容时自动生成 autoGenerateTriggered = false; reportTaskId = null; + setGenerateButtonState(false); } else if (data.task.status === 'error') { clearInterval(reportPollingInterval); showMessage('报告生成失败: ' + data.task.error_message, 'error'); @@ -2984,6 +3159,7 @@ // 重置自动生成标志,允许重新尝试 autoGenerateTriggered = false; reportTaskId = null; + setGenerateButtonState(false); } } }) @@ -3020,11 +3196,17 @@ if (task) { taskArea.innerHTML = renderTaskStatus(task); + if (task.status === 'completed') { + lastCompletedReportTask = task; + } else if (task.status === 'running') { + lastCompletedReportTask = null; + } + updateDownloadButtonState(task); } else if (status && errorMessage) { const loadingIndicator = status === 'loading' ? '' : ''; const statusBadgeClass = status === 'error' ? 'task-status-error' : 'task-status-running'; const statusText = status === 'error' ? '错误' : '处理中'; - + taskArea.innerHTML = `
@@ -3253,4 +3435,4 @@ } - + \ No newline at end of file