BaiFu
Committed by GitHub

Add final report download button (#329)

* fix(app): 改进应用健康检查机制并更新默认配置

添加专用的健康检查路径和代理配置,重构健康检查URL构建逻辑
增加健康检查失败时的日志记录
延长应用启动等待时间至90秒

* style(templates): 统一CSS选择器缩进格式并修复空格问题

* feat(报告下载): 实现报告文件下载功能并增强任务状态管理

- 在ReportAgent中修改generate_report返回包含文件路径的字典
- 在ReportTask中添加文件路径相关字段
- 新增/download接口用于下载报告文件
- 在前端添加下载按钮及相关控制逻辑
- 完善任务状态显示,增加文件路径信息

* feat(report): 添加报告下载功能并优化状态管理

- 在ReportAgent中返回报告文件保存路径信息
- 新增Flask接口/download/<task_id>用于下载报告文件
- 在前端添加下载按钮及相关控制逻辑
- 修复报告生成状态重置问题
- 优化健康检查URL构建和代理设置
- 统一CSS样式中的空格和缩进

---------

Co-authored-by: HKLHaoBin <we3q@qq.com>
Co-authored-by: Zhang Yuxiang <51037789+NTFago@users.noreply.github.com>
  1 +@echo off
  2 +
  3 +REM 切换到当前 bat 文件所在目录(不怕有中文路径)
  4 +cd /d "%~dp0"
  5 +
  6 +REM 激活虚拟环境(注意:是 activate.bat,不是 Activate.ps1)
  7 +call .\myenv\Scripts\activate.bat
  8 +
  9 +REM 运行你的程序
  10 +python app.py
  11 +
  12 +REM 防止双击后窗口一闪而过(如果不需要可以删掉这一行)
  13 +pause
@@ -190,10 +190,16 @@ class ReportAgent: @@ -190,10 +190,16 @@ class ReportAgent:
190 save_report: 是否保存报告到文件 190 save_report: 是否保存报告到文件
191 191
192 Returns: 192 Returns:
193 - 最终HTML报告内容 193 + dict: 包含HTML内容与保存文件信息
194 """ 194 """
195 start_time = datetime.now() 195 start_time = datetime.now()
196 196
  197 + # 为新的查询重置状态,确保文件命名信息完整
  198 + self.state = ReportState(query=query)
  199 + self.state.metadata.query = query
  200 + self.state.query = query
  201 + self.state.mark_processing()
  202 +
197 logger.info(f"开始生成报告: {query}") 203 logger.info(f"开始生成报告: {query}")
198 logger.info(f"输入数据 - 报告数量: {len(reports)}, 论坛日志长度: {len(forum_logs)}") 204 logger.info(f"输入数据 - 报告数量: {len(reports)}, 论坛日志长度: {len(forum_logs)}")
199 205
@@ -205,8 +211,9 @@ class ReportAgent: @@ -205,8 +211,9 @@ class ReportAgent:
205 html_report = self._generate_html_report(query, reports, forum_logs, template_result) 211 html_report = self._generate_html_report(query, reports, forum_logs, template_result)
206 212
207 # Step 3: 保存报告 213 # Step 3: 保存报告
  214 + saved_files = {}
208 if save_report: 215 if save_report:
209 - self._save_report(html_report) 216 + saved_files = self._save_report(html_report)
210 217
211 # 更新生成时间 218 # 更新生成时间
212 end_time = datetime.now() 219 end_time = datetime.now()
@@ -215,7 +222,10 @@ class ReportAgent: @@ -215,7 +222,10 @@ class ReportAgent:
215 222
216 logger.info(f"报告生成完成,耗时: {generation_time:.2f} 秒") 223 logger.info(f"报告生成完成,耗时: {generation_time:.2f} 秒")
217 224
218 - return html_report 225 + return {
  226 + 'html_content': html_report,
  227 + **saved_files
  228 + }
219 229
220 except Exception as e: 230 except Exception as e:
221 logger.exception(f"报告生成过程中发生错误: {str(e)}") 231 logger.exception(f"报告生成过程中发生错误: {str(e)}")
@@ -357,13 +367,26 @@ class ReportAgent: @@ -357,13 +367,26 @@ class ReportAgent:
357 with open(filepath, 'w', encoding='utf-8') as f: 367 with open(filepath, 'w', encoding='utf-8') as f:
358 f.write(html_content) 368 f.write(html_content)
359 369
360 - logger.info(f"报告已保存到: {filepath}") 370 + abs_report_path = os.path.abspath(filepath)
  371 + rel_report_path = os.path.relpath(abs_report_path, os.getcwd())
  372 + logger.info(f"报告已保存到: {abs_report_path}")
361 373
362 # 保存状态 374 # 保存状态
363 state_filename = f"report_state_{query_safe}_{timestamp}.json" 375 state_filename = f"report_state_{query_safe}_{timestamp}.json"
364 state_filepath = os.path.join(self.config.OUTPUT_DIR, state_filename) 376 state_filepath = os.path.join(self.config.OUTPUT_DIR, state_filename)
365 self.state.save_to_file(state_filepath) 377 self.state.save_to_file(state_filepath)
366 - logger.info(f"状态已保存到: {state_filepath}") 378 + abs_state_path = os.path.abspath(state_filepath)
  379 + rel_state_path = os.path.relpath(abs_state_path, os.getcwd())
  380 + logger.info(f"状态已保存到: {abs_state_path}")
  381 +
  382 + return {
  383 + 'report_filename': filename,
  384 + 'report_filepath': abs_report_path,
  385 + 'report_relative_path': rel_report_path,
  386 + 'state_filename': state_filename,
  387 + 'state_filepath': abs_state_path,
  388 + 'state_relative_path': rel_state_path
  389 + }
367 390
368 def get_progress_summary(self) -> Dict[str, Any]: 391 def get_progress_summary(self) -> Dict[str, Any]:
369 """获取进度摘要""" 392 """获取进度摘要"""
@@ -8,7 +8,7 @@ import json @@ -8,7 +8,7 @@ import json
8 import threading 8 import threading
9 import time 9 import time
10 from datetime import datetime 10 from datetime import datetime
11 -from flask import Blueprint, request, jsonify, Response 11 +from flask import Blueprint, request, jsonify, Response, send_file
12 from typing import Dict, Any 12 from typing import Dict, Any
13 from loguru import logger 13 from loguru import logger
14 from .agent import ReportAgent, create_agent 14 from .agent import ReportAgent, create_agent
@@ -50,6 +50,11 @@ class ReportTask: @@ -50,6 +50,11 @@ class ReportTask:
50 self.created_at = datetime.now() 50 self.created_at = datetime.now()
51 self.updated_at = datetime.now() 51 self.updated_at = datetime.now()
52 self.html_content = "" 52 self.html_content = ""
  53 + self.report_file_path = ""
  54 + self.report_file_relative_path = ""
  55 + self.report_file_name = ""
  56 + self.state_file_path = ""
  57 + self.state_file_relative_path = ""
53 58
54 def update_status(self, status: str, progress: int = None, error_message: str = ""): 59 def update_status(self, status: str, progress: int = None, error_message: str = ""):
55 """更新任务状态""" 60 """更新任务状态"""
@@ -70,7 +75,10 @@ class ReportTask: @@ -70,7 +75,10 @@ class ReportTask:
70 'error_message': self.error_message, 75 'error_message': self.error_message,
71 'created_at': self.created_at.isoformat(), 76 'created_at': self.created_at.isoformat(),
72 'updated_at': self.updated_at.isoformat(), 77 'updated_at': self.updated_at.isoformat(),
73 - 'has_result': bool(self.html_content) 78 + 'has_result': bool(self.html_content),
  79 + 'report_file_ready': bool(self.report_file_path),
  80 + 'report_file_name': self.report_file_name,
  81 + 'report_file_path': self.report_file_relative_path
74 } 82 }
75 83
76 84
@@ -119,7 +127,7 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " @@ -119,7 +127,7 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = "
119 task.update_status("running", 50) 127 task.update_status("running", 50)
120 128
121 # 生成报告 129 # 生成报告
122 - html_report = report_agent.generate_report( 130 + generation_result = report_agent.generate_report(
123 query=query, 131 query=query,
124 reports=content['reports'], 132 reports=content['reports'],
125 forum_logs=content['forum_logs'], 133 forum_logs=content['forum_logs'],
@@ -127,10 +135,17 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " @@ -127,10 +135,17 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = "
127 save_report=True 135 save_report=True
128 ) 136 )
129 137
  138 + html_report = generation_result.get('html_content', '')
  139 +
