This commit is contained in:
andreicarpen 2026-05-28 17:39:39 -04:00 committed by GitHub
commit 76d59ac34d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 389 additions and 91 deletions

View File

@ -6,6 +6,7 @@ Report API路由
import os import os
import traceback import traceback
import threading import threading
from typing import Optional
from flask import request, jsonify, send_file from flask import request, jsonify, send_file
from . import report_bp from . import report_bp
@ -33,7 +34,8 @@ def generate_report():
请求JSON 请求JSON
{ {
"simulation_id": "sim_xxxx", // 必填模拟ID "simulation_id": "sim_xxxx", // 必填模拟ID
"force_regenerate": false // 可选强制重新生成 "force_regenerate": false, // 可选强制重新生成清空已有报告
"resume": false // 可选从已失败的报告恢复生成沿用大纲和已完成章节
} }
返回 返回
@ -58,6 +60,7 @@ def generate_report():
}), 400 }), 400
force_regenerate = data.get('force_regenerate', False) force_regenerate = data.get('force_regenerate', False)
resume = data.get('resume', False)
# 获取模拟信息 # 获取模拟信息
manager = SimulationManager() manager = SimulationManager()
@ -69,9 +72,18 @@ def generate_report():
"error": t('api.simulationNotFound', id=simulation_id) "error": t('api.simulationNotFound', id=simulation_id)
}), 404 }), 404
# 检查是否已有报告 # Resume 模式:查找已失败的报告并复用其 report_id
if not force_regenerate:
existing_report = ReportManager.get_report_by_simulation(simulation_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: if existing_report and existing_report.status == ReportStatus.COMPLETED:
return jsonify({ return jsonify({
"success": True, "success": True,
@ -106,7 +118,10 @@ def generate_report():
"error": t('api.missingSimRequirement') "error": t('api.missingSimRequirement')
}), 400 }), 400
# 提前生成 report_id以便立即返回给前端 # Resume 时复用已有 report_id否则生成新的
if resume_report_id:
report_id = resume_report_id
else:
import uuid import uuid
report_id = f"report_{uuid.uuid4().hex[:12]}" report_id = f"report_{uuid.uuid4().hex[:12]}"
@ -150,10 +165,11 @@ def generate_report():
message=f"[{stage}] {message}" message=f"[{stage}] {message}"
) )
# 生成报告(传入预先生成的 report_id # 生成报告(传入预先生成的 report_idresume=True 时复用已有大纲和章节
report = agent.generate_report( report = agent.generate_report(
progress_callback=progress_callback, progress_callback=progress_callback,
report_id=report_id report_id=report_id,
resume=resume
) )
# 保存报告 # 保存报告

View File

