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>
@echo off
REM 切换到当前 bat 文件所在目录(不怕有中文路径)
cd /d "%~dp0"
REM 激活虚拟环境(注意:是 activate.bat,不是 Activate.ps1)
call .\myenv\Scripts\activate.bat
REM 运行你的程序
python app.py
REM 防止双击后窗口一闪而过(如果不需要可以删掉这一行)
pause
... ...
... ... @@ -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
... ...
... ... @@ -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/<task_id>', 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/<task_id>', 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
... ...
... ... @@ -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
... ...
... ... @@ -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;
        }
        /* 备用方案:通过JavaScript添加的类 */
        .forum-message.query-engine {
            background-color: #eaf1f8;
            border-color: #608ab1;
        }
        .forum-message.insight-engine {
            background-color: #f2ebf3;
            border-color: #8e6a9f;
        }
        .forum-message.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;
}
.forum-message.insight-engine {
background-color: #f2ebf3;
border-color: #8e6a9f;
}
.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 @@
<div>正在初始化...</div>
</div>
</div>
<!-- 控制按钮区域 -->
<div class="report-controls">
<button class="report-button primary" id="generateReportButton">生成最终报告</button>
<button class="report-button" id="downloadReportButton" disabled>下载HTML</button>
</div>
<!-- 任务进度区域 -->
<div id="taskProgressArea"></div>
... ... @@ -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 @@
</div>
</div>
`;
if (task.report_file_path) {
statusHTML += `
<div class="task-info-line">
<div class="task-info-item">
<span class="task-info-label">保存路径:</span>
<span class="task-info-value">${task.report_file_path}</span>
</div>
</div>
`;
}
if (task.error_message) {
statusHTML += `
<div class="task-error-message">
... ... @@ -2871,13 +3023,33 @@
</div>
`;
}
if (task.status === 'completed') {
statusHTML += `
<div class="task-actions">
<button class="report-button primary" onclick="viewReport('${task.task_id}')">重新加载</button>
${task.report_file_ready ? `<button class="report-button" onclick="downloadReport('${task.task_id}')">下载HTML</button>` : ''}
</div>
`;
}
statusHTML += '</div>';
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 = '<div class="console-line">[系统] 开始生成报告,日志已重置</div>';
// 按钮已移除,无需操作按钮状态
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' ? '<span class="report-loading-spinner"></span>' : '';
const statusBadgeClass = status === 'error' ? 'task-status-error' : 'task-status-running';
const statusText = status === 'error' ? '错误' : '处理中';
taskArea.innerHTML = `
<div class="task-progress-container">
<div class="task-progress-header">
... ... @@ -3253,4 +3435,4 @@
}
</script>
</body>
</html>
</html>
\ No newline at end of file
... ...