130 task.update_status("running", 90) 140 task.update_status("running", 90)
131 141
132 # 保存结果 142 # 保存结果
133 task.html_content = html_report 143 task.html_content = html_report
  144 + task.report_file_path = generation_result.get('report_filepath', '')
  145 + task.report_file_relative_path = generation_result.get('report_relative_path', '')
  146 + task.report_file_name = generation_result.get('report_filename', '')
  147 + task.state_file_path = generation_result.get('state_filepath', '')
  148 + task.state_file_relative_path = generation_result.get('state_relative_path', '')
134 task.update_status("completed", 100) 149 task.update_status("completed", 100)
135 150
136 except Exception as e: 151 except Exception as e:
@@ -251,7 +266,10 @@ def get_progress(task_id: str): @@ -251,7 +266,10 @@ def get_progress(task_id: str):
251 'status': 'completed', 266 'status': 'completed',
252 'progress': 100, 267 'progress': 100,
253 'error_message': '', 268 'error_message': '',
254 - 'has_result': True 269 + 'has_result': True,
  270 + 'report_file_ready': False,
  271 + 'report_file_name': '',
  272 + 'report_file_path': ''
255 } 273 }
256 }) 274 })
257 275
@@ -329,6 +347,44 @@ def get_result_json(task_id: str): @@ -329,6 +347,44 @@ def get_result_json(task_id: str):
329 }), 500 347 }), 500
330 348
331 349
  350 +@report_bp.route('/download/<task_id>', methods=['GET'])
  351 +def download_report(task_id: str):
  352 + """下载已生成的报告HTML文件"""
  353 + try:
  354 + if not current_task or current_task.task_id != task_id:
  355 + return jsonify({
  356 + 'success': False,
  357 + 'error': '任务不存在'
  358 + }), 404
  359 +
  360 + if current_task.status != "completed" or not current_task.report_file_path:
  361 + return jsonify({
  362 + 'success': False,
  363 + 'error': '报告尚未完成或尚未保存'
  364 + }), 400
  365 +
  366 + if not os.path.exists(current_task.report_file_path):
  367 + return jsonify({
  368 + 'success': False,
  369 + 'error': '报告文件不存在或已被删除'
  370 + }), 404
  371 +
  372 + download_name = current_task.report_file_name or os.path.basename(current_task.report_file_path)
  373 + return send_file(
  374 + current_task.report_file_path,
  375 + mimetype='text/html',
  376 + as_attachment=True,
  377 + download_name=download_name
  378 + )
  379 +
  380 + except Exception as e:
  381 + logger.exception(f"下载报告失败: {str(e)}")
  382 + return jsonify({
  383 + 'success': False,
  384 + 'error': str(e)
  385 + }), 500
  386 +
  387 +
