feat:导出面板动态编写一半
This commit is contained in:
951
backend/AgentCoord/Export/__init__.py
Normal file
951
backend/AgentCoord/Export/__init__.py
Normal file
@@ -0,0 +1,951 @@
|
||||
"""
|
||||
导出工具模块
|
||||
支持多种格式的文件生成:Word、Markdown、Excel、PPT、思维导图、信息图
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BaseExporter:
|
||||
"""导出器基类"""
|
||||
|
||||
def __init__(self, task_data: Dict[str, Any]):
|
||||
self.task_data = task_data
|
||||
|
||||
def generate(self, file_path: str) -> bool:
|
||||
"""生成导出文件,子类实现具体逻辑"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MarkdownExporter(BaseExporter):
|
||||
"""Markdown 导出器"""
|
||||
|
||||
def generate(self, file_path: str) -> bool:
|
||||
"""生成 Markdown 文件"""
|
||||
try:
|
||||
content_lines = []
|
||||
|
||||
# 标题
|
||||
task_name = self.task_data.get('task_name', '未命名任务')
|
||||
content_lines.append(f"# {task_name}\n")
|
||||
|
||||
# 任务描述
|
||||
task_content = self.task_data.get('task_content', '')
|
||||
if task_content:
|
||||
content_lines.append("## 任务描述\n")
|
||||
content_lines.append(f"{task_content}\n")
|
||||
|
||||
# 任务大纲
|
||||
task_outline = self.task_data.get('task_outline')
|
||||
if task_outline:
|
||||
content_lines.append("## 任务大纲\n")
|
||||
content_lines.append(self._format_outline(task_outline))
|
||||
|
||||
# 执行结果
|
||||
result = self.task_data.get('result')
|
||||
if result:
|
||||
content_lines.append("## 执行结果\n")
|
||||
if isinstance(result, list):
|
||||
for idx, item in enumerate(result, 1):
|
||||
content_lines.append(f"### 步骤 {idx}\n")
|
||||
content_lines.append(f"{json.dumps(item, ensure_ascii=False, indent=2)}\n")
|
||||
else:
|
||||
content_lines.append(f"{json.dumps(result, ensure_ascii=False, indent=2)}\n")
|
||||
|
||||
# 参与智能体 - 从 task_outline 的 Collaboration Process 中提取
|
||||
task_outline = self.task_data.get('task_outline')
|
||||
if task_outline and isinstance(task_outline, dict):
|
||||
collaboration_process = task_outline.get('Collaboration Process', [])
|
||||
if collaboration_process and isinstance(collaboration_process, list):
|
||||
# 收集所有参与步骤的智能体
|
||||
all_agents = set()
|
||||
for step in collaboration_process:
|
||||
if isinstance(step, dict):
|
||||
agent_selection = step.get('AgentSelection', [])
|
||||
if isinstance(agent_selection, list):
|
||||
for agent in agent_selection:
|
||||
if agent:
|
||||
all_agents.add(agent)
|
||||
if all_agents:
|
||||
content_lines.append("## 参与智能体\n")
|
||||
for agent_name in sorted(all_agents):
|
||||
content_lines.append(f"- {agent_name}")
|
||||
|
||||
# 写入文件
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(content_lines))
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Markdown 导出失败: {e}")
|
||||
return False
|
||||
|
||||
def _format_outline(self, outline: Any, level: int = 2) -> str:
|
||||
"""格式化大纲内容"""
|
||||
lines = []
|
||||
prefix = '#' * level
|
||||
|
||||
if isinstance(outline, dict):
|
||||
for key, value in outline.items():
|
||||
# 如果值是简单类型,直接显示
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
lines.append(f"**{key}**: {value}")
|
||||
# 如果值是列表或字典,递归处理
|
||||
elif isinstance(value, list):
|
||||
lines.append(f"**{key}**:")
|
||||
lines.append(self._format_outline(value, level + 1))
|
||||
elif isinstance(value, dict):
|
||||
lines.append(f"**{key}**:")
|
||||
lines.append(self._format_outline(value, level + 1))
|
||||
else:
|
||||
lines.append(f"**{key}**: {value}")
|
||||
elif isinstance(outline, list):
|
||||
for idx, item in enumerate(outline, 1):
|
||||
if isinstance(item, dict):
|
||||
# 列表中的每个字典作为一个整体项
|
||||
lines.append(f"{prefix} 步骤 {idx}")
|
||||
for key, value in item.items():
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
lines.append(f" - **{key}**: {value}")
|
||||
elif isinstance(value, list):
|
||||
lines.append(f" - **{key}**:")
|
||||
for v in value:
|
||||
lines.append(f" - {v}")
|
||||
elif isinstance(value, dict):
|
||||
lines.append(f" - **{key}**:")
|
||||
lines.append(self._format_outline(value, level + 2))
|
||||
else:
|
||||
lines.append(f" - **{key}**: {value}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append(str(outline))
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
class DocxExporter(BaseExporter):
|
||||
"""Word 文档导出器"""
|
||||
|
||||
def generate(self, file_path: str) -> bool:
|
||||
"""生成 Word 文档"""
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.shared import Inches, Pt
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
doc = Document()
|
||||
|
||||
# 标题
|
||||
task_name = self.task_data.get('task_name', '未命名任务')
|
||||
title = doc.add_heading(task_name, level=0)
|
||||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
# 任务描述
|
||||
task_content = self.task_data.get('task_content', '')
|
||||
if task_content:
|
||||
doc.add_heading('任务描述', level=1)
|
||||
doc.add_paragraph(task_content)
|
||||
|
||||
# 任务大纲
|
||||
task_outline = self.task_data.get('task_outline')
|
||||
if task_outline:
|
||||
doc.add_heading('任务大纲', level=1)
|
||||
self._add_outline_to_doc(doc, task_outline)
|
||||
|
||||
# 执行结果
|
||||
result = self.task_data.get('result')
|
||||
if result:
|
||||
doc.add_heading('执行结果', level=1)
|
||||
if isinstance(result, list):
|
||||
for idx, item in enumerate(result, 1):
|
||||
doc.add_heading(f'步骤 {idx}', level=2)
|
||||
doc.add_paragraph(json.dumps(item, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
doc.add_paragraph(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
# 参与智能体 - 从 task_outline 的 Collaboration Process 中提取
|
||||
task_outline = self.task_data.get('task_outline')
|
||||
if task_outline and isinstance(task_outline, dict):
|
||||
collaboration_process = task_outline.get('Collaboration Process', [])
|
||||
if collaboration_process and isinstance(collaboration_process, list):
|
||||
# 收集所有参与步骤的智能体
|
||||
all_agents = set()
|
||||
for step in collaboration_process:
|
||||
if isinstance(step, dict):
|
||||
agent_selection = step.get('AgentSelection', [])
|
||||
if isinstance(agent_selection, list):
|
||||
for agent in agent_selection:
|
||||
if agent:
|
||||
all_agents.add(agent)
|
||||
if all_agents:
|
||||
doc.add_heading('参与智能体', level=1)
|
||||
for agent_name in sorted(all_agents):
|
||||
doc.add_paragraph(f"- {agent_name}")
|
||||
|
||||
# 添加时间戳
|
||||
doc.add_paragraph(f"\n\n导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
doc.save(file_path)
|
||||
return True
|
||||
except ImportError:
|
||||
print("请安装 python-docx 库: pip install python-docx")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Word 导出失败: {e}")
|
||||
return False
|
||||
|
||||
def _add_outline_to_doc(self, doc, outline: Any, level: int = 2):
|
||||
"""递归添加大纲到文档"""
|
||||
if isinstance(outline, dict):
|
||||
for key, value in outline.items():
|
||||
# 如果值是简单类型,直接显示为段落
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
doc.add_paragraph(f"**{key}**: {value}")
|
||||
# 如果值是列表或字典,递归处理
|
||||
elif isinstance(value, list):
|
||||
doc.add_paragraph(f"**{key}**:")
|
||||
self._add_outline_to_doc(doc, value, level + 1)
|
||||
elif isinstance(value, dict):
|
||||
doc.add_paragraph(f"**{key}**:")
|
||||
self._add_outline_to_doc(doc, value, level + 1)
|
||||
else:
|
||||
doc.add_paragraph(f"**{key}**: {value}")
|
||||
elif isinstance(outline, list):
|
||||
for idx, item in enumerate(outline, 1):
|
||||
if isinstance(item, dict):
|
||||
# 列表中的每个字典作为一个整体项
|
||||
doc.add_heading(f"步骤 {idx}", level=min(level, 3))
|
||||
for key, value in item.items():
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
doc.add_paragraph(f" - **{key}**: {value}")
|
||||
elif isinstance(value, list):
|
||||
doc.add_paragraph(f" - **{key}**:")
|
||||
for v in value:
|
||||
doc.add_paragraph(f" - {v}")
|
||||
elif isinstance(value, dict):
|
||||
doc.add_paragraph(f" - **{key}**:")
|
||||
self._add_outline_to_doc(doc, value, level + 2)
|
||||
else:
|
||||
doc.add_paragraph(f" - **{key}**: {value}")
|
||||
else:
|
||||
doc.add_paragraph(str(item))
|
||||
else:
|
||||
doc.add_paragraph(str(outline))
|
||||
|
||||
|
||||
class ExcelExporter(BaseExporter):
|
||||
"""Excel 导出器"""
|
||||
|
||||
def generate(self, file_path: str) -> bool:
|
||||
"""生成 Excel 文件"""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "任务执行结果"
|
||||
|
||||
# 标题
|
||||
task_name = self.task_data.get('task_name', '未命名任务')
|
||||
ws['A1'] = task_name
|
||||
ws['A1'].font = Font(size=14, bold=True)
|
||||
ws.merge_cells('A1:E1')
|
||||
|
||||
row = 3
|
||||
|
||||
# 任务描述
|
||||
ws[f'A{row}'] = "任务描述"
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
ws[f'B{row}'] = self.task_data.get('task_content', '')
|
||||
row += 1
|
||||
|
||||
# 导出时间
|
||||
ws[f'A{row}'] = "导出时间"
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
ws[f'B{row}'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
row += 2
|
||||
|
||||
# 参与智能体
|
||||
task_outline = self.task_data.get('task_outline')
|
||||
all_agents = []
|
||||
if task_outline and isinstance(task_outline, dict):
|
||||
collaboration_process = task_outline.get('Collaboration Process', [])
|
||||
if collaboration_process and isinstance(collaboration_process, list):
|
||||
agents_set = set()
|
||||
for step in collaboration_process:
|
||||
if isinstance(step, dict):
|
||||
agent_selection = step.get('AgentSelection', [])
|
||||
if isinstance(agent_selection, list):
|
||||
for agent in agent_selection:
|
||||
if agent:
|
||||
agents_set.add(agent)
|
||||
all_agents = sorted(agents_set)
|
||||
|
||||
if all_agents:
|
||||
ws[f'A{row}'] = "参与智能体"
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
ws[f'B{row}'] = '、'.join(all_agents)
|
||||
row += 2
|
||||
|
||||
# 任务大纲
|
||||
if task_outline:
|
||||
ws[f'A{row}'] = "任务大纲"
|
||||
ws[f'A{row}'].font = Font(size=12, bold=True)
|
||||
row += 1
|
||||
|
||||
if isinstance(task_outline, dict):
|
||||
# 写入任务大纲的键值对
|
||||
for key, value in task_outline.items():
|
||||
if key == 'Collaboration Process':
|
||||
# 单独处理协作步骤
|
||||
if isinstance(value, list):
|
||||
for idx, step in enumerate(value, 1):
|
||||
if isinstance(step, dict):
|
||||
step_name = step.get('StepName', f'步骤{idx}')
|
||||
task_content = step.get('TaskContent', '')
|
||||
ws[f'A{row}'] = f"步骤{idx}: {step_name}"
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
ws[f'B{row}'] = task_content
|
||||
row += 1
|
||||
|
||||
# 参与智能体
|
||||
agents = step.get('AgentSelection', [])
|
||||
if agents:
|
||||
ws[f'B{row}'] = f"参与智能体: {'、'.join(agents)}"
|
||||
row += 1
|
||||
else:
|
||||
ws[f'A{row}'] = key
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
ws[f'B{row}'] = str(value)
|
||||
elif isinstance(value, list):
|
||||
ws[f'B{row}'] = json.dumps(value, ensure_ascii=False)
|
||||
elif isinstance(value, dict):
|
||||
ws[f'B{row}'] = json.dumps(value, ensure_ascii=False)
|
||||
row += 1
|
||||
row += 1
|
||||
|
||||
# 执行结果
|
||||
ws[f'A{row}'] = "执行结果"
|
||||
ws[f'A{row}'].font = Font(size=12, bold=True)
|
||||
row += 1
|
||||
|
||||
ws[f'A{row}'] = "步骤"
|
||||
ws[f'B{row}'] = "结果"
|
||||
for col in ['A', 'B']:
|
||||
ws[f'{col}{row}'].font = Font(bold=True)
|
||||
row += 1
|
||||
|
||||
result = self.task_data.get('result')
|
||||
if isinstance(result, list):
|
||||
for idx, item in enumerate(result, 1):
|
||||
if isinstance(item, dict):
|
||||
# 完整的执行结果,包含 ActionHistory 和每个 action 的结果
|
||||
ws[f'A{row}'] = f"步骤 {idx}"
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
row += 1
|
||||
|
||||
# 遍历所有字段
|
||||
for key, value in item.items():
|
||||
if key == 'ActionHistory' and isinstance(value, list):
|
||||
# ActionHistory 需要展开显示
|
||||
ws[f'A{row}'] = "动作历史:"
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
row += 1
|
||||
|
||||
for action_idx, action in enumerate(value, 1):
|
||||
if isinstance(action, dict):
|
||||
# 每个 action 显示 AgentName, ActionType, Description, Action_Result
|
||||
ws[f'A{row}'] = f" 动作{action_idx}"
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
|
||||
action_details = []
|
||||
if action.get('AgentName'):
|
||||
action_details.append(f"智能体: {action.get('AgentName')}")
|
||||
if action.get('ActionType'):
|
||||
action_details.append(f"类型: {action.get('ActionType')}")
|
||||
if action.get('Description'):
|
||||
action_details.append(f"描述: {action.get('Description')}")
|
||||
if action.get('Action_Result'):
|
||||
# 执行结果是长文本,全部显示
|
||||
action_details.append(f"结果: {action.get('Action_Result')}")
|
||||
|
||||
ws[f'B{row}'] = '\n'.join(action_details)
|
||||
ws.column_dimensions['B'].width = 100
|
||||
row += 1
|
||||
else:
|
||||
# 其他字段
|
||||
ws[f'A{row}'] = key
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
ws[f'B{row}'] = str(value)
|
||||
else:
|
||||
ws[f'B{row}'] = json.dumps(value, ensure_ascii=False, indent=2)
|
||||
row += 1
|
||||
row += 1 # 步骤之间空一行
|
||||
else:
|
||||
ws[f'A{row}'] = f"步骤 {idx}"
|
||||
ws[f'A{row}'].font = Font(bold=True)
|
||||
ws[f'B{row}'] = str(item)
|
||||
row += 1
|
||||
|
||||
# 设置列宽
|
||||
ws.column_dimensions['A'].width = 20
|
||||
|
||||
wb.save(file_path)
|
||||
return True
|
||||
except ImportError:
|
||||
print("请安装 openpyxl 库: pip install openpyxl")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Excel 导出失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
class PptExporter(BaseExporter):
|
||||
"""PPT 导出器"""
|
||||
|
||||
def generate(self, file_path: str) -> bool:
|
||||
"""生成 PowerPoint 文件"""
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
prs = Presentation()
|
||||
|
||||
# 标题页
|
||||
title_slide_layout = prs.slide_layouts[0]
|
||||
slide = prs.slides.add_slide(title_slide_layout)
|
||||
task_name = self.task_data.get('task_name', '未命名任务')
|
||||
title = slide.shapes.title
|
||||
subtitle = slide.placeholders[1]
|
||||
title.text = task_name
|
||||
subtitle.text = f"导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
|
||||
# 任务描述
|
||||
task_content = self.task_data.get('task_content', '')
|
||||
if task_content:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
||||
title = slide.shapes.title
|
||||
title.text = '任务描述'
|
||||
body = slide.placeholders[1]
|
||||
tf = body.text_frame
|
||||
tf.text = task_content[:500] + '...' if len(task_content) > 500 else task_content
|
||||
|
||||
# 参与智能体
|
||||
task_outline = self.task_data.get('task_outline')
|
||||
all_agents = []
|
||||
if task_outline and isinstance(task_outline, dict):
|
||||
collaboration_process = task_outline.get('Collaboration Process', [])
|
||||
if collaboration_process and isinstance(collaboration_process, list):
|
||||
agents_set = set()
|
||||
for step in collaboration_process:
|
||||
if isinstance(step, dict):
|
||||
agent_selection = step.get('AgentSelection', [])
|
||||
if isinstance(agent_selection, list):
|
||||
for agent in agent_selection:
|
||||
if agent:
|
||||
agents_set.add(agent)
|
||||
all_agents = sorted(agents_set)
|
||||
|
||||
if all_agents:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
||||
title = slide.shapes.title
|
||||
title.text = '参与智能体'
|
||||
body = slide.placeholders[1]
|
||||
tf = body.text_frame
|
||||
for agent in all_agents:
|
||||
p = tf.add_paragraph()
|
||||
p.text = f"• {agent}"
|
||||
|
||||
# 任务步骤
|
||||
if task_outline and isinstance(task_outline, dict):
|
||||
collaboration_process = task_outline.get('Collaboration Process', [])
|
||||
if collaboration_process and isinstance(collaboration_process, list):
|
||||
for idx, step in enumerate(collaboration_process, 1):
|
||||
if isinstance(step, dict):
|
||||
step_name = step.get('StepName', f'步骤 {idx}')
|
||||
task_content_step = step.get('TaskContent', '')
|
||||
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
||||
title = slide.shapes.title
|
||||
title.text = f'{idx}. {step_name}'
|
||||
body = slide.placeholders[1]
|
||||
tf = body.text_frame
|
||||
tf.text = task_content_step[:300] + '...' if len(task_content_step) > 300 else task_content_step
|
||||
|
||||
# 执行结果 - 包含步骤信息、输入产物、动作、输出产物
|
||||
result = self.task_data.get('result')
|
||||
max_lines_per_slide = 20 # 内容区最大行数
|
||||
|
||||
def create_slide(prs, title_text, content_lines):
|
||||
"""创建幻灯片,处理超过最大行数的情况"""
|
||||
total_lines = len(content_lines)
|
||||
total_pages = (total_lines + max_lines_per_slide - 1) // max_lines_per_slide
|
||||
|
||||
for page_idx in range(total_pages):
|
||||
start_idx = page_idx * max_lines_per_slide
|
||||
end_idx = min(start_idx + max_lines_per_slide, total_lines)
|
||||
page_content = content_lines[start_idx:end_idx]
|
||||
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
||||
title = slide.shapes.title
|
||||
if total_pages > 1:
|
||||
title.text = f'{title_text} - {page_idx+1}/{total_pages}'
|
||||
else:
|
||||
title.text = title_text
|
||||
title.text_frame.paragraphs[0].font.size = Pt(24)
|
||||
|
||||
body = slide.placeholders[1]
|
||||
tf = body.text_frame
|
||||
|
||||
for line in page_content:
|
||||
p = tf.add_paragraph()
|
||||
p.text = line['text']
|
||||
p.font.size = line.get('font_size', Pt(10))
|
||||
if line.get('bold'):
|
||||
p.font.bold = True
|
||||
|
||||
def add_content_lines(content_lines, text, font_size=Pt(10), bold=False):
|
||||
"""将文本添加到内容行,按原始换行符分割"""
|
||||
if text:
|
||||
lines = text.split('\n')
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line:
|
||||
content_lines.append({'text': line, 'font_size': font_size, 'bold': bold})
|
||||
|
||||
if isinstance(result, list):
|
||||
for idx, item in enumerate(result, 1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
# 获取步骤基本信息
|
||||
step_task_content = item.get('TaskContent', '')
|
||||
output_name = item.get('OutputName', '')
|
||||
|
||||
# 获取输入产物信息
|
||||
input_names = item.get('InputName_List', [])
|
||||
input_records = item.get('inputObject_Record', [])
|
||||
|
||||
# 调试:打印步骤的 keys 和 input_names
|
||||
print(f"[PPT导出] 步骤 {idx}: input_names={input_names}, input_records类型={type(input_records)}, input_records长度={len(input_records) if isinstance(input_records, list) else 'N/A'}")
|
||||
|
||||
if not isinstance(input_records, list):
|
||||
input_records = []
|
||||
|
||||
# 调试:打印 input_records 内容
|
||||
if input_names and input_records:
|
||||
print(f"[PPT导出] 步骤 {idx}: input_records内容={input_records[:2]}...") # 只打印前2个
|
||||
|
||||
# 步骤信息页:包含任务内容、输入产物
|
||||
content_lines = []
|
||||
if step_task_content:
|
||||
content_lines.append({'text': '任务内容:', 'font_size': Pt(14), 'bold': True})
|
||||
add_content_lines(content_lines, step_task_content, Pt(12))
|
||||
|
||||
if input_names:
|
||||
content_lines.append({'text': '输入产物:', 'font_size': Pt(14), 'bold': True})
|
||||
for i, input_name in enumerate(input_names):
|
||||
content_lines.append({'text': f'{i+1}. {input_name}', 'font_size': Pt(12), 'bold': True})
|
||||
|
||||
# 从 input_records 中查找匹配的产物内容
|
||||
# inputObject_Record 格式: [{"产物名称": "产物内容"}]
|
||||
if isinstance(input_records, list):
|
||||
print(f"[PPT导出] 步骤 {idx}: 开始查找 input_name='{input_name}'")
|
||||
for record_idx, record in enumerate(input_records):
|
||||
print(f"[PPT导出] 步骤 {idx}: record[{record_idx}]={type(record)}, keys={list(record.keys()) if isinstance(record, dict) else 'N/A'}")
|
||||
if isinstance(record, dict) and input_name in record:
|
||||
value = record[input_name]
|
||||
print(f"[PPT导出] 步骤 {idx}: 找到匹配! value类型={type(value)}, value前20字={str(value)[:20]}")
|
||||
if isinstance(value, str):
|
||||
add_content_lines(content_lines, f' 内容: {value}', Pt(10))
|
||||
print(f"[PPT导出] 步骤 {idx}: 已添加内容到content_lines")
|
||||
break
|
||||
else:
|
||||
print(f"[PPT导出] 步骤 {idx}: 未找到匹配 '{input_name}'")
|
||||
|
||||
if output_name:
|
||||
content_lines.append({'text': '输出产物:', 'font_size': Pt(14), 'bold': True})
|
||||
content_lines.append({'text': str(output_name), 'font_size': Pt(12)})
|
||||
|
||||
if content_lines:
|
||||
create_slide(prs, f'步骤 {idx} - 概述', content_lines)
|
||||
|
||||
# 动作历史页:每个动作一个幻灯片
|
||||
actions = item.get('ActionHistory', [])
|
||||
if isinstance(actions, list):
|
||||
for action in actions:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
|
||||
agent = action.get('AgentName', '未知')
|
||||
action_type = action.get('ActionType', '')
|
||||
|
||||
content_lines = []
|
||||
|
||||
# 动作描述
|
||||
desc = action.get('Description', '')
|
||||
if desc:
|
||||
content_lines.append({'text': '动作描述:', 'font_size': Pt(14), 'bold': True})
|
||||
add_content_lines(content_lines, desc, Pt(12))
|
||||
|
||||
# 执行结果
|
||||
action_result = action.get('Action_Result', '')
|
||||
if action_result:
|
||||
content_lines.append({'text': '执行结果:', 'font_size': Pt(14), 'bold': True})
|
||||
add_content_lines(content_lines, action_result, Pt(10))
|
||||
|
||||
# 创建幻灯片(自动分页)
|
||||
if content_lines:
|
||||
create_slide(prs, f'{agent} ({action_type})', content_lines)
|
||||
# 没有 ActionHistory,显示整个结果
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[1])
|
||||
title = slide.shapes.title
|
||||
title.text = f'步骤 {idx}'
|
||||
body = slide.placeholders[1]
|
||||
tf = body.text_frame
|
||||
tf.text = json.dumps(item, ensure_ascii=False, indent=2)[:2000]
|
||||
|
||||
prs.save(file_path)
|
||||
return True
|
||||
except ImportError:
|
||||
print("请安装 python-pptx 库: pip install python-pptx")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"PPT 导出失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
class MindmapExporter(BaseExporter):
|
||||
"""思维导图导出器(输出 Markdown 格式,可通过 markmap 渲染)"""
|
||||
|
||||
def generate(self, file_path: str) -> bool:
|
||||
"""生成思维导图(Markdown 格式)"""
|
||||
try:
|
||||
content_lines = []
|
||||
|
||||
# 标题
|
||||
task_name = self.task_data.get('task_name', '未命名任务')
|
||||
content_lines.append(f"# {task_name}\n")
|
||||
|
||||
# 使用 Markmap 兼容的 Markdown 格式
|
||||
content_lines.append("```mindmap")
|
||||
content_lines.append(f" - {task_name}")
|
||||
|
||||
# 任务描述
|
||||
task_content = self.task_data.get('task_content', '')
|
||||
if task_content:
|
||||
content_lines.append(f" - 任务描述")
|
||||
# 限制内容长度
|
||||
short_content = task_content[:100] + '...' if len(task_content) > 100 else task_content
|
||||
content_lines.append(f" - {short_content}")
|
||||
|
||||
# 任务大纲
|
||||
task_outline = self.task_data.get('task_outline')
|
||||
if task_outline:
|
||||
content_lines.append(f" - 任务大纲")
|
||||
content_lines.append(self._format_mindmap(task_outline, indent=4))
|
||||
|
||||
# 执行结果
|
||||
result = self.task_data.get('result')
|
||||
if result:
|
||||
content_lines.append(f" - 执行结果")
|
||||
if isinstance(result, list):
|
||||
content_lines.append(f" - 共 {len(result)} 个步骤")
|
||||
|
||||
content_lines.append("```")
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(content_lines))
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"思维导图导出失败: {e}")
|
||||
return False
|
||||
|
||||
def _format_mindmap(self, data: Any, indent: int = 4) -> str:
|
||||
"""格式化思维导图"""
|
||||
lines = []
|
||||
prefix = ' ' * indent
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
# 如果值是简单类型,直接显示
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
lines.append(f"{prefix}- {key}: {value}")
|
||||
elif isinstance(value, list):
|
||||
lines.append(f"{prefix}- {key}")
|
||||
for v in value:
|
||||
if isinstance(v, dict):
|
||||
lines.append(self._format_mindmap(v, indent + 2))
|
||||
else:
|
||||
lines.append(f"{prefix} - {v}")
|
||||
elif isinstance(value, dict):
|
||||
lines.append(f"{prefix}- {key}")
|
||||
lines.append(self._format_mindmap(value, indent + 2))
|
||||
else:
|
||||
lines.append(f"{prefix}- {key}: {value}")
|
||||
elif isinstance(data, list):
|
||||
for idx, item in enumerate(data, 1):
|
||||
if isinstance(item, dict):
|
||||
# 列表中的每个字典作为一个步骤
|
||||
step_name = item.get('StepName', f'步骤{idx}')
|
||||
lines.append(f"{prefix}- {step_name}")
|
||||
for key, value in item.items():
|
||||
if key == 'StepName':
|
||||
continue
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
lines.append(f"{prefix} - {key}: {value}")
|
||||
elif isinstance(value, list):
|
||||
lines.append(f"{prefix} - {key}:")
|
||||
for v in value:
|
||||
if isinstance(v, dict):
|
||||
lines.append(self._format_mindmap(v, indent + 4))
|
||||
else:
|
||||
lines.append(f"{prefix} - {v}")
|
||||
elif isinstance(value, dict):
|
||||
lines.append(f"{prefix} - {key}:")
|
||||
lines.append(self._format_mindmap(value, indent + 4))
|
||||
else:
|
||||
lines.append(f"{prefix}- {item}")
|
||||
else:
|
||||
lines.append(f"{prefix}- {data}")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
class InfographicExporter(BaseExporter):
|
||||
"""信息图导出器(输出 HTML)"""
|
||||
|
||||
def generate(self, file_path: str) -> bool:
|
||||
"""生成信息图(HTML 格式)"""
|
||||
try:
|
||||
task_name = self.task_data.get('task_name', '未命名任务')
|
||||
task_content = self.task_data.get('task_content', '')
|
||||
task_outline = self.task_data.get('task_outline')
|
||||
result = self.task_data.get('result')
|
||||
|
||||
# 从 task_outline 中提取参与智能体
|
||||
all_agents = []
|
||||
if task_outline and isinstance(task_outline, dict):
|
||||
collaboration_process = task_outline.get('Collaboration Process', [])
|
||||
if collaboration_process and isinstance(collaboration_process, list):
|
||||
agents_set = set()
|
||||
for step in collaboration_process:
|
||||
if isinstance(step, dict):
|
||||
agent_selection = step.get('AgentSelection', [])
|
||||
if isinstance(agent_selection, list):
|
||||
for agent in agent_selection:
|
||||
if agent:
|
||||
agents_set.add(agent)
|
||||
all_agents = sorted(agents_set)
|
||||
|
||||
# 统计执行步骤数
|
||||
step_count = 0
|
||||
if task_outline and isinstance(task_outline, dict):
|
||||
collaboration_process = task_outline.get('Collaboration Process', [])
|
||||
if isinstance(collaboration_process, list):
|
||||
step_count = len(collaboration_process)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{task_name}</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 40px 20px; }}
|
||||
.container {{ max-width: 800px; margin: 0 auto; }}
|
||||
.card {{ background: white; border-radius: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); overflow: hidden; }}
|
||||
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px; text-align: center; }}
|
||||
.header h1 {{ font-size: 32px; margin-bottom: 10px; }}
|
||||
.header .time {{ opacity: 0.8; font-size: 14px; }}
|
||||
.content {{ padding: 40px; }}
|
||||
.section {{ margin-bottom: 30px; }}
|
||||
.section h2 {{ color: #667eea; font-size: 20px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #f0f0f0; }}
|
||||
.section p {{ color: #666; line-height: 1.8; }}
|
||||
.stats {{ display: flex; gap: 20px; flex-wrap: wrap; }}
|
||||
.stat-item {{ flex: 1; min-width: 150px; background: #f8f9fa; padding: 20px; border-radius: 12px; text-align: center; }}
|
||||
.stat-item .value {{ font-size: 28px; font-weight: bold; color: #667eea; }}
|
||||
.stat-item .label {{ font-size: 14px; color: #999; margin-top: 5px; }}
|
||||
.agent-list {{ display: flex; flex-wrap: wrap; gap: 10px; }}
|
||||
.agent-tag {{ background: #667eea; color: white; padding: 8px 16px; border-radius: 20px; font-size: 14px; }}
|
||||
.steps-list {{ background: #f8f9fa; border-radius: 12px; padding: 20px; }}
|
||||
.step-item {{ margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #eee; }}
|
||||
.step-item:last-child {{ margin-bottom: 0; padding-bottom: 0; border-bottom: none; }}
|
||||
.step-name {{ font-weight: bold; color: #333; margin-bottom: 5px; }}
|
||||
.step-content {{ color: #666; font-size: 14px; }}
|
||||
.result-content {{ background: #f8f9fa; border-radius: 12px; padding: 20px; max-height: 400px; overflow-y: auto; }}
|
||||
.result-step {{ margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #eee; }}
|
||||
.result-step:last-child {{ margin-bottom: 0; padding-bottom: 0; border-bottom: none; }}
|
||||
.result-step h4 {{ color: #667eea; margin-bottom: 10px; }}
|
||||
.result-step p {{ color: #666; font-size: 14px; line-height: 1.6; margin-bottom: 5px; word-break: break-all; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>{task_name}</h1>
|
||||
<div class="time">导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="section">
|
||||
<h2>任务描述</h2>
|
||||
<p>{task_content or '无'}</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>执行统计</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="value">{step_count}</div>
|
||||
<div class="label">执行步骤</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="value">{len(all_agents)}</div>
|
||||
<div class="label">参与智能体</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>参与智能体</h2>
|
||||
<div class="agent-list">
|
||||
{''.join(f'<span class="agent-tag">{agent}</span>' for agent in all_agents) if all_agents else '无'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>任务步骤</h2>
|
||||
<div class="steps-list">
|
||||
{self._format_steps_html(task_outline)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>执行结果</h2>
|
||||
<div class="result-content">
|
||||
{self._format_result_html(result)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"信息图导出失败: {e}")
|
||||
return False
|
||||
|
||||
def _format_steps_html(self, task_outline):
|
||||
"""格式化步骤为 HTML"""
|
||||
if not task_outline or not isinstance(task_outline, dict):
|
||||
return '<p>无</p>'
|
||||
|
||||
collaboration_process = task_outline.get('Collaboration Process', [])
|
||||
if not collaboration_process or not isinstance(collaboration_process, list):
|
||||
return '<p>无</p>'
|
||||
|
||||
steps_html = []
|
||||
for idx, step in enumerate(collaboration_process, 1):
|
||||
if isinstance(step, dict):
|
||||
step_name = step.get('StepName', f'步骤 {idx}')
|
||||
task_content = step.get('TaskContent', '')
|
||||
agent_selection = step.get('AgentSelection', [])
|
||||
agents_str = '、'.join(agent_selection) if isinstance(agent_selection, list) else ''
|
||||
|
||||
steps_html.append(f"""
|
||||
<div class="step-item">
|
||||
<div class="step-name">{idx}. {step_name}</div>
|
||||
<div class="step-content">{task_content}</div>
|
||||
<div class="step-content">参与智能体: {agents_str}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
return ''.join(steps_html) if steps_html else '<p>无</p>'
|
||||
|
||||
def _format_result_html(self, result):
|
||||
"""格式化执行结果为 HTML"""
|
||||
if not result:
|
||||
return '<p>无执行结果</p>'
|
||||
|
||||
try:
|
||||
if isinstance(result, list):
|
||||
# 遍历每个步骤的结果
|
||||
result_html = []
|
||||
for idx, item in enumerate(result, 1):
|
||||
result_html.append(f'<div class="result-step"><h4>步骤 {idx}</h4>')
|
||||
if isinstance(item, dict):
|
||||
# 尝试提取关键信息
|
||||
for key, value in item.items():
|
||||
if key in ['LogNodeType', 'NodeId']:
|
||||
continue
|
||||
if isinstance(value, str) and len(value) > 500:
|
||||
value = value[:500] + '...'
|
||||
result_html.append(f'<p><strong>{key}:</strong> {value}</p>')
|
||||
else:
|
||||
result_html.append(f'<p>{item}</p>')
|
||||
result_html.append('</div>')
|
||||
return ''.join(result_html) if result_html else '<p>无执行结果</p>'
|
||||
elif isinstance(result, dict):
|
||||
# 字典格式的结果
|
||||
result_html = []
|
||||
for key, value in result.items():
|
||||
if isinstance(value, str) and len(value) > 500:
|
||||
value = value[:500] + '...'
|
||||
result_html.append(f'<p><strong>{key}:</strong> {value}</p>')
|
||||
return ''.join(result_html) if result_html else '<p>无执行结果</p>'
|
||||
else:
|
||||
# 其他类型直接转字符串
|
||||
result_str = str(result)
|
||||
if len(result_str) > 1000:
|
||||
result_str = result_str[:1000] + '...'
|
||||
return f'<p>{result_str}</p>'
|
||||
except Exception as e:
|
||||
return f'<p>解析执行结果失败: {str(e)}</p>'
|
||||
|
||||
|
||||
# 导出器工厂
|
||||
class ExportFactory:
|
||||
"""导出器工厂类"""
|
||||
|
||||
_exporters = {
|
||||
'markdown': MarkdownExporter,
|
||||
'doc': DocxExporter,
|
||||
'excel': ExcelExporter,
|
||||
'ppt': PptExporter,
|
||||
'mindmap': MindmapExporter,
|
||||
'infographic': InfographicExporter,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(cls, export_type: str, task_data: Dict[str, Any]) -> Optional[BaseExporter]:
|
||||
"""创建导出器实例"""
|
||||
exporter_class = cls._exporters.get(export_type)
|
||||
if exporter_class:
|
||||
return exporter_class(task_data)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def export(cls, export_type: str, task_data: Dict[str, Any], file_path: str) -> bool:
|
||||
"""执行导出"""
|
||||
exporter = cls.create(export_type, task_data)
|
||||
if exporter:
|
||||
return exporter.generate(file_path)
|
||||
return False
|
||||
@@ -5,8 +5,8 @@ AgentCoord 数据库模块
|
||||
"""
|
||||
|
||||
from .database import get_db, get_db_context, test_connection, engine, text
|
||||
from .models import MultiAgentTask, UserAgent, TaskStatus
|
||||
from .crud import MultiAgentTaskCRUD, UserAgentCRUD
|
||||
from .models import MultiAgentTask, UserAgent, ExportRecord, TaskStatus
|
||||
from .crud import MultiAgentTaskCRUD, UserAgentCRUD, ExportRecordCRUD
|
||||
|
||||
__all__ = [
|
||||
# 连接管理
|
||||
@@ -18,8 +18,10 @@ __all__ = [
|
||||
# 模型
|
||||
"MultiAgentTask",
|
||||
"UserAgent",
|
||||
"ExportRecord",
|
||||
"TaskStatus",
|
||||
# CRUD
|
||||
"MultiAgentTaskCRUD",
|
||||
"UserAgentCRUD",
|
||||
"ExportRecordCRUD",
|
||||
]
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import MultiAgentTask, UserAgent
|
||||
from .models import MultiAgentTask, UserAgent, ExportRecord
|
||||
|
||||
|
||||
class MultiAgentTaskCRUD:
|
||||
@@ -414,3 +414,85 @@ class UserAgentCRUD:
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
return agent
|
||||
|
||||
|
||||
class ExportRecordCRUD:
|
||||
"""导出记录 CRUD 操作"""
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
db: Session,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
export_type: str,
|
||||
file_name: str,
|
||||
file_path: str,
|
||||
file_url: str = "",
|
||||
file_size: int = 0,
|
||||
) -> ExportRecord:
|
||||
"""创建导出记录"""
|
||||
record = ExportRecord(
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
export_type=export_type,
|
||||
file_name=file_name,
|
||||
file_path=file_path,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, record_id: int) -> Optional[ExportRecord]:
|
||||
"""根据 ID 获取记录"""
|
||||
return db.query(ExportRecord).filter(ExportRecord.id == record_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_by_task_id(
|
||||
db: Session, task_id: str, limit: int = 50
|
||||
) -> List[ExportRecord]:
|
||||
"""根据任务 ID 获取导出记录列表"""
|
||||
return (
|
||||
db.query(ExportRecord)
|
||||
.filter(ExportRecord.task_id == task_id)
|
||||
.order_by(ExportRecord.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_by_user_id(
|
||||
db: Session, user_id: str, limit: int = 50
|
||||
) -> List[ExportRecord]:
|
||||
"""根据用户 ID 获取导出记录列表"""
|
||||
return (
|
||||
db.query(ExportRecord)
|
||||
.filter(ExportRecord.user_id == user_id)
|
||||
.order_by(ExportRecord.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, record_id: int) -> bool:
|
||||
"""删除导出记录"""
|
||||
record = db.query(ExportRecord).filter(ExportRecord.id == record_id).first()
|
||||
if record:
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_by_task_id(db: Session, task_id: str) -> bool:
|
||||
"""删除任务的所有导出记录"""
|
||||
records = db.query(ExportRecord).filter(ExportRecord.task_id == task_id).all()
|
||||
if records:
|
||||
for record in records:
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -81,6 +81,39 @@ class MultiAgentTask(Base):
|
||||
}
|
||||
|
||||
|
||||
class ExportRecord(Base):
|
||||
"""导出记录模型"""
|
||||
__tablename__ = "export_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id = Column(String(64), nullable=False, index=True) # 关联任务ID
|
||||
user_id = Column(String(64), nullable=False, index=True) # 用户ID
|
||||
export_type = Column(String(32), nullable=False) # 导出类型: doc/markdown/mindmap/infographic/excel/ppt
|
||||
file_name = Column(String(256), nullable=False) # 文件名
|
||||
file_path = Column(String(512), nullable=False) # 服务器存储路径
|
||||
file_url = Column(String(512)) # 访问URL
|
||||
file_size = Column(Integer, default=0) # 文件大小(字节)
|
||||
created_at = Column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_export_records_task_user", "task_id", "user_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"task_id": self.task_id,
|
||||
"user_id": self.user_id,
|
||||
"export_type": self.export_type,
|
||||
"file_name": self.file_name,
|
||||
"file_path": self.file_path,
|
||||
"file_url": self.file_url,
|
||||
"file_size": self.file_size,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class UserAgent(Base):
|
||||
"""用户保存的智能体配置模型 (可选表)"""
|
||||
__tablename__ = "user_agents"
|
||||
|
||||
@@ -8,3 +8,6 @@ flask-socketio==5.3.6
|
||||
python-socketio==5.11.0
|
||||
simple-websocket==1.0.0
|
||||
eventlet==0.40.4
|
||||
python-docx==1.1.2
|
||||
openpyxl==3.1.5
|
||||
python-pptx==1.0.2
|
||||
@@ -1,4 +1,5 @@
|
||||
from flask import Flask, request, jsonify, Response, stream_with_context
|
||||
from flask import Flask, request, jsonify, Response, stream_with_context, send_file
|
||||
from flask_cors import CORS
|
||||
from flask_socketio import SocketIO, emit, join_room, leave_room
|
||||
import json
|
||||
from DataProcess import Add_Collaboration_Brief_FrontEnd
|
||||
@@ -27,10 +28,14 @@ from db import (
|
||||
get_db_context,
|
||||
MultiAgentTaskCRUD,
|
||||
UserAgentCRUD,
|
||||
ExportRecordCRUD,
|
||||
TaskStatus,
|
||||
text,
|
||||
)
|
||||
|
||||
# 导出模块导入
|
||||
from AgentCoord.Export import ExportFactory
|
||||
|
||||
# initialize global variables
|
||||
yaml_file = os.path.join(os.getcwd(), "config", "config.yaml")
|
||||
try:
|
||||
@@ -48,8 +53,18 @@ AgentProfile_Dict = {}
|
||||
Request_Cache: dict[str, str] = {}
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'agentcoord-secret-key'
|
||||
CORS(app) # 启用 CORS 支持
|
||||
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
|
||||
|
||||
# 配置静态文件服务(用于导出文件访问)
|
||||
EXPORT_DIR = os.path.join(os.getcwd(), "uploads", "exports")
|
||||
|
||||
@app.route('/uploads/<path:filename>', methods=['GET'])
|
||||
def serve_export_file(filename):
|
||||
"""服务导出文件(静态文件访问)"""
|
||||
from flask import send_from_directory
|
||||
return send_from_directory('uploads', filename)
|
||||
|
||||
def truncate_rehearsal_log(RehearsalLog: List, restart_from_step_index: int) -> List:
|
||||
"""
|
||||
截断 RehearsalLog,只保留指定索引之前的步骤结果
|
||||
@@ -2663,6 +2678,321 @@ def handle_update_assigned_agents(data):
|
||||
})
|
||||
|
||||
|
||||
# ==================== 导出功能 ====================
|
||||
|
||||
# 导出类型配置
|
||||
EXPORT_TYPE_CONFIG = {
|
||||
"doc": {"ext": ".docx", "mime": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||
"markdown": {"ext": ".md", "mime": "text/markdown"},
|
||||
"excel": {"ext": ".xlsx", "mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
|
||||
"ppt": {"ext": ".pptx", "mime": "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
|
||||
"mindmap": {"ext": ".md", "mime": "text/markdown"}, # 思维导图先用 markdown
|
||||
"infographic": {"ext": ".html", "mime": "text/html"}, # 信息图先用 html
|
||||
}
|
||||
|
||||
|
||||
def ensure_export_dir(task_id: str) -> str:
|
||||
"""确保导出目录存在"""
|
||||
task_dir = os.path.join(EXPORT_DIR, task_id)
|
||||
os.makedirs(task_dir, exist_ok=True)
|
||||
return task_dir
|
||||
|
||||
|
||||
def generate_export_file_name(task_name: str, export_type: str) -> str:
|
||||
"""生成导出文件名"""
|
||||
from datetime import datetime
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
# 清理文件名中的非法字符
|
||||
safe_name = "".join(c for c in task_name if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||
return f"{safe_name}_{export_type}_{timestamp}"
|
||||
|
||||
|
||||
@socketio.on('export')
|
||||
def handle_export(data):
|
||||
"""
|
||||
WebSocket:处理导出请求
|
||||
|
||||
请求格式:
|
||||
{
|
||||
"id": "request-id",
|
||||
"action": "export",
|
||||
"data": {
|
||||
"task_id": "task-id", // 任务ID
|
||||
"export_type": "doc", // 导出类型: doc/markdown/excel/ppt/mindmap/infographic
|
||||
"user_id": "user-id", // 用户ID
|
||||
}
|
||||
}
|
||||
"""
|
||||
request_id = data.get('id')
|
||||
incoming_data = data.get('data', {})
|
||||
task_id = incoming_data.get('task_id')
|
||||
export_type = incoming_data.get('export_type')
|
||||
user_id = incoming_data.get('user_id', 'anonymous')
|
||||
|
||||
# 参数验证
|
||||
if not task_id:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': '缺少 task_id 参数'
|
||||
})
|
||||
return
|
||||
|
||||
if not export_type or export_type not in EXPORT_TYPE_CONFIG:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': f'无效的导出类型: {export_type}'
|
||||
})
|
||||
return
|
||||
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
# 获取任务数据
|
||||
task = MultiAgentTaskCRUD.get_by_id(db, task_id)
|
||||
if not task:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': f'任务不存在: {task_id}'
|
||||
})
|
||||
return
|
||||
|
||||
# 准备导出数据
|
||||
export_data = {
|
||||
'task_name': task.query or '未命名任务',
|
||||
'task_content': task.query or '', # 使用 query 作为任务描述
|
||||
'task_outline': task.task_outline,
|
||||
'result': task.result,
|
||||
'agents_info': task.agents_info,
|
||||
'assigned_agents': task.assigned_agents,
|
||||
}
|
||||
|
||||
# 生成文件名
|
||||
file_name_base = generate_export_file_name(export_data['task_name'], export_type)
|
||||
config = EXPORT_TYPE_CONFIG[export_type]
|
||||
file_name = file_name_base + config['ext']
|
||||
file_path = os.path.join(ensure_export_dir(task_id), file_name)
|
||||
|
||||
# 生成文件内容
|
||||
# 使用 ExportFactory 来生成各种格式的文件
|
||||
try:
|
||||
success = ExportFactory.export(export_type, export_data, file_path)
|
||||
if not success:
|
||||
# 如果导出失败,创建空文件占位
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(b'')
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': f'导出类型 {export_type} 不支持或生成失败'
|
||||
})
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"导出文件失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# 导出失败时创建空文件
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(b'')
|
||||
|
||||
# 获取文件大小
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# 生成访问URL(基于文件路径)
|
||||
# 相对路径用于静态访问
|
||||
relative_path = os.path.join('uploads', 'exports', task_id, file_name)
|
||||
file_url = f"/{relative_path.replace(os.sep, '/')}"
|
||||
|
||||
# 保存导出记录到数据库
|
||||
record = ExportRecordCRUD.create(
|
||||
db=db,
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
export_type=export_type,
|
||||
file_name=file_name,
|
||||
file_path=file_path,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'record_id': record.id,
|
||||
'file_name': file_name,
|
||||
'file_url': file_url,
|
||||
'file_size': file_size,
|
||||
'export_type': export_type,
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
|
||||
@socketio.on('get_export_list')
|
||||
def handle_get_export_list(data):
|
||||
"""
|
||||
WebSocket:获取导出记录列表
|
||||
|
||||
请求格式:
|
||||
{
|
||||
"id": "request-id",
|
||||
"action": "get_export_list",
|
||||
"data": {
|
||||
"task_id": "task-id", // 任务ID
|
||||
}
|
||||
}
|
||||
"""
|
||||
request_id = data.get('id')
|
||||
incoming_data = data.get('data', {})
|
||||
task_id = incoming_data.get('task_id')
|
||||
|
||||
if not task_id:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': '缺少 task_id 参数'
|
||||
})
|
||||
return
|
||||
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
records = ExportRecordCRUD.get_by_task_id(db, task_id)
|
||||
export_list = [record.to_dict() for record in records]
|
||||
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'list': export_list,
|
||||
'total': len(export_list)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
|
||||
# ==================== REST API 接口 ====================
|
||||
|
||||
@app.route('/api/export/<int:record_id>/download', methods=['GET'])
|
||||
def download_export(record_id: int):
|
||||
"""下载导出文件"""
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
record = ExportRecordCRUD.get_by_id(db, record_id)
|
||||
if not record:
|
||||
return jsonify({'error': '导出记录不存在'}), 404
|
||||
|
||||
if not os.path.exists(record.file_path):
|
||||
return jsonify({'error': '文件不存在'}), 404
|
||||
|
||||
# 发送文件
|
||||
config = EXPORT_TYPE_CONFIG.get(record.export_type, {})
|
||||
mime_type = config.get('mime', 'application/octet-stream')
|
||||
|
||||
return send_file(
|
||||
record.file_path,
|
||||
mimetype=mime_type,
|
||||
as_attachment=True,
|
||||
download_name=record.file_name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/export/<int:record_id>/preview', methods=['GET'])
|
||||
def preview_export(record_id: int):
|
||||
"""预览导出文件"""
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
record = ExportRecordCRUD.get_by_id(db, record_id)
|
||||
if not record:
|
||||
return jsonify({'error': '导出记录不存在'}), 404
|
||||
|
||||
if not os.path.exists(record.file_path):
|
||||
return jsonify({'error': '文件不存在'}), 404
|
||||
|
||||
# 根据文件类型返回不同的 Content-Type
|
||||
config = EXPORT_TYPE_CONFIG.get(record.export_type, {})
|
||||
mime_type = config.get('mime', 'application/octet-stream')
|
||||
|
||||
# 读取文件内容
|
||||
if record.export_type == 'markdown':
|
||||
with open(record.file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
return jsonify({'content': content, 'type': 'markdown'})
|
||||
else:
|
||||
# 其他类型返回文件路径,前端自行处理
|
||||
return jsonify({
|
||||
'file_url': record.file_url,
|
||||
'file_name': record.file_name,
|
||||
'type': record.export_type
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/export/<int:record_id>/share', methods=['GET'])
|
||||
def share_export(record_id: int):
|
||||
"""生成分享链接"""
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
record = ExportRecordCRUD.get_by_id(db, record_id)
|
||||
if not record:
|
||||
return jsonify({'error': '导出记录不存在'}), 404
|
||||
|
||||
# 生成分享Token(简化实现,直接用记录ID)
|
||||
share_token = f"export_{record.id}_{int(record.created_at.timestamp())}"
|
||||
share_url = f"/share/{share_token}"
|
||||
|
||||
return jsonify({
|
||||
'share_url': share_url,
|
||||
'file_name': record.file_name,
|
||||
'expired_at': None # TODO: 可以添加过期时间
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/export/<int:record_id>', methods=['DELETE'])
|
||||
def delete_export(record_id: int):
|
||||
"""删除导出记录"""
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
record = ExportRecordCRUD.get_by_id(db, record_id)
|
||||
if not record:
|
||||
return jsonify({'error': '导出记录不存在'}), 404
|
||||
|
||||
# 删除物理文件
|
||||
if os.path.exists(record.file_path):
|
||||
os.remove(record.file_path)
|
||||
|
||||
# 删除数据库记录
|
||||
ExportRecordCRUD.delete(db, record_id)
|
||||
|
||||
return jsonify({'success': True, 'message': '删除成功'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="start the backend for AgentCoord"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import websocket from '@/utils/websocket'
|
||||
import type { Agent, IApiStepTask, IRawPlanResponse, IRawStepTask } from '@/stores'
|
||||
import { withRetry } from '@/utils/retry'
|
||||
import request from '@/utils/request'
|
||||
import { useConfigStoreHook } from '@/stores'
|
||||
|
||||
export interface ActionHistory {
|
||||
ID: string
|
||||
@@ -770,6 +772,209 @@ class Api {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 导出功能 ====================
|
||||
|
||||
/**
|
||||
* 导出任务为指定格式
|
||||
*/
|
||||
exportTask = async (params: {
|
||||
task_id: string
|
||||
export_type: string
|
||||
user_id?: string
|
||||
}): Promise<{
|
||||
record_id: number
|
||||
file_name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
export_type: string
|
||||
}> => {
|
||||
if (!websocket.connected) {
|
||||
throw new Error('WebSocket未连接')
|
||||
}
|
||||
|
||||
const rawResponse = await websocket.send('export', {
|
||||
task_id: params.task_id,
|
||||
export_type: params.export_type,
|
||||
user_id: params.user_id || 'anonymous',
|
||||
})
|
||||
|
||||
const response = this.extractResponse<{
|
||||
record_id: number
|
||||
file_name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
export_type: string
|
||||
}>(rawResponse)
|
||||
|
||||
if (response) {
|
||||
console.log('导出成功:', response)
|
||||
return response
|
||||
}
|
||||
throw new Error('导出失败')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导出记录列表
|
||||
*/
|
||||
getExportList = async (params: {
|
||||
task_id: string
|
||||
}): Promise<{
|
||||
list: Array<{
|
||||
id: number
|
||||
task_id: string
|
||||
user_id: string
|
||||
export_type: string
|
||||
file_name: string
|
||||
file_path: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
created_at: string
|
||||
}>
|
||||
total: number
|
||||
}> => {
|
||||
if (!websocket.connected) {
|
||||
throw new Error('WebSocket未连接')
|
||||
}
|
||||
|
||||
const rawResponse = await websocket.send('get_export_list', {
|
||||
task_id: params.task_id,
|
||||
})
|
||||
|
||||
const response = this.extractResponse<{
|
||||
list: Array<{
|
||||
id: number
|
||||
task_id: string
|
||||
user_id: string
|
||||
export_type: string
|
||||
file_name: string
|
||||
file_path: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
created_at: string
|
||||
}>
|
||||
total: number
|
||||
}>(rawResponse)
|
||||
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
return { list: [], total: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载导出文件
|
||||
*/
|
||||
downloadExport = async (recordId: number): Promise<void> => {
|
||||
const configStore = useConfigStoreHook()
|
||||
const baseURL = configStore.config.apiBaseUrl || ''
|
||||
const url = `${baseURL}/api/export/${recordId}/download`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('下载失败')
|
||||
}
|
||||
|
||||
// 获取文件名从 Content-Disposition 头
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
let fileName = 'download'
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||
if (match) {
|
||||
fileName = match[1].replace(/['"]/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 Blob 并下载
|
||||
const blob = await response.blob()
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览导出文件
|
||||
*/
|
||||
previewExport = async (recordId: number): Promise<{
|
||||
content?: string
|
||||
file_url?: string
|
||||
file_name?: string
|
||||
type: string
|
||||
}> => {
|
||||
const configStore = useConfigStoreHook()
|
||||
const baseURL = configStore.config.apiBaseUrl || ''
|
||||
const url = `${baseURL}/api/export/${recordId}/preview`
|
||||
|
||||
const response = await request<{
|
||||
content?: string
|
||||
file_url?: string
|
||||
file_name?: string
|
||||
type: string
|
||||
}>({
|
||||
url,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分享链接
|
||||
*/
|
||||
shareExport = async (recordId: number): Promise<{
|
||||
share_url: string
|
||||
file_name: string
|
||||
expired_at: string | null
|
||||
}> => {
|
||||
const configStore = useConfigStoreHook()
|
||||
const baseURL = configStore.config.apiBaseUrl || ''
|
||||
const url = `${baseURL}/api/export/${recordId}/share`
|
||||
|
||||
const response = await request<{
|
||||
share_url: string
|
||||
file_name: string
|
||||
expired_at: string | null
|
||||
}>({
|
||||
url,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除导出记录
|
||||
*/
|
||||
deleteExport = async (recordId: number): Promise<boolean> => {
|
||||
const configStore = useConfigStoreHook()
|
||||
const baseURL = configStore.config.apiBaseUrl || ''
|
||||
const url = `${baseURL}/api/export/${recordId}`
|
||||
|
||||
try {
|
||||
await request({
|
||||
url,
|
||||
method: 'DELETE',
|
||||
})
|
||||
console.log('删除导出记录成功:', recordId)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('删除导出记录失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Api()
|
||||
|
||||
@@ -20,7 +20,7 @@ onMounted(async () => {
|
||||
agentsStore.setAgents(res)
|
||||
}
|
||||
await api.setAgents(
|
||||
agentsStore.agents.map(item => pick(item, ['Name', 'Profile', 'apiUrl', 'apiKey', 'apiModel']))
|
||||
agentsStore.agents.map(item => pick(item, ['Name', 'Profile', 'Icon', 'Classification', 'apiUrl', 'apiKey', 'apiModel']))
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
v-for="item in exportStyles"
|
||||
:key="item.type"
|
||||
class="export-style-item"
|
||||
:class="{ 'is-loading': loadingTypes.includes(item.type) }"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<div class="style-icon" :style="{ color: item.color }">
|
||||
@@ -18,13 +19,13 @@
|
||||
<!-- 导出结果列表 -->
|
||||
<div class="export-result-section">
|
||||
<div v-if="exportResults.length > 0" class="result-list">
|
||||
<div v-for="(result, index) in exportResults" :key="index" class="result-item">
|
||||
<div v-for="(result, index) in exportResults" :key="result.id || index" class="result-item">
|
||||
<div class="result-icon" :style="{ color: result.color }">
|
||||
<SvgIcon :icon-class="result.icon" size="30px" />
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<div class="result-name">{{ result.name }}</div>
|
||||
<div class="result-time">{{ formatTime(result.exportTime || Date.now()) }}</div>
|
||||
<div class="result-time">{{ formatTime(result.exportTime || result.created_at) }}</div>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<el-popover
|
||||
@@ -71,18 +72,53 @@
|
||||
content="删除后,该记录无法恢复 !"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
:title="previewData?.file_name || '文件预览'"
|
||||
width="80%"
|
||||
:close-on-click-modal="true"
|
||||
class="preview-dialog"
|
||||
>
|
||||
<div class="preview-content">
|
||||
<div v-if="previewLoading" class="preview-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="previewData?.type === 'markdown'" class="preview-markdown">
|
||||
<pre>{{ previewData?.content }}</pre>
|
||||
</div>
|
||||
<div v-else-if="previewData?.file_url" class="preview-iframe">
|
||||
<iframe :src="previewData?.file_url" frameborder="0"></iframe>
|
||||
<div class="preview-tip">该文件类型暂不支持直接预览,请下载查看</div>
|
||||
</div>
|
||||
<div v-else class="preview-empty">无法预览此文件</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="handlePreviewDownload">下载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import SvgIcon from '@/components/SvgIcon/index.vue'
|
||||
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog/index.vue'
|
||||
import { useAgentsStore } from '@/stores'
|
||||
import api from '@/api'
|
||||
|
||||
const agentsStore = useAgentsStore()
|
||||
|
||||
// Props 接收大任务ID(数据库主键)
|
||||
const props = defineProps<{
|
||||
TaskID?: string // 大任务ID,用于导出整个任务的执行结果
|
||||
}>()
|
||||
|
||||
// 事件定义
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
@@ -96,13 +132,19 @@ interface ExportStyle {
|
||||
color: string
|
||||
}
|
||||
|
||||
// 导出结果类型
|
||||
// 导出结果类型(与后端返回的数据结构匹配)
|
||||
interface ExportResult {
|
||||
id?: number
|
||||
record_id?: number
|
||||
name: string
|
||||
icon: string
|
||||
type: string
|
||||
color: string
|
||||
exportTime?: number // 时间戳
|
||||
exportTime?: number
|
||||
created_at?: string
|
||||
file_url?: string
|
||||
file_name?: string
|
||||
export_type?: string
|
||||
}
|
||||
|
||||
// 导出样式数据
|
||||
@@ -118,14 +160,40 @@ const exportStyles = ref<ExportStyle[]>([
|
||||
// 导出结果列表
|
||||
const exportResults = ref<ExportResult[]>([])
|
||||
|
||||
// 加载中的导出类型
|
||||
const loadingTypes = ref<string[]>([])
|
||||
|
||||
// 删除对话框相关
|
||||
const dialogVisible = ref(false)
|
||||
const resultToDelete = ref<ExportResult | null>(null)
|
||||
|
||||
// 预览相关
|
||||
const previewVisible = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewData = ref<{
|
||||
content?: string
|
||||
file_url?: string
|
||||
file_name?: string
|
||||
type: string
|
||||
}>({ type: '' })
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (timestamp: number): string => {
|
||||
const formatTime = (timestamp: any): string => {
|
||||
if (!timestamp) return '未知时间'
|
||||
|
||||
let time: number
|
||||
|
||||
// 如果是 ISO 字符串,转换为时间戳
|
||||
if (typeof timestamp === 'string') {
|
||||
time = new Date(timestamp).getTime()
|
||||
} else if (typeof timestamp === 'number') {
|
||||
time = timestamp
|
||||
} else {
|
||||
return '未知时间'
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const diff = now - time
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
@@ -135,25 +203,133 @@ const formatTime = (timestamp: number): string => {
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
|
||||
const date = new Date(timestamp)
|
||||
const date = new Date(time)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
date.getDate()
|
||||
).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 获取导出记录列表
|
||||
const fetchExportList = async () => {
|
||||
// 使用大任务ID(从 TaskTemplate 传入的 props.TaskID)
|
||||
const taskId = props.TaskID
|
||||
if (!taskId) {
|
||||
console.warn('没有任务ID,无法获取导出列表')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.getExportList({ task_id: taskId })
|
||||
|
||||
// 转换为前端显示格式
|
||||
exportResults.value = result.list.map((item) => {
|
||||
const style = exportStyles.value.find((s) => s.type === item.export_type)
|
||||
return {
|
||||
id: item.id,
|
||||
record_id: item.id,
|
||||
name: item.file_name,
|
||||
icon: style?.icon || 'DOCX',
|
||||
type: item.export_type,
|
||||
color: style?.color || '#999',
|
||||
exportTime: new Date(item.created_at).getTime(),
|
||||
created_at: item.created_at,
|
||||
file_url: item.file_url,
|
||||
file_name: item.file_name,
|
||||
export_type: item.export_type,
|
||||
}
|
||||
})
|
||||
|
||||
console.log('获取导出列表成功:', exportResults.value)
|
||||
} catch (error) {
|
||||
console.error('获取导出列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 预览文件
|
||||
const previewResult = (_result: ExportResult) => {
|
||||
ElMessage.success('预览功能开发中')
|
||||
const previewResult = async (result: ExportResult) => {
|
||||
const recordId = result.id || result.record_id
|
||||
if (!recordId) {
|
||||
ElMessage.error('无法获取文件ID')
|
||||
return
|
||||
}
|
||||
|
||||
// 先初始化数据,再显示对话框,避免渲染时属性不存在
|
||||
previewData.value = {
|
||||
content: '',
|
||||
file_url: '',
|
||||
file_name: result.file_name || '文件预览',
|
||||
type: result.type || ''
|
||||
}
|
||||
previewVisible.value = true
|
||||
previewLoading.value = true
|
||||
|
||||
try {
|
||||
const data = await api.previewExport(recordId)
|
||||
previewData.value = {
|
||||
...previewData.value,
|
||||
...data,
|
||||
}
|
||||
previewLoading.value = false
|
||||
} catch (error) {
|
||||
console.error('预览失败:', error)
|
||||
ElMessage.error('预览失败')
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 预览弹窗中的下载按钮
|
||||
const handlePreviewDownload = () => {
|
||||
const fileName = previewData.value?.file_name
|
||||
const recordId = fileName ? exportResults.value.find(
|
||||
(r) => r.file_name === fileName
|
||||
)?.id : null
|
||||
|
||||
if (recordId) {
|
||||
downloadById(recordId)
|
||||
}
|
||||
previewVisible.value = false
|
||||
}
|
||||
|
||||
// 下载文件(根据ID)
|
||||
const downloadById = async (recordId: number) => {
|
||||
try {
|
||||
await api.downloadExport(recordId)
|
||||
ElMessage.success('开始下载')
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadResult = (_result: ExportResult) => {
|
||||
ElMessage.success('下载功能开发中')
|
||||
const downloadResult = (result: ExportResult) => {
|
||||
const recordId = result.id || result.record_id
|
||||
if (!recordId) {
|
||||
ElMessage.error('无法获取文件ID')
|
||||
return
|
||||
}
|
||||
|
||||
downloadById(recordId)
|
||||
}
|
||||
|
||||
// 分享
|
||||
const shareResult = (_result: ExportResult) => {
|
||||
ElMessage.success('分享功能开发中')
|
||||
const shareResult = async (result: ExportResult) => {
|
||||
const recordId = result.id || result.record_id
|
||||
if (!recordId) {
|
||||
ElMessage.error('无法获取文件ID')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.shareExport(recordId)
|
||||
// 将分享链接复制到剪贴板
|
||||
const fullUrl = window.location.origin + data.share_url
|
||||
await navigator.clipboard.writeText(fullUrl)
|
||||
ElMessage.success('分享链接已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('分享失败:', error)
|
||||
ElMessage.error('分享失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
@@ -163,44 +339,104 @@ const deleteResult = (result: ExportResult) => {
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = () => {
|
||||
const confirmDelete = async () => {
|
||||
if (!resultToDelete.value) return
|
||||
|
||||
const index = exportResults.value.indexOf(resultToDelete.value)
|
||||
if (index > -1) {
|
||||
exportResults.value.splice(index, 1)
|
||||
ElMessage.success('删除成功')
|
||||
const recordId = resultToDelete.value.id || resultToDelete.value.record_id
|
||||
if (!recordId) {
|
||||
ElMessage.error('无法获取文件ID')
|
||||
dialogVisible.value = false
|
||||
resultToDelete.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await api.deleteExport(recordId)
|
||||
if (success) {
|
||||
// 从列表中移除
|
||||
const index = exportResults.value.findIndex(
|
||||
(r) => (r.id || r.record_id) === recordId
|
||||
)
|
||||
if (index > -1) {
|
||||
exportResults.value.splice(index, 1)
|
||||
}
|
||||
ElMessage.success('删除成功')
|
||||
} else {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
resultToDelete.value = null
|
||||
}
|
||||
|
||||
// 选择导出样式
|
||||
const handleSelect = (item: ExportStyle) => {
|
||||
const currentTask = agentsStore.currentTask
|
||||
if (!currentTask) {
|
||||
const handleSelect = async (item: ExportStyle) => {
|
||||
// 使用大任务ID(从 TaskTemplate 传入的 props.TaskID)
|
||||
const taskId = props.TaskID
|
||||
console.log('导出任务 - TaskID:', taskId)
|
||||
|
||||
if (!taskId) {
|
||||
ElMessage.warning('请先选择任务')
|
||||
return
|
||||
}
|
||||
|
||||
// 生成导出名称:任务名称 + 样式名称
|
||||
const taskName = currentTask.StepName || '未知任务'
|
||||
const exportName = `${taskName}${item.name}`
|
||||
|
||||
// 添加到导出结果列表(添加到最前面)
|
||||
const newItem = {
|
||||
name: exportName,
|
||||
icon: item.icon,
|
||||
type: item.type,
|
||||
color: item.color,
|
||||
exportTime: Date.now()
|
||||
// 防止重复点击
|
||||
if (loadingTypes.value.includes(item.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
exportResults.value.unshift(newItem)
|
||||
// 添加加载状态
|
||||
loadingTypes.value.push(item.type)
|
||||
|
||||
console.log('导出任务:', { task: currentTask, style: item })
|
||||
ElMessage.success(`已开始导出: ${item.name}`)
|
||||
try {
|
||||
// 调用后端接口导出(导出整个大任务的执行结果)
|
||||
console.log('开始导出 - task_id:', taskId, 'type:', item.type)
|
||||
const result = await api.exportTask({
|
||||
task_id: taskId,
|
||||
export_type: item.type,
|
||||
user_id: 'current_user', // TODO: 获取实际用户ID
|
||||
})
|
||||
console.log('导出结果:', result)
|
||||
|
||||
// 添加到导出结果列表(添加到最前面)
|
||||
const newItem: ExportResult = {
|
||||
id: result.record_id,
|
||||
record_id: result.record_id,
|
||||
name: result.file_name,
|
||||
icon: item.icon,
|
||||
type: item.type,
|
||||
color: item.color,
|
||||
exportTime: Date.now(),
|
||||
created_at: new Date().toISOString(),
|
||||
file_url: result.file_url,
|
||||
file_name: result.file_name,
|
||||
export_type: item.type,
|
||||
}
|
||||
|
||||
exportResults.value.unshift(newItem)
|
||||
|
||||
console.log('导出成功:', result)
|
||||
ElMessage.success(`导出成功: ${item.name}`)
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
ElMessage.error(`导出失败: ${item.name}`)
|
||||
} finally {
|
||||
// 移除加载状态
|
||||
const idx = loadingTypes.value.indexOf(item.type)
|
||||
if (idx > -1) {
|
||||
loadingTypes.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取导出列表
|
||||
onMounted(() => {
|
||||
fetchExportList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -239,6 +475,11 @@ const handleSelect = (item: ExportStyle) => {
|
||||
background: var(--color-bg-content-hover);
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.style-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-plan-item);
|
||||
@@ -362,6 +603,69 @@ const handleSelect = (item: ExportStyle) => {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 预览内容样式
|
||||
.preview-content {
|
||||
min-height: 300px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
gap: 12px;
|
||||
color: var(--color-text-placeholder);
|
||||
|
||||
.el-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-markdown {
|
||||
padding: 16px;
|
||||
background: var(--color-bg-detail);
|
||||
border-radius: 8px;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-detail);
|
||||
}
|
||||
|
||||
.preview-tip {
|
||||
margin-top: 12px;
|
||||
color: var(--color-text-placeholder);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -415,4 +719,11 @@ const handleSelect = (item: ExportStyle) => {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 预览弹窗样式
|
||||
.preview-dialog {
|
||||
.el-dialog__body {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -174,7 +174,7 @@ defineExpose({
|
||||
</div>
|
||||
<div class="h-[1px] w-full bg-[var(--color-border-separate)] my-[8px]"></div>
|
||||
<div class="export-drawer-body">
|
||||
<ExportList @close="exportDialogVisible = false" />
|
||||
<ExportList :TaskID="TaskID" @close="exportDialogVisible = false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user