diff --git a/backend/app/api/report.py b/backend/app/api/report.py index d7f2a4d0..2aca8241 100644 --- a/backend/app/api/report.py +++ b/backend/app/api/report.py @@ -6,6 +6,7 @@ Report API路由 import os import traceback import threading +from typing import Optional from flask import request, jsonify, send_file from . import report_bp @@ -33,9 +34,10 @@ def generate_report(): 请求(JSON): { "simulation_id": "sim_xxxx", // 必填,模拟ID - "force_regenerate": false // 可选,强制重新生成 + "force_regenerate": false, // 可选,强制重新生成(清空已有报告) + "resume": false // 可选,从已失败的报告恢复生成(沿用大纲和已完成章节) } - + 返回: { "success": true, @@ -49,7 +51,7 @@ def generate_report(): """ try: data = request.get_json() or {} - + simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ @@ -58,20 +60,30 @@ def generate_report(): }), 400 force_regenerate = data.get('force_regenerate', False) - + resume = data.get('resume', False) + # 获取模拟信息 manager = SimulationManager() state = manager.get_simulation(simulation_id) - + if not state: return jsonify({ "success": False, "error": t('api.simulationNotFound', id=simulation_id) }), 404 - # 检查是否已有报告 - if not force_regenerate: - existing_report = ReportManager.get_report_by_simulation(simulation_id) + # Resume 模式:查找已失败的报告并复用其 report_id + existing_report = ReportManager.get_report_by_simulation(simulation_id) + resume_report_id: Optional[str] = None + if resume: + if existing_report and existing_report.status != ReportStatus.COMPLETED: + resume_report_id = existing_report.report_id + else: + # 没有可恢复的报告,降级为全新生成 + resume = False + + # 检查是否已有报告(非 resume / 非 force_regenerate 情况) + if not force_regenerate and not resume: if existing_report and existing_report.status == ReportStatus.COMPLETED: return jsonify({ "success": True, @@ -83,7 +95,7 @@ def generate_report(): "already_generated": True } }) - + # 获取项目信息 project = ProjectManager.get_project(state.project_id) if not project: @@ -91,24 +103,27 @@ def generate_report(): "success": False, "error": t('api.projectNotFound', id=state.project_id) }), 404 - + graph_id = state.graph_id or project.graph_id if not graph_id: return jsonify({ "success": False, "error": t('api.missingGraphIdEnsure') }), 400 - + simulation_requirement = project.simulation_requirement if not simulation_requirement: return jsonify({ "success": False, "error": t('api.missingSimRequirement') }), 400 - - # 提前生成 report_id,以便立即返回给前端 - import uuid - report_id = f"report_{uuid.uuid4().hex[:12]}" + + # Resume 时复用已有 report_id,否则生成新的 + if resume_report_id: + report_id = resume_report_id + else: + import uuid + report_id = f"report_{uuid.uuid4().hex[:12]}" # 创建异步任务 task_manager = TaskManager() @@ -150,10 +165,11 @@ def generate_report(): message=f"[{stage}] {message}" ) - # 生成报告(传入预先生成的 report_id) + # 生成报告(传入预先生成的 report_id;resume=True 时复用已有大纲和章节) report = agent.generate_report( progress_callback=progress_callback, - report_id=report_id + report_id=report_id, + resume=resume ) # 保存报告 diff --git a/backend/app/services/report_agent.py b/backend/app/services/report_agent.py index cecd70b4..3d86d87f 100644 --- a/backend/app/services/report_agent.py +++ b/backend/app/services/report_agent.py @@ -1530,13 +1530,14 @@ class ReportAgent: return final_answer def generate_report( - self, + self, progress_callback: Optional[Callable[[str, int, str], None]] = None, - report_id: Optional[str] = None + report_id: Optional[str] = None, + resume: bool = False ) -> Report: """ 生成完整报告(分章节实时输出) - + 每个章节生成完成后立即保存到文件夹,不需要等待整个报告完成。 文件结构: reports/{report_id}/ @@ -1547,21 +1548,22 @@ class ReportAgent: section_02.md - 第2章节 ... full_report.md - 完整报告 - + Args: progress_callback: 进度回调函数 (stage, progress, message) report_id: 报告ID(可选,如果不传则自动生成) - + resume: 是否从已保存的大纲和章节恢复生成(跳过规划阶段和已完成章节) + Returns: Report: 完整报告 """ import uuid - + # 如果没有传入 report_id,则自动生成 if not report_id: report_id = f"report_{uuid.uuid4().hex[:12]}" start_time = datetime.now() - + report = Report( report_id=report_id, simulation_id=self.simulation_id, @@ -1570,73 +1572,142 @@ class ReportAgent: status=ReportStatus.PENDING, created_at=datetime.now().isoformat() ) - + # 已完成的章节标题列表(用于进度追踪) completed_section_titles = [] - + try: # 初始化:创建报告文件夹并保存初始状态 ReportManager._ensure_report_folder(report_id) - + # 初始化日志记录器(结构化日志 agent_log.jsonl) self.report_logger = ReportLogger(report_id) - self.report_logger.log_start( - simulation_id=self.simulation_id, - graph_id=self.graph_id, - simulation_requirement=self.simulation_requirement - ) - # 初始化控制台日志记录器(console_log.txt) self.console_logger = ReportConsoleLogger(report_id) - - ReportManager.update_progress( - report_id, "pending", 0, t('progress.initReport'), - completed_sections=[] - ) - ReportManager.save_report(report) - - # 阶段1: 规划大纲 - report.status = ReportStatus.PLANNING - ReportManager.update_progress( - report_id, "planning", 5, t('progress.startPlanningOutline'), - completed_sections=[] - ) - - # 记录规划开始日志 - self.report_logger.log_planning_start() - - if progress_callback: - progress_callback("planning", 0, t('progress.startPlanningOutline')) - - outline = self.plan_outline( - progress_callback=lambda stage, prog, msg: - progress_callback(stage, prog // 5, msg) if progress_callback else None - ) - report.outline = outline - - # 记录规划完成日志 - self.report_logger.log_planning_complete(outline.to_dict()) - - # 保存大纲到文件 - ReportManager.save_outline(report_id, outline) - ReportManager.update_progress( - report_id, "planning", 15, t('progress.outlineDone', count=len(outline.sections)), - completed_sections=[] - ) - ReportManager.save_report(report) - - logger.info(t('report.outlineSavedToFile', reportId=report_id)) - - # 阶段2: 逐章节生成(分章节保存) - report.status = ReportStatus.GENERATING - + + # 尝试从已保存状态恢复(resume 模式) + existing_outline: Optional[ReportOutline] = None + existing_sections: Dict[int, str] = {} + if resume: + existing_outline = ReportManager.load_outline(report_id) + if existing_outline: + for sec_info in ReportManager.get_generated_sections(report_id): + existing_sections[sec_info['section_index']] = sec_info['content'] + logger.info(t('report.resumeStart', reportId=report_id, count=len(existing_sections))) + self.report_logger.log( + action="resume_start", + stage="generating", + details={ + "completed_sections_count": len(existing_sections), + "total_sections": len(existing_outline.sections), + "message": t('report.resumeStart', reportId=report_id, count=len(existing_sections)) + } + ) + + if existing_outline is not None: + # Resume 路径:沿用已保存的大纲,跳过规划阶段 + outline = existing_outline + report.outline = outline + report.status = ReportStatus.GENERATING + ReportManager.update_progress( + report_id, "generating", 15, + t('progress.resumeStart', count=len(existing_sections)), + completed_sections=[] + ) + ReportManager.save_report(report) + else: + # 全新生成:从头规划 + self.report_logger.log_start( + simulation_id=self.simulation_id, + graph_id=self.graph_id, + simulation_requirement=self.simulation_requirement + ) + + ReportManager.update_progress( + report_id, "pending", 0, t('progress.initReport'), + completed_sections=[] + ) + ReportManager.save_report(report) + + # 阶段1: 规划大纲 + report.status = ReportStatus.PLANNING + ReportManager.update_progress( + report_id, "planning", 5, t('progress.startPlanningOutline'), + completed_sections=[] + ) + + # 记录规划开始日志 + self.report_logger.log_planning_start() + + if progress_callback: + progress_callback("planning", 0, t('progress.startPlanningOutline')) + + outline = self.plan_outline( + progress_callback=lambda stage, prog, msg: + progress_callback(stage, prog // 5, msg) if progress_callback else None + ) + report.outline = outline + + # 记录规划完成日志 + self.report_logger.log_planning_complete(outline.to_dict()) + + # 保存大纲到文件 + ReportManager.save_outline(report_id, outline) + ReportManager.update_progress( + report_id, "planning", 15, t('progress.outlineDone', count=len(outline.sections)), + completed_sections=[] + ) + ReportManager.save_report(report) + + logger.info(t('report.outlineSavedToFile', reportId=report_id)) + + # 阶段2: 逐章节生成(分章节保存) + report.status = ReportStatus.GENERATING + total_sections = len(outline.sections) generated_sections = [] # 保存内容用于上下文 - + for i, section in enumerate(outline.sections): section_num = i + 1 base_progress = 20 + int((i / total_sections) * 70) - + + # Resume 模式:复用已保存的章节 + if section_num in existing_sections: + saved_md = existing_sections[section_num] + header_prefix = f"## {section.title}\n\n" + stripped = saved_md[len(header_prefix):] if saved_md.startswith(header_prefix) else saved_md + section.content = stripped.strip() + generated_sections.append(f"## {section.title}\n\n{section.content}") + completed_section_titles.append(section.title) + + if self.report_logger: + self.report_logger.log( + action="section_resumed", + stage="generating", + section_title=section.title, + section_index=section_num, + details={ + "content": saved_md, + "message": t('report.sectionResumed', title=section.title), + } + ) + + ReportManager.update_progress( + report_id, "generating", + base_progress + int(70 / total_sections), + t('progress.sectionResumed', title=section.title), + current_section=None, + completed_sections=completed_section_titles + ) + + if progress_callback: + progress_callback( + "generating", + base_progress + int(70 / total_sections), + t('progress.sectionResumed', title=section.title) + ) + continue + # 更新进度 ReportManager.update_progress( report_id, "generating", base_progress, @@ -1651,7 +1722,7 @@ class ReportAgent: base_progress, t('progress.generatingSection', title=section.title, current=section_num, total=total_sections) ) - + # 生成主章节内容 section_content = self._generate_section_react( section=section, @@ -1659,13 +1730,13 @@ class ReportAgent: previous_sections=generated_sections, progress_callback=lambda stage, prog, msg: progress_callback( - stage, + stage, base_progress + int(prog * 0.7 / total_sections), msg ) if progress_callback else None, section_index=section_num ) - + section.content = section_content generated_sections.append(f"## {section.title}\n\n{section_content}") @@ -2081,15 +2152,37 @@ class ReportManager: def save_outline(cls, report_id: str, outline: ReportOutline) -> None: """ 保存报告大纲 - + 在规划阶段完成后立即调用 """ cls._ensure_report_folder(report_id) - + with open(cls._get_outline_path(report_id), 'w', encoding='utf-8') as f: json.dump(outline.to_dict(), f, ensure_ascii=False, indent=2) - + logger.info(t('report.outlineSaved', reportId=report_id)) + + @classmethod + def load_outline(cls, report_id: str) -> Optional[ReportOutline]: + """加载已保存的报告大纲(用于 resume 模式)""" + path = cls._get_outline_path(report_id) + if not os.path.exists(path): + return None + try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + sections = [ + ReportSection(title=s.get('title', ''), content=s.get('content', '')) + for s in data.get('sections', []) + ] + return ReportOutline( + title=data.get('title', ''), + summary=data.get('summary', ''), + sections=sections + ) + except Exception as e: + logger.warning(f"Failed to load outline for {report_id}: {e}") + return None @classmethod def save_section( diff --git a/frontend/src/api/report.js b/frontend/src/api/report.js index c89a67d8..05090faf 100644 --- a/frontend/src/api/report.js +++ b/frontend/src/api/report.js @@ -2,12 +2,21 @@ import service, { requestWithRetry } from './index' /** * 开始报告生成 - * @param {Object} data - { simulation_id, force_regenerate? } + * @param {Object} data - { simulation_id, force_regenerate?, resume? } + * - resume=true: 从已失败的报告恢复(沿用大纲和已完成章节) */ export const generateReport = (data) => { return requestWithRetry(() => service.post('/api/report/generate', data), 3, 1000) } +/** + * 获取报告生成进度(包含 status / progress / completed_sections 等) + * @param {string} reportId + */ +export const getReportProgress = (reportId) => { + return service.get(`/api/report/${reportId}/progress`) +} + /** * 获取报告生成状态 * @param {string} reportId diff --git a/frontend/src/components/Step4Report.vue b/frontend/src/components/Step4Report.vue index 8e53ceb5..efb9ce92 100644 --- a/frontend/src/components/Step4Report.vue +++ b/frontend/src/components/Step4Report.vue @@ -4,6 +4,25 @@
+ +
+
!
+
+
{{ $t('step4.reportFailedTitle') }}
+
+ {{ $t('step4.reportFailedDesc', { count: Object.keys(generatedSections).length }) }} +
+
{{ reportError }}
+ +
+
+
@@ -393,7 +412,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' -import { getAgentLog, getConsoleLog } from '../api/report' +import { getAgentLog, getConsoleLog, getReportProgress, generateReport } from '../api/report' const router = useRouter() const { t } = useI18n() @@ -425,6 +444,9 @@ const expandedContent = ref(new Set()) const expandedLogs = ref(new Set()) const collapsedSections = ref(new Set()) const isComplete = ref(false) +const reportStatus = ref(null) // 后端报告状态: pending|planning|generating|completed|failed +const reportError = ref(null) // 失败时的错误信息 +const isResuming = ref(false) // 点击"继续生成"按钮后的 loading 态 const startTime = ref(null) const leftPanel = ref(null) const rightPanel = ref(null) @@ -2154,14 +2176,63 @@ const fetchConsoleLog = async () => { } } +let progressTimer = null + +const fetchProgress = async () => { + if (!props.reportId) return + try { + const res = await getReportProgress(props.reportId) + if (res.success && res.data) { + const prevStatus = reportStatus.value + reportStatus.value = res.data.status + // progress.json 用 message 字段携带失败原因 + reportError.value = res.data.status === 'failed' ? (res.data.message || null) : null + + if (res.data.status === 'failed') { + emit('update-status', 'error') + stopPolling() + } else if (res.data.status === 'completed') { + emit('update-status', 'completed') + } else if (prevStatus !== res.data.status) { + emit('update-status', 'processing') + } + } + } catch (err) { + // 进度文件还没创建时是正常的 404,静默忽略 + } +} + +const resumeReport = async () => { + if (!props.simulationId || isResuming.value) return + isResuming.value = true + try { + const res = await generateReport({ simulation_id: props.simulationId, resume: true }) + if (res.success) { + // 重置本地失败态,重启轮询 + reportStatus.value = 'generating' + reportError.value = null + emit('update-status', 'processing') + agentLogLine.value = 0 + consoleLogLine.value = 0 + startPolling() + } + } catch (err) { + console.warn('Resume failed:', err) + } finally { + isResuming.value = false + } +} + const startPolling = () => { if (agentLogTimer || consoleLogTimer) return - + fetchAgentLog() fetchConsoleLog() - + fetchProgress() + agentLogTimer = setInterval(fetchAgentLog, 2000) consoleLogTimer = setInterval(fetchConsoleLog, 1500) + progressTimer = setInterval(fetchProgress, 3000) } const stopPolling = () => { @@ -2173,6 +2244,10 @@ const stopPolling = () => { clearInterval(consoleLogTimer) consoleLogTimer = null } + if (progressTimer) { + clearInterval(progressTimer) + progressTimer = null + } } // Lifecycle @@ -2200,8 +2275,11 @@ watch(() => props.reportId, (newId) => { expandedLogs.value = new Set() collapsedSections.value = new Set() isComplete.value = false + reportStatus.value = null + reportError.value = null + isResuming.value = false startTime.value = null - + startPolling() } }, { immediate: true }) @@ -2332,6 +2410,72 @@ watch(() => props.reportId, (newId) => { padding: 30px 50px 60px 50px; } +.resume-banner { + display: flex; + gap: 14px; + align-items: flex-start; + padding: 16px 18px; + margin-bottom: 24px; + background: #FFF7ED; + border: 1px solid #FDBA74; + border-radius: 8px; +} +.resume-banner-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + background: #F97316; + color: #FFF; + font-weight: 700; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; +} +.resume-banner-body { + flex: 1; + min-width: 0; +} +.resume-banner-title { + font-weight: 700; + font-size: 14px; + color: #9A3412; + margin-bottom: 4px; +} +.resume-banner-desc { + font-size: 13px; + color: #7C2D12; + line-height: 1.5; +} +.resume-banner-error { + margin-top: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: #9A3412; + word-break: break-word; + opacity: 0.85; +} +.resume-banner-btn { + margin-top: 10px; + padding: 8px 16px; + background: #F97316; + color: #FFF; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; +} +.resume-banner-btn:hover:not(:disabled) { + background: #EA580C; +} +.resume-banner-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .left-panel::-webkit-scrollbar { width: 6px; } diff --git a/locales/en.json b/locales/en.json index 544c68b1..694535d0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -247,7 +247,11 @@ "panelRelatedEdges": "Related Edges", "panelRelatedNodes": "Related Nodes", "world1": "World 1", - "world2": "World 2" + "world2": "World 2", + "reportFailedTitle": "Report generation failed", + "reportFailedDesc": "Your outline and {count} completed sections are preserved. Click below to continue from where it stopped.", + "resumeGeneration": "Resume Generation", + "resumingGeneration": "Resuming..." }, "step5": { "interactiveTools": "Interactive Tools", @@ -429,6 +433,8 @@ "outlineDone": "Outline complete, {count} sections", "generatingSection": "Generating section: {title} ({current}/{total})", "sectionDone": "Section {title} complete", + "resumeStart": "Resuming generation, {count} sections already done", + "sectionResumed": "Section {title} already exists, skipped", "assemblingReport": "Assembling full report...", "reportComplete": "Report generation complete", "reportFailed": "Report generation failed: {error}", @@ -597,6 +603,8 @@ "sectionGenFailedContent": "(This section failed to generate: LLM returned empty response, please retry later)", "outlineSavedToFile": "Outline saved to file: {reportId}/outline.json", "sectionSaved": "Section saved: {reportId}/section_{sectionNum}.md", + "resumeStart": "Resuming report generation {reportId} ({count} sections already completed)", + "sectionResumed": "Section {title} already generated, reusing", "reportGenDone": "Report generation complete: {reportId}", "reportGenFailed": "Report generation failed: {error}", "agentChat": "Report Agent chat: {message}...", diff --git a/locales/zh.json b/locales/zh.json index cd747e2f..36c5771d 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -247,7 +247,11 @@ "panelRelatedEdges": "相关关系", "panelRelatedNodes": "相关节点", "world1": "世界1", - "world2": "世界2" + "world2": "世界2", + "reportFailedTitle": "报告生成失败", + "reportFailedDesc": "大纲和已完成的 {count} 个章节已保存,点击下方按钮可从失败处继续。", + "resumeGeneration": "继续生成", + "resumingGeneration": "正在继续..." }, "step5": { "interactiveTools": "Interactive Tools", @@ -429,6 +433,8 @@ "outlineDone": "大纲规划完成,共{count}个章节", "generatingSection": "正在生成章节: {title} ({current}/{total})", "sectionDone": "章节 {title} 已完成", + "resumeStart": "恢复生成,已完成 {count} 个章节", + "sectionResumed": "章节 {title} 已存在,跳过", "assemblingReport": "正在组装完整报告...", "reportComplete": "报告生成完成", "reportFailed": "报告生成失败: {error}", @@ -597,6 +603,8 @@ "sectionGenFailedContent": "(本章节生成失败:LLM 返回空响应,请稍后重试)", "outlineSavedToFile": "大纲已保存到文件: {reportId}/outline.json", "sectionSaved": "章节已保存: {reportId}/section_{sectionNum}.md", + "resumeStart": "恢复生成报告 {reportId}(已完成 {count} 个章节)", + "sectionResumed": "章节 {title} 已生成过,复用已有内容", "reportGenDone": "报告生成完成: {reportId}", "reportGenFailed": "报告生成失败: {error}", "agentChat": "Report Agent对话: {message}...",