332 @report_bp.route('/cancel/<task_id>', methods=['POST']) 388 @report_bp.route('/cancel/<task_id>', methods=['POST'])
333 def cancel_task(task_id: str): 389 def cancel_task(task_id: str):
334 """取消报告生成任务""" 390 """取消报告生成任务"""
@@ -656,6 +656,14 @@ def stop_streamlit_app(app_name): @@ -656,6 +656,14 @@ def stop_streamlit_app(app_name):
656 except Exception as e: 656 except Exception as e:
657 return False, f"停止失败: {str(e)}" 657 return False, f"停止失败: {str(e)}"
658 658
  659 +HEALTHCHECK_PATH = "/_stcore/health"
  660 +HEALTHCHECK_PROXIES = {'http': None, 'https': None}
  661 +
  662 +
  663 +def _build_healthcheck_url(port):
  664 + return f"http://127.0.0.1:{port}{HEALTHCHECK_PATH}"
  665 +
  666 +
659 def check_app_status(): 667 def check_app_status():
660 """检查应用状态""" 668 """检查应用状态"""
661 for app_name, info in processes.items(): 669 for app_name, info in processes.items():
@@ -663,21 +671,24 @@ def check_app_status(): @@ -663,21 +671,24 @@ def check_app_status():
663 if info['process'].poll() is None: 671 if info['process'].poll() is None:
664 # 进程仍在运行,检查端口是否可访问 672 # 进程仍在运行,检查端口是否可访问
665 try: 673 try:
666 - response = requests.get(f"http://localhost:{info['port']}", timeout=2) 674 + response = requests.get(
  675 + _build_healthcheck_url(info['port']),
  676 + timeout=2,
  677 + proxies=HEALTHCHECK_PROXIES
  678 + )
