Merge 0a89d657f0 into 96096ea0ff
This commit is contained in:
commit
76d59ac34d
|
|
@ -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,7 +34,8 @@ def generate_report():
|
|||
请求(JSON):
|
||||
{
|
||||
"simulation_id": "sim_xxxx", // 必填,模拟ID
|
||||
"force_regenerate": false // 可选,强制重新生成
|
||||
"force_regenerate": false, // 可选,强制重新生成(清空已有报告)
|
||||
"resume": false // 可选,从已失败的报告恢复生成(沿用大纲和已完成章节)
|
||||
}
|
||||
|
||||
返回:
|
||||
|
|
@ -58,6 +60,7 @@ def generate_report():
|
|||
}), 400
|
||||
|
||||
force_regenerate = data.get('force_regenerate', False)
|
||||
resume = data.get('resume', False)
|
||||
|
||||
# 获取模拟信息
|
||||
manager = SimulationManager()
|
||||
|
|
@ -69,9 +72,18 @@ def generate_report():
|
|||
"error": t('api.simulationNotFound', id=simulation_id)
|
||||
}), 404
|
||||
|
||||
# 检查是否已有报告
|
||||
if not force_regenerate:
|
||||
# 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,
|
||||
|
|
@ -106,7 +118,10 @@ def generate_report():
|
|||
"error": t('api.missingSimRequirement')
|
||||
}), 400
|
||||
|
||||
# 提前生成 report_id,以便立即返回给前端
|
||||
# Resume 时复用已有 report_id,否则生成新的
|
||||
if resume_report_id:
|
||||
report_id = resume_report_id
|
||||
else:
|
||||
import uuid
|
||||
report_id = f"report_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
# 保存报告
|
||||
|
|
|
|||
|
|
@ -1532,7 +1532,8 @@ class ReportAgent:
|
|||
def generate_report(
|
||||
self,
|
||||
progress_callback: Optional[Callable[[str, int, str], None]] = None,
|
||||
report_id: Optional[str] = None
|
||||
report_id: Optional[str] = None,
|
||||
resume: bool = False
|
||||
) -> Report:
|
||||
"""
|
||||
生成完整报告(分章节实时输出)
|
||||
|
|
@ -1551,6 +1552,7 @@ class ReportAgent:
|
|||
Args:
|
||||
progress_callback: 进度回调函数 (stage, progress, message)
|
||||
report_id: 报告ID(可选,如果不传则自动生成)
|
||||
resume: 是否从已保存的大纲和章节恢复生成(跳过规划阶段和已完成章节)
|
||||
|
||||
Returns:
|
||||
Report: 完整报告
|
||||
|
|
@ -1580,15 +1582,47 @@ class ReportAgent:
|
|||
|
||||
# 初始化日志记录器(结构化日志 agent_log.jsonl)
|
||||
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(
|
||||
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=[]
|
||||
|
|
@ -1637,6 +1671,43 @@ class ReportAgent:
|
|||
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,
|
||||
|
|
@ -2091,6 +2162,28 @@ class ReportManager:
|
|||
|
||||
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(
|
||||
cls,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,25 @@
|
|||
<div class="main-split-layout">
|
||||
<!-- LEFT PANEL: Report Style -->
|
||||
<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">
|
||||
<!-- Report Header -->
|
||||
<div class="report-header-block">
|
||||
|
|
@ -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)
|
||||
|
|
@ -2055,11 +2077,31 @@ const fetchAgentLog = async () => {
|
|||
if (log.action === 'report_complete') {
|
||||
isComplete.value = true
|
||||
currentSectionIndex.value = null // 确保清除 loading 状态
|
||||
// 清掉历史 error 日志留下的 failed 态,防止 banner 残留
|
||||
reportStatus.value = 'completed'
|
||||
reportError.value = null
|
||||
emit('update-status', 'completed')
|
||||
stopPolling()
|
||||
// 滚动逻辑统一在循环结束后的 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') {
|
||||
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') {
|
||||
// 不 stopPolling:progress.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 = () => {
|
||||
if (agentLogTimer || consoleLogTimer) return
|
||||
|
||||
fetchAgentLog()
|
||||
fetchConsoleLog()
|
||||
fetchProgress()
|
||||
|
||||
agentLogTimer = setInterval(fetchAgentLog, 2000)
|
||||
consoleLogTimer = setInterval(fetchConsoleLog, 1500)
|
||||
progressTimer = setInterval(fetchProgress, 3000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
|
|
@ -2173,6 +2264,10 @@ const stopPolling = () => {
|
|||
clearInterval(consoleLogTimer)
|
||||
consoleLogTimer = null
|
||||
}
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer)
|
||||
progressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
|
@ -2200,6 +2295,9 @@ 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()
|
||||
|
|
@ -2332,6 +2430,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}...",
|
||||
|
|
|
|||
|
|
@ -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}...",
|
||||
|
|
|
|||
Loading…
Reference in New Issue