@ -1532,7 +1532,8 @@ class ReportAgent:
def generate_report( def generate_report(
self, self,
progress_callback: Optional[Callable[[str, int, str], None]] = None, progress_callback: Optional[Callable[[str, int, str], None]] = None,
report_id: Optional[str] = None report_id: Optional[str] = None,
resume: bool = False
) -> Report: ) -> Report:
""" """
生成完整报告分章节实时输出 生成完整报告分章节实时输出
@ -1551,6 +1552,7 @@ class ReportAgent:
Args: Args:
progress_callback: 进度回调函数 (stage, progress, message) progress_callback: 进度回调函数 (stage, progress, message)
report_id: 报告ID可选如果不传则自动生成 report_id: 报告ID可选如果不传则自动生成
resume: 是否从已保存的大纲和章节恢复生成跳过规划阶段和已完成章节
Returns: Returns:
Report: 完整报告 Report: 完整报告
@ -1580,15 +1582,47 @@ class ReportAgent:
# 初始化日志记录器(结构化日志 agent_log.jsonl # 初始化日志记录器(结构化日志 agent_log.jsonl
self.report_logger = ReportLogger(report_id) self.report_logger = ReportLogger(report_id)
# 初始化控制台日志记录器console_log.txt
self.console_logger = ReportConsoleLogger(report_id)
# 尝试从已保存状态恢复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( self.report_logger.log_start(
simulation_id=self.simulation_id, simulation_id=self.simulation_id,
graph_id=self.graph_id, graph_id=self.graph_id,
simulation_requirement=self.simulation_requirement simulation_requirement=self.simulation_requirement
) )
# 初始化控制台日志记录器console_log.txt
self.console_logger = ReportConsoleLogger(report_id)
ReportManager.update_progress( ReportManager.update_progress(
report_id, "pending", 0, t('progress.initReport'), report_id, "pending", 0, t('progress.initReport'),
completed_sections=[] completed_sections=[]
@ -1637,6 +1671,43 @@ class ReportAgent:
section_num = i + 1 section_num = i + 1
base_progress = 20 + int((i / total_sections) * 70) 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( ReportManager.update_progress(
report_id, "generating", base_progress, report_id, "generating", base_progress,
@ -2091,6 +2162,28 @@ class ReportManager:
logger.info(t('report.outlineSaved', reportId=report_id)) 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 @classmethod
def save_section( def save_section(
cls, cls,

View File

@ -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) => { export const generateReport = (data) => {
return requestWithRetry(() => service.post('/api/report/generate', data), 3, 1000) 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 * @param {string} reportId

View File

@ -4,6 +4,25 @@
<div class="main-split-layout"> <div class="main-split-layout">
<!-- LEFT PANEL: Report Style --> <!-- LEFT PANEL: Report Style -->
<div class="left-panel report-style" ref="leftPanel"> <div class="left-panel report-style" ref="leftPanel">
<!-- Failure + Resume Banner -->
<div v-if="reportStatus === 'failed'" class="resume-banner">
<div class="resume-banner-icon">!</div>
<div class="resume-banner-body">
<div class="resume-banner-title">{{ $t('step4.reportFailedTitle') }}</div>
<div class="resume-banner-desc">
{{ $t('step4.reportFailedDesc', { count: Object.keys(generatedSections).length }) }}
</div>
<div v-if="reportError" class="resume-banner-error">{{ reportError }}</div>
<button
class="resume-banner-btn"
:disabled="isResuming || !simulationId"
@click="resumeReport"
>
{{ isResuming ? $t('step4.resumingGeneration') : $t('step4.resumeGeneration') }}
</button>
</div>
</div>
<div v-if="reportOutline" class="report-content-wrapper"> <div v-if="reportOutline" class="report-content-wrapper">
<!-- Report Header --> <!-- Report Header -->
<div class="report-header-block"> <div class="report-header-block">
@ -393,7 +412,7 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { getAgentLog, getConsoleLog } from '../api/report' import { getAgentLog, getConsoleLog, getReportProgress, generateReport } from '../api/report'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
@ -425,6 +444,9 @@ const expandedContent = ref(new Set())
const expandedLogs = ref(new Set()) const expandedLogs = ref(new Set())
const collapsedSections = ref(new Set()) const collapsedSections = ref(new Set())
const isComplete = ref(false) 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 startTime = ref(null)
const leftPanel = ref(null) const leftPanel = ref(null)
const rightPanel = ref(null) const rightPanel = ref(null)
@ -2055,11 +2077,31 @@ const fetchAgentLog = async () => {
if (log.action === 'report_complete') { if (log.action === 'report_complete') {
isComplete.value = true isComplete.value = true
currentSectionIndex.value = null // loading currentSectionIndex.value = null // loading
// error failed banner
reportStatus.value = 'completed'
reportError.value = null
emit('update-status', 'completed') emit('update-status', 'completed')
stopPolling() stopPolling()
// nextTick // nextTick
} }
// Resume = error
if (log.action === 'resume_start') {
reportStatus.value = 'generating'
reportError.value = null
}
// Agent progress.json
// update_progress
// stopPolling Resume
// error resume/complete
if (log.action === 'error') {
reportStatus.value = 'failed'
reportError.value = log.details?.error || log.details?.message || null
currentSectionIndex.value = null
emit('update-status', 'error')
}
if (log.action === 'report_start') { if (log.action === 'report_start') {
startTime.value = new Date(log.timestamp) startTime.value = new Date(log.timestamp)
} }
@ -2154,14 +2196,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') {
// stopPollingprogress.json resume generating
emit('update-status', 'error')
} 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 = () => { const startPolling = () => {
if (agentLogTimer || consoleLogTimer) return if (agentLogTimer || consoleLogTimer) return
fetchAgentLog() fetchAgentLog()
fetchConsoleLog() fetchConsoleLog()
fetchProgress()
agentLogTimer = setInterval(fetchAgentLog, 2000) agentLogTimer = setInterval(fetchAgentLog, 2000)
consoleLogTimer = setInterval(fetchConsoleLog, 1500) consoleLogTimer = setInterval(fetchConsoleLog, 1500)
progressTimer = setInterval(fetchProgress, 3000)
} }
const stopPolling = () => { const stopPolling = () => {
@ -2173,6 +2264,10 @@ const stopPolling = () => {
clearInterval(consoleLogTimer) clearInterval(consoleLogTimer)
consoleLogTimer = null consoleLogTimer = null
} }
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
} }
// Lifecycle // Lifecycle
@ -2200,6 +2295,9 @@ watch(() => props.reportId, (newId) => {
expandedLogs.value = new Set() expandedLogs.value = new Set()
collapsedSections.value = new Set() collapsedSections.value = new Set()
isComplete.value = false isComplete.value = false
reportStatus.value = null
reportError.value = null
isResuming.value = false
startTime.value = null startTime.value = null
startPolling() startPolling()
@ -2332,6 +2430,72 @@ watch(() => props.reportId, (newId) => {
padding: 30px 50px 60px 50px; 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 { .left-panel::-webkit-scrollbar {
width: 6px; width: 6px;
} }

View File

@ -247,7 +247,11 @@
"panelRelatedEdges": "Related Edges", "panelRelatedEdges": "Related Edges",
"panelRelatedNodes": "Related Nodes", "panelRelatedNodes": "Related Nodes",
"world1": "World 1", "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": { "step5": {
"interactiveTools": "Interactive Tools", "interactiveTools": "Interactive Tools",
@ -429,6 +433,8 @@
"outlineDone": "Outline complete, {count} sections", "outlineDone": "Outline complete, {count} sections",
"generatingSection": "Generating section: {title} ({current}/{total})", "generatingSection": "Generating section: {title} ({current}/{total})",
"sectionDone": "Section {title} complete", "sectionDone": "Section {title} complete",
"resumeStart": "Resuming generation, {count} sections already done",
"sectionResumed": "Section {title} already exists, skipped",
"assemblingReport": "Assembling full report...", "assemblingReport": "Assembling full report...",
"reportComplete": "Report generation complete", "reportComplete": "Report generation complete",
"reportFailed": "Report generation failed: {error}", "reportFailed": "Report generation failed: {error}",
@ -597,6 +603,8 @@
"sectionGenFailedContent": "(This section failed to generate: LLM returned empty response, please retry later)", "sectionGenFailedContent": "(This section failed to generate: LLM returned empty response, please retry later)",
"outlineSavedToFile": "Outline saved to file: {reportId}/outline.json", "outlineSavedToFile": "Outline saved to file: {reportId}/outline.json",
"sectionSaved": "Section saved: {reportId}/section_{sectionNum}.md", "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}", "reportGenDone": "Report generation complete: {reportId}",
"reportGenFailed": "Report generation failed: {error}", "reportGenFailed": "Report generation failed: {error}",
"agentChat": "Report Agent chat: {message}...", "agentChat": "Report Agent chat: {message}...",

View File

@ -247,7 +247,11 @@
"panelRelatedEdges": "相关关系", "panelRelatedEdges": "相关关系",
"panelRelatedNodes": "相关节点", "panelRelatedNodes": "相关节点",
"world1": "世界1", "world1": "世界1",
"world2": "世界2" "world2": "世界2",
"reportFailedTitle": "报告生成失败",
"reportFailedDesc": "大纲和已完成的 {count} 个章节已保存,点击下方按钮可从失败处继续。",
"resumeGeneration": "继续生成",
"resumingGeneration": "正在继续..."
}, },
"step5": { "step5": {
"interactiveTools": "Interactive Tools", "interactiveTools": "Interactive Tools",
@ -429,6 +433,8 @@
"outlineDone": "大纲规划完成,共{count}个章节", "outlineDone": "大纲规划完成,共{count}个章节",
"generatingSection": "正在生成章节: {title} ({current}/{total})", "generatingSection": "正在生成章节: {title} ({current}/{total})",
"sectionDone": "章节 {title} 已完成", "sectionDone": "章节 {title} 已完成",
"resumeStart": "恢复生成,已完成 {count} 个章节",
"sectionResumed": "章节 {title} 已存在,跳过",
"assemblingReport": "正在组装完整报告...", "assemblingReport": "正在组装完整报告...",
"reportComplete": "报告生成完成", "reportComplete": "报告生成完成",
"reportFailed": "报告生成失败: {error}", "reportFailed": "报告生成失败: {error}",
@ -597,6 +603,8 @@
"sectionGenFailedContent": "本章节生成失败LLM 返回空响应,请稍后重试)", "sectionGenFailedContent": "本章节生成失败LLM 返回空响应,请稍后重试)",
"outlineSavedToFile": "大纲已保存到文件: {reportId}/outline.json", "outlineSavedToFile": "大纲已保存到文件: {reportId}/outline.json",
"sectionSaved": "章节已保存: {reportId}/section_{sectionNum}.md", "sectionSaved": "章节已保存: {reportId}/section_{sectionNum}.md",
"resumeStart": "恢复生成报告 {reportId}(已完成 {count} 个章节)",
"sectionResumed": "章节 {title} 已生成过,复用已有内容",
"reportGenDone": "报告生成完成: {reportId}", "reportGenDone": "报告生成完成: {reportId}",
"reportGenFailed": "报告生成失败: {error}", "reportGenFailed": "报告生成失败: {error}",
"agentChat": "Report Agent对话: {message}...", "agentChat": "Report Agent对话: {message}...",