667 if response.status_code == 200: 679 if response.status_code == 200:
668 info['status'] = 'running' 680 info['status'] = 'running'
669 else: 681 else:
670 info['status'] = 'starting' 682 info['status'] = 'starting'
671 - except requests.exceptions.RequestException:  
672 - info['status'] = 'starting'  
673 - except Exception: 683 + except Exception as exc:
  684 + logger.warning(f"{app_name} 健康检查失败: {exc}")
674 info['status'] = 'starting' 685 info['status'] = 'starting'
675 else: 686 else:
676 # 进程已结束 687 # 进程已结束
677 info['process'] = None 688 info['process'] = None
678 info['status'] = 'stopped' 689 info['status'] = 'stopped'
679 690
680 -def wait_for_app_startup(app_name, max_wait_time=30): 691 +def wait_for_app_startup(app_name, max_wait_time=90):
681 """等待应用启动完成""" 692 """等待应用启动完成"""
682 import time 693 import time
683 start_time = time.time() 694 start_time = time.time()
@@ -690,12 +701,16 @@ def wait_for_app_startup(app_name, max_wait_time=30): @@ -690,12 +701,16 @@ def wait_for_app_startup(app_name, max_wait_time=30):
690 return False, "进程启动失败" 701 return False, "进程启动失败"
691 702
692 try: 703 try:
693 - response = requests.get(f"http://localhost:{info['port']}", timeout=2) 704 + response = requests.get(
  705 + _build_healthcheck_url(info['port']),
  706 + timeout=2,
  707 + proxies=HEALTHCHECK_PROXIES
  708 + )
694 if response.status_code == 200: 709 if response.status_code == 200:
695 info['status'] = 'running' 710 info['status'] = 'running'
696 return True, "启动成功" 711 return True, "启动成功"
697 - except:  
698 - pass 712 + except Exception as exc:
  713 + logger.warning(f"{app_name} 健康检查失败: {exc}")
699 714
700 time.sleep(1) 715 time.sleep(1)
701 716
@@ -699,6 +699,13 @@ @@ -699,6 +699,13 @@
699 font-size: 13px; 699 font-size: 13px;
700 } 700 }
701 701
  702 + .task-actions {
  703 + margin-top: 15px;
  704 + display: flex;
  705 + gap: 12px;
  706 + flex-wrap: wrap;
  707 + }
  708 +
702 @keyframes spin { 709 @keyframes spin {
703 to { transform: rotate(360deg); } 710 to { transform: rotate(360deg); }
704 } 711 }
@@ -801,51 +808,51 @@ @@ -801,51 +808,51 @@
801 } 808 }
802 809
803 /* 不同Engine的颜色区分 */ 810 /* 不同Engine的颜色区分 */
804 -        .forum-message.agent:has(.forum-message-header:contains("Query Engine")) {  
805 -            background-color: #eaf1f8;  
806 -            border-color: #608ab1;  
807 -        } 811 + .forum-message.agent:has(.forum-message-header:contains("Query Engine")) {
  812 + background-color: #eaf1f8;
  813 + border-color: #608ab1;
  814 + }
808 815
809 .forum-message.agent:has(.forum-message-header:contains("QUERY Engine")) { 816 .forum-message.agent:has(.forum-message-header:contains("QUERY Engine")) {
810 -            background-color: #eaf1f8;  
811 -            border-color: #608ab1;  
812 -     } 817 + background-color: #eaf1f8;
  818 + border-color: #608ab1;
  819 + }
813 820
814 -        .forum-message.agent:has(.forum-message-header:contains("Insight Engine")) {  
815 -            background-color: #f2ebf3;  
816 -            border-color: #8e6a9f;  
817 -        } 821 + .forum-message.agent:has(.forum-message-header:contains("Insight Engine")) {
  822 + background-color: #f2ebf3;
  823 + border-color: #8e6a9f;
  824 + }
818 825
819 .forum-message.agent:has(.forum-message-header:contains("INSIGHT Engine")) { 826 .forum-message.agent:has(.forum-message-header:contains("INSIGHT Engine")) {
820 -            background-color: #f2ebf3;  
821 -            border-color: #8e6a9f;  
822 -        } 827 + background-color: #f2ebf3;
  828 + border-color: #8e6a9f;
  829 + }
823 830
824 -        .forum-message.agent:has(.forum-message-header:contains("Media Engine")) {  
825 -            background-color: #ebf2ea;  
826 -            border-color: #6a9a6e;  
827 -        } 831 + .forum-message.agent:has(.forum-message-header:contains("Media Engine")) {
  832 + background-color: #ebf2ea;
  833 + border-color: #6a9a6e;
  834 + }
828 835
829 .forum-message.agent:has(.forum-message-header:contains("MEDIA Engine")) { 836 .forum-message.agent:has(.forum-message-header:contains("MEDIA Engine")) {
830 -            background-color: #ebf2ea;  
831 -            border-color: #6a9a6e;  
832 -        }  
833 -  
834 -        /* 备用方案:通过JavaScript添加的类 */  
835 -        .forum-message.query-engine {  
836 -            background-color: #eaf1f8;  
837 -            border-color: #608ab1;  
838 -        }  
839 -  
840 -        .forum-message.insight-engine {  
841 -            background-color: #f2ebf3;  
842 -            border-color: #8e6a9f;  
843 -        }  
844 -  
845 -        .forum-message.media-engine {  
846 -            background-color: #ebf2ea;  
847 -            border-color: #6a9a6e;  
848 -        } 837 + background-color: #ebf2ea;
  838 + border-color: #6a9a6e;
  839 + }
  840 +
  841 + /* 备用方案:通过JavaScript添加的类 */
  842 + .forum-message.query-engine {
  843 + background-color: #eaf1f8;
  844 + border-color: #608ab1;
  845 + }
  846 +
  847 + .forum-message.insight-engine {
  848 + background-color: #f2ebf3;
  849 + border-color: #8e6a9f;
  850 + }
  851 +
  852 + .forum-message.media-engine {
  853 + background-color: #ebf2ea;
  854 + border-color: #6a9a6e;
  855 + }
849 856
850 .forum-message.agent.QUERY { 857 .forum-message.agent.QUERY {
851 background-color: #eaf1f8; 858 background-color: #eaf1f8;
@@ -857,10 +864,10 @@ @@ -857,10 +864,10 @@
857 border-color: #608ab1; 864 border-color: #608ab1;
858 } 865 }
859 866
860 -        .forum-message.agent.MEDIA {  
861 -            background-color: #ebf2ea;  
862 -            border-color: #6a9a6e;  
863 -        } 867 + .forum-message.agent.MEDIA {
  868 + background-color: #ebf2ea;
  869 + border-color: #6a9a6e;
  870 + }
864 871
865 .forum-message.agent.media-engine { 872 .forum-message.agent.media-engine {
866 background-color: #ebf2ea; 873 background-color: #ebf2ea;
@@ -2378,6 +2385,7 @@ @@ -2378,6 +2385,7 @@
2378 // Report Engine 相关函数 2385 // Report Engine 相关函数
2379 let reportLogLineCount = 0; 2386 let reportLogLineCount = 0;
2380 let reportLockCheckInterval = null; 2387 let reportLockCheckInterval = null;
  2388 + let lastCompletedReportTask = null;
2381 2389
2382 // 实时刷新论坛消息(适用于所有页面) 2390 // 实时刷新论坛消息(适用于所有页面)
2383 function refreshForumMessages() { 2391 function refreshForumMessages() {
@@ -2784,6 +2792,12 @@ @@ -2784,6 +2792,12 @@
2784 </div> 2792 </div>
2785 </div> 2793 </div>
2786 2794
  2795 + <!-- 控制按钮区域 -->
  2796 + <div class="report-controls">
  2797 + <button class="report-button primary" id="generateReportButton">生成最终报告</button>
  2798 + <button class="report-button" id="downloadReportButton" disabled>下载HTML</button>
  2799 + </div>
  2800 +
2787 <!-- 任务进度区域 --> 2801 <!-- 任务进度区域 -->
2788 <div id="taskProgressArea"></div> 2802 <div id="taskProgressArea"></div>
2789 2803
@@ -2796,17 +2810,144 @@ @@ -2796,17 +2810,144 @@
2796 `; 2810 `;
2797 2811
2798 reportContent.innerHTML = interfaceHTML; 2812 reportContent.innerHTML = interfaceHTML;
  2813 + initializeReportControls();
2799 2814
2800 // 立即更新状态信息 2815 // 立即更新状态信息
2801 updateEngineStatusDisplay(statusData); 2816 updateEngineStatusDisplay(statusData);
2802 2817
2803 // 如果有当前任务,显示任务状态 2818 // 如果有当前任务,显示任务状态
2804 if (statusData.current_task) { 2819 if (statusData.current_task) {
2805 - const taskArea = document.getElementById('taskProgressArea');  
2806 - if (taskArea) {  
2807 - taskArea.innerHTML = renderTaskStatus(statusData.current_task); 2820 + updateTaskProgressStatus(statusData.current_task);
  2821 + } else {
  2822 + updateDownloadButtonState(null);
2808 } 2823 }
2809 } 2824 }
  2825 +
  2826 + function initializeReportControls() {
  2827 + const generateButton = document.getElementById('generateReportButton');
  2828 + if (generateButton && !generateButton.dataset.bound) {
  2829 + generateButton.dataset.bound = 'true';
  2830 + generateButton.addEventListener('click', () => {
  2831 + if (reportTaskId) {
  2832 + showMessage('已有报告生成任务在运行', 'info');
  2833 + return;
  2834 + }
  2835 + const reportButton = document.querySelector('[data-app="report"]');
  2836 + if (reportButton && reportButton.classList.contains('locked')) {
  2837 + showMessage('需等待三个Agent完成最新分析后才能生成最终报告', 'error');
  2838 + return;
  2839 + }
  2840 + generateReport();
  2841 + });
  2842 + }
  2843 +
  2844 + const downloadButton = document.getElementById('downloadReportButton');
  2845 + if (downloadButton && !downloadButton.dataset.bound) {
  2846 + downloadButton.dataset.bound = 'true';
  2847 + downloadButton.addEventListener('click', () => downloadReport());
  2848 + }
  2849 +
  2850 + if (reportTaskId) {
  2851 + setGenerateButtonState(true);
  2852 + } else {
  2853 + setGenerateButtonState(false);
  2854 + }
  2855 +
  2856 + if (lastCompletedReportTask) {
  2857 + updateDownloadButtonState(lastCompletedReportTask);
  2858 + }
  2859 + }
  2860 +
  2861 + function setGenerateButtonState(forceLoading = false) {
  2862 + const generateButton = document.getElementById('generateReportButton');
  2863 + if (!generateButton) return;
  2864 +
  2865 + if (forceLoading || reportTaskId) {
  2866 + if (!generateButton.dataset.originalText) {
  2867 + generateButton.dataset.originalText = generateButton.textContent || '生成最终报告';
  2868 + }
  2869 + generateButton.disabled = true;
  2870 + generateButton.textContent = '生成中...';
  2871 + } else {
  2872 + const originalText = generateButton.dataset.originalText || '生成最终报告';
  2873 + generateButton.disabled = false;
  2874 + generateButton.textContent = originalText;
  2875 + }
  2876 + }
  2877 +
  2878 + function updateDownloadButtonState(task) {
  2879 + const downloadButton = document.getElementById('downloadReportButton');
  2880 + if (!downloadButton) return;
  2881 +
  2882 + if (task && task.status === 'completed' && (task.report_file_ready || task.report_file_path)) {
  2883 + downloadButton.disabled = false;
  2884 + downloadButton.dataset.taskId = task.task_id;
  2885 + downloadButton.dataset.filename = task.report_file_name || '';
  2886 + const label = task.report_file_name ? `下载HTML (${task.report_file_name})` : '下载HTML';
  2887 + downloadButton.textContent = label;
  2888 + lastCompletedReportTask = task;
  2889 + } else if (!lastCompletedReportTask || (task && task.status !== 'completed')) {
  2890 + downloadButton.disabled = true;
  2891 + downloadButton.dataset.taskId = '';
  2892 + downloadButton.dataset.filename = '';
  2893 + downloadButton.textContent = '下载HTML';
  2894 + if (!reportTaskId) {
  2895 + lastCompletedReportTask = null;
  2896 + }
  2897 + }
  2898 + }
  2899 +
  2900 + function downloadReport(taskId = null) {
  2901 + const downloadButton = document.getElementById('downloadReportButton');
  2902 + const targetTaskId = taskId || (downloadButton ? downloadButton.dataset.taskId : '');
  2903 +
  2904 + if (!targetTaskId) {
  2905 + showMessage('暂无可下载的报告,请先生成最终报告', 'error');
  2906 + return;
  2907 + }
  2908 +
  2909 + let preferredFileName = '';
  2910 + if (downloadButton && downloadButton.dataset.filename) {
  2911 + preferredFileName = downloadButton.dataset.filename;
  2912 + } else if (lastCompletedReportTask && lastCompletedReportTask.task_id === targetTaskId) {
  2913 + preferredFileName = lastCompletedReportTask.report_file_name || '';
  2914 + }
  2915 +
  2916 + fetch(`/api/report/download/${targetTaskId}`)
  2917 + .then(response => {
  2918 + if (!response.ok) {
  2919 + const contentType = response.headers.get('Content-Type') || '';
  2920 + if (contentType.includes('application/json')) {
  2921 + return response.json().then(err => {
  2922 + throw new Error(err.error || '下载失败');
  2923 + });
  2924 + }
  2925 + throw new Error('下载失败');
  2926 + }
  2927 + const disposition = response.headers.get('Content-Disposition') || '';
  2928 + return response.blob().then(blob => ({ blob, disposition }));
  2929 + })
  2930 + .then(({ blob, disposition }) => {
  2931 + let filename = preferredFileName;
  2932 + if (!filename) {
  2933 + const match = disposition.match(/filename="?([^";]+)"?/i);
  2934 + filename = match ? match[1] : `final_report_${targetTaskId}.html`;
  2935 + }
  2936 +
  2937 + const url = window.URL.createObjectURL(blob);
  2938 + const link = document.createElement('a');
  2939 + link.href = url;
  2940 + link.download = filename || 'final_report.html';
  2941 + document.body.appendChild(link);
  2942 + link.click();
  2943 + document.body.removeChild(link);
  2944 + window.URL.revokeObjectURL(url);
  2945 + showMessage('报告文件已开始下载', 'success');
  2946 + })
  2947 + .catch(error => {
  2948 + console.error('下载报告失败:', error);
  2949 + showMessage('下载报告失败: ' + error.message, 'error');
  2950 + });
2810 } 2951 }
2811 2952
2812 // 渲染任务状态(使用新的进度条样式) 2953 // 渲染任务状态(使用新的进度条样式)
@@ -2864,6 +3005,17 @@ @@ -2864,6 +3005,17 @@
2864 </div> 3005 </div>
2865 `; 3006 `;
2866 3007
  3008 + if (task.report_file_path) {
  3009 + statusHTML += `
  3010 + <div class="task-info-line">
  3011 + <div class="task-info-item">
  3012 + <span class="task-info-label">保存路径:</span>
  3013 + <span class="task-info-value">${task.report_file_path}</span>
  3014 + </div>
  3015 + </div>
  3016 + `;
  3017 + }
  3018 +
2867 if (task.error_message) { 3019 if (task.error_message) {
2868 statusHTML += ` 3020 statusHTML += `
2869 <div class="task-error-message"> 3021 <div class="task-error-message">
@@ -2872,12 +3024,32 @@ @@ -2872,12 +3024,32 @@
2872 `; 3024 `;
2873 } 3025 }
2874 3026
  3027 + if (task.status === 'completed') {
  3028 + statusHTML += `
  3029 + <div class="task-actions">
  3030 + <button class="report-button primary" onclick="viewReport('${task.task_id}')">重新加载</button>
  3031 + ${task.report_file_ready ? `<button class="report-button" onclick="downloadReport('${task.task_id}')">下载HTML</button>` : ''}
  3032 + </div>
  3033 + `;
  3034 + }
  3035 +
2875 statusHTML += '</div>'; 3036 statusHTML += '</div>';
2876 return statusHTML; 3037 return statusHTML;
2877 } 3038 }
2878 3039
2879 // 生成报告 3040 // 生成报告
2880 function generateReport() { 3041 function generateReport() {
  3042 + if (reportTaskId) {
  3043 + showMessage('已有报告生成任务在运行', 'info');
  3044 + return;
  3045 + }
  3046 +
  3047 + const reportButton = document.querySelector('[data-app="report"]');
  3048 + if (reportButton && reportButton.classList.contains('locked')) {
  3049 + showMessage('需等待三个Agent完成最新分析后才能生成最终报告', 'error');
  3050 + return;
  3051 + }
  3052 +
2881 const query = document.getElementById('searchInput').value.trim() || '智能舆情分析报告'; 3053 const query = document.getElementById('searchInput').value.trim() || '智能舆情分析报告';
2882 3054
2883 // 重置日志计数器,因为后台会清空日志文件 3055 // 重置日志计数器,因为后台会清空日志文件
@@ -2887,7 +3059,7 @@ @@ -2887,7 +3059,7 @@
2887 const consoleOutput = document.getElementById('consoleOutput'); 3059 const consoleOutput = document.getElementById('consoleOutput');
2888 consoleOutput.innerHTML = '<div class="console-line">[系统] 开始生成报告,日志已重置</div>'; 3060 consoleOutput.innerHTML = '<div class="console-line">[系统] 开始生成报告,日志已重置</div>';
2889 3061
2890 - // 按钮已移除,无需操作按钮状态 3062 + setGenerateButtonState(true);
2891 3063
2892 // 在现有状态信息后添加任务进度状态,而不是替换 3064 // 在现有状态信息后添加任务进度状态,而不是替换
2893 addTaskProgressStatus('正在启动报告生成任务...', 'loading'); 3065 addTaskProgressStatus('正在启动报告生成任务...', 'loading');
@@ -2934,6 +3106,7 @@ @@ -2934,6 +3106,7 @@
2934 // 重置标志允许重新尝试 3106 // 重置标志允许重新尝试
2935 autoGenerateTriggered = false; 3107 autoGenerateTriggered = false;
2936 reportTaskId = null; 3108 reportTaskId = null;
  3109 + setGenerateButtonState(false);
2937 } 3110 }
2938 }) 3111 })
2939 .catch(error => { 3112 .catch(error => {
@@ -2942,6 +3115,7 @@ @@ -2942,6 +3115,7 @@
2942 // 重置标志允许重新尝试 3115 // 重置标志允许重新尝试
2943 autoGenerateTriggered = false; 3116 autoGenerateTriggered = false;
2944 reportTaskId = null; 3117 reportTaskId = null;
  3118 + setGenerateButtonState(false);
2945 }); 3119 });
2946 } 3120 }
2947 3121
@@ -2977,6 +3151,7 @@ @@ -2977,6 +3151,7 @@
2977 // 重置自动生成标志,允许下次有新内容时自动生成 3151 // 重置自动生成标志,允许下次有新内容时自动生成
2978 autoGenerateTriggered = false; 3152 autoGenerateTriggered = false;
2979 reportTaskId = null; 3153 reportTaskId = null;
  3154 + setGenerateButtonState(false);
2980 } else if (data.task.status === 'error') { 3155 } else if (data.task.status === 'error') {
2981 clearInterval(reportPollingInterval); 3156 clearInterval(reportPollingInterval);
2982 showMessage('报告生成失败: ' + data.task.error_message, 'error'); 3157 showMessage('报告生成失败: ' + data.task.error_message, 'error');
@@ -2984,6 +3159,7 @@ @@ -2984,6 +3159,7 @@
2984 // 重置自动生成标志,允许重新尝试 3159 // 重置自动生成标志,允许重新尝试
2985 autoGenerateTriggered = false; 3160 autoGenerateTriggered = false;
2986 reportTaskId = null; 3161 reportTaskId = null;
  3162 + setGenerateButtonState(false);
2987 } 3163 }
2988 } 3164 }
2989 }) 3165 })
@@ -3020,6 +3196,12 @@ @@ -3020,6 +3196,12 @@
3020 3196
3021 if (task) { 3197 if (task) {
3022 taskArea.innerHTML = renderTaskStatus(task); 3198 taskArea.innerHTML = renderTaskStatus(task);
  3199 + if (task.status === 'completed') {
  3200 + lastCompletedReportTask = task;
  3201 + } else if (task.status === 'running') {
  3202 + lastCompletedReportTask = null;
  3203 + }
  3204 + updateDownloadButtonState(task);
3023 } else if (status && errorMessage) { 3205 } else if (status && errorMessage) {
3024 const loadingIndicator = status === 'loading' ? '<span class="report-loading-spinner"></span>' : ''; 3206 const loadingIndicator = status === 'loading' ? '<span class="report-loading-spinner"></span>' : '';
3025 const statusBadgeClass = status === 'error' ? 'task-status-error' : 'task-status-running'; 3207 const statusBadgeClass = status === 'error' ? 'task-status-error' : 'task-status-running';