diff --git a/backend/database.py b/backend/database.py index a409e87..5cb1226 100644 --- a/backend/database.py +++ b/backend/database.py @@ -26,9 +26,23 @@ async def init_db(): from models.document import Document from models.generation_log import GenerationLog from models.reference_file import ReferenceFile + from models.template_block import TemplateBlock async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) dialect_name = conn.dialect.name + # template_blocks 新增列 + block_columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("template_blocks")]) + if "anchor_start_index" not in block_columns: + await conn.execute(text("ALTER TABLE template_blocks ADD COLUMN anchor_start_index INT DEFAULT 0")) + if "anchor_end_index" not in block_columns: + await conn.execute(text("ALTER TABLE template_blocks ADD COLUMN anchor_end_index INT DEFAULT 0")) + if "html_snippet" not in block_columns: + await conn.execute(text("ALTER TABLE template_blocks ADD COLUMN html_snippet TEXT")) + # generation_logs 新增 block_id + log_columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("generation_logs")]) + if "block_id" not in log_columns: + await conn.execute(text("ALTER TABLE generation_logs ADD COLUMN block_id INT DEFAULT NULL")) + await conn.execute(text("ALTER TABLE generation_logs MODIFY COLUMN paragraph_id INT DEFAULT NULL")) columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("ai_models")]) if "supports_streaming" not in columns: if dialect_name == "sqlite": diff --git a/backend/models/generation_log.py b/backend/models/generation_log.py index c4f2848..2c1719e 100644 --- a/backend/models/generation_log.py +++ b/backend/models/generation_log.py @@ -5,7 +5,8 @@ class GenerationLog(Base): __tablename__ = "generation_logs" id = Column(Integer, primary_key=True, autoincrement=True) document_id = Column(Integer, ForeignKey("documents.id"), nullable=False) - paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=False) + paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True) + block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True, comment="块级生成日志关联") model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True) status = Column(String(20), default="pending", comment="pending/generating/success/failed") content = Column(Text, default="", comment="生成的内容") diff --git a/backend/models/template_block.py b/backend/models/template_block.py index 6bb7960..42bb21a 100644 --- a/backend/models/template_block.py +++ b/backend/models/template_block.py @@ -13,6 +13,9 @@ class TemplateBlock(Base): sort_index = Column(Integer, default=0, comment="排序") block_type = Column(String(30), default="text", comment="heading/text/table/ai_slot/variable") anchor_ref = Column(String(500), default="", comment="原始锚点引用") + anchor_start_index = Column(Integer, default=0, comment="原始文档起始元素索引") + anchor_end_index = Column(Integer, default=0, comment="原始文档结束元素索引") + html_snippet = Column(Text, comment="渲染后的 HTML 片段") title = Column(String(500), default="", comment="块标题") content_json = Column(Text, default="{}", comment="块内容 JSON") style_json = Column(Text, default="{}", comment="块样式 JSON") diff --git a/backend/routers/export.py b/backend/routers/export.py index 9d3e3ae..d78f921 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -15,7 +15,8 @@ from models.document import Document from models.generation_log import GenerationLog from models.paragraph import Paragraph from models.template import Template -from services.document_export import export_document_bytes +from models.template_block import TemplateBlock +from services.document_export import export_document_bytes, export_document_bytes_from_blocks from services.minio_client import ( download_object_bytes, get_presigned_url, @@ -74,6 +75,78 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)): ) +@router.get("/{document_id}/docx-v2") +async def export_docx_v2(document_id: int, db: AsyncSession = Depends(get_db)): + """基于块结构的 Word 导出。""" + document = await db.get(Document, document_id) + if document is None: + raise HTTPException(status_code=404, detail="生成记录不存在") + + template = await db.get(Template, document.template_id) + if template is None: + raise HTTPException(status_code=404, detail="模板不存在") + + template_bucket, template_object = split_bucket_path(template.file_path) + template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object) + + # 查询块和生成日志 + blocks_result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == document.template_id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) + ) + blocks = blocks_result.scalars().all() + + logs_result = await db.execute( + select(GenerationLog) + .where(GenerationLog.document_id == document_id) + ) + logs = logs_result.scalars().all() + + block_dicts = [ + { + "id": b.id, + "sort_index": b.sort_index, + "anchor_start_index": b.anchor_start_index, + "anchor_end_index": b.anchor_end_index, + "edit_mode": b.edit_mode, + "block_type": b.block_type, + "title": b.title, + } + for b in blocks + ] + + log_dicts = [ + { + "id": log.id, + "block_id": log.block_id, + "status": log.status, + "content": json.loads(log.content) if log.content else {"content": []}, + } + for log in logs + ] + + exported_bytes = await asyncio.to_thread( + export_document_bytes_from_blocks, template_bytes, block_dicts, log_dicts + ) + + object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx" + await asyncio.to_thread( + upload_bytes, + settings.MINIO_BUCKET_OUTPUTS, + object_name, + exported_bytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + document.file_path = f"{settings.MINIO_BUCKET_OUTPUTS}/{object_name}" + await db.commit() + return RedirectResponse( + url=get_presigned_url(settings.MINIO_BUCKET_OUTPUTS, object_name), + status_code=307, + ) + + @router.get("/{document_id}/pdf") async def export_pdf(document_id: int): return PlainTextResponse( diff --git a/backend/routers/generate.py b/backend/routers/generate.py index 58e680b..cc88b8b 100644 --- a/backend/routers/generate.py +++ b/backend/routers/generate.py @@ -1,6 +1,7 @@ import asyncio import json import os +import time import uuid from datetime import datetime @@ -17,14 +18,17 @@ from models.generation_log import GenerationLog from models.paragraph import Paragraph from models.reference_file import ReferenceFile from models.template import Template +from models.template_block import TemplateBlock from schemas.schemas import GenerateFullRequest, GenerateTestRequest, ReferenceFileUpdate, Response from services.ai_service import call_ai, stream_ai_preview from services.file_summary import summarize_minio_files from services.generation_runtime import ( build_mock_content, + build_mock_content_for_block, generation_progress, request_cancel, run_generation, + run_generation_for_blocks, update_progress, ) from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url @@ -368,6 +372,45 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge return Response(data=_serialize_document(document)) +@router.post("/full-v2") +async def generate_full_v2(body: GenerateFullRequest, db: AsyncSession = Depends(get_db)): + """基于块结构的全量生成(可视化编辑器使用)。""" + template = await db.get(Template, body.template_id) + if template is None: + raise HTTPException(status_code=404, detail="模板不存在") + + result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == body.template_id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) + ) + blocks = result.scalars().all() + if not blocks: + raise HTTPException(status_code=400, detail="模板下暂无可生成块") + + ai_block_count = sum(1 for b in blocks if b.edit_mode == "ai") + if ai_block_count == 0: + raise HTTPException(status_code=400, detail="模板下没有 AI 生成块") + + document = Document( + template_id=template.id, + name=f"{template.name}-{datetime.now().strftime('%Y%m%d%H%M%S')}", + para_count_done=0, + para_count_total=ai_block_count, + status="generating", + file_path="", + error="", + request_payload_json=json.dumps({"template_name": template.name, "block_count": len(blocks), "ai_block_count": ai_block_count}, ensure_ascii=False), + ) + db.add(document) + await db.flush() + await db.commit() + await db.refresh(document) + update_progress(document.id, status="pending", percent=0, done=0, total=ai_block_count, message="块级生成任务已创建") + asyncio.create_task(run_generation_for_blocks(document.id, template.id)) + return Response(data=_serialize_document(document)) + + @router.get("/progress/{document_id}") async def generate_progress(document_id: int): async def event_generator(): @@ -387,6 +430,86 @@ async def generate_progress(document_id: int): return EventSourceResponse(event_generator()) +@router.post("/blocks/{block_id}/regenerate") +async def regenerate_block( + block_id: int, + body: GenerateTestRequest, + db: AsyncSession = Depends(get_db), +): + """对单个块重新生成内容。""" + block = await db.get(TemplateBlock, block_id) + if block is None: + raise HTTPException(status_code=404, detail="块不存在") + + document = await db.get(Document, body.template_id) + if document is None: + raise HTTPException(status_code=404, detail="生成记录不存在") + + model = None + if body.model_id: + model = await db.get(AiModel, body.model_id) + elif block.model_id: + model = await db.get(AiModel, block.model_id) + + if model is None or model.status != "enabled": + # 使用默认模型 + result = await db.execute( + select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1) + ) + model = result.scalars().first() + + content = {} + status = "success" + error_msg = "" + duration = 0.0 + + if model is None: + content = build_mock_content_for_block(block) + else: + start = time.perf_counter() + try: + temp_para = Paragraph( + template_id=block.template_id, + sort_index=block.sort_index, + title=block.title, + content="", + prompt_text=body.prompt_text or block.prompt_text or "", + output_format=block.output_format, + need_prompt=block.need_prompt, + edit_mode=block.edit_mode, + ) + setattr(temp_para, "enable_reasoning", bool(model.enable_reasoning)) + result_data = await call_ai(temp_para, model, None) + content = result_data.content + except Exception as error: + content = build_mock_content_for_block(block) + status = "failed" + error_msg = str(error) + duration = round(time.perf_counter() - start, 4) + + log = GenerationLog( + document_id=document.id, + block_id=block.id, + model_id=model.id if model else None, + status=status, + content=json.dumps(content, ensure_ascii=False), + duration=duration, + error_msg=error_msg, + ) + db.add(log) + await db.commit() + await db.refresh(log) + + return Response(data={ + "id": log.id, + "block_id": block.id, + "status": log.status, + "content": content, + "duration": log.duration, + "error_msg": log.error_msg, + }) + + @router.get("/documents") async def list_documents( page: int = Query(1, ge=1), @@ -410,6 +533,7 @@ async def get_document(document_id: int, db: AsyncSession = Depends(get_db)): if document is None: raise HTTPException(status_code=404, detail="生成记录不存在") + # 查询段落级日志 log_result = await db.execute( select(GenerationLog, Paragraph) .join(Paragraph, Paragraph.id == GenerationLog.paragraph_id) @@ -431,6 +555,52 @@ async def get_document(document_id: int, db: AsyncSession = Depends(get_db)): payload = _serialize_document(document) payload["logs"] = items + + # 查询块级日志和模板块(用于可视化预览) + blocks_result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == document.template_id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) + ) + template_blocks = blocks_result.scalars().all() + + block_logs_result = await db.execute( + select(GenerationLog) + .where(GenerationLog.document_id == document_id, GenerationLog.block_id.isnot(None)) + ) + block_logs = block_logs_result.scalars().all() + + block_log_map: dict[int, dict] = {} + for log in block_logs: + if log.block_id is not None: + block_log_map[log.block_id] = { + "id": log.id, + "block_id": log.block_id, + "status": log.status, + "content": json.loads(log.content) if log.content else {"content": []}, + "duration": log.duration, + "error_msg": log.error_msg, + } + + payload["template_blocks"] = [ + { + "id": b.id, + "sort_index": b.sort_index, + "block_type": b.block_type, + "anchor_start_index": b.anchor_start_index, + "anchor_end_index": b.anchor_end_index, + "html_snippet": b.html_snippet, + "title": b.title, + "edit_mode": b.edit_mode, + "output_format": b.output_format, + "model_id": b.model_id, + "prompt_text": b.prompt_text, + } + for b in template_blocks + ] + payload["block_logs"] = block_log_map + payload["has_blocks"] = len(template_blocks) > 0 + return Response(data=payload) @@ -460,3 +630,22 @@ async def delete_document(document_id: int, db: AsyncSession = Depends(get_db)): await db.delete(document) await db.commit() return Response(data={"id": document_id}) + + +@router.delete("/blocks/{block_id}/reset/{document_id}") +async def reset_block(block_id: int, document_id: int, db: AsyncSession = Depends(get_db)): + """删除块的生成日志,恢复为模板默认内容。""" + block = await db.get(TemplateBlock, block_id) + if block is None: + raise HTTPException(status_code=404, detail="块不存在") + + result = await db.execute( + select(GenerationLog) + .where(GenerationLog.document_id == document_id, GenerationLog.block_id == block_id) + ) + logs = result.scalars().all() + for log in logs: + await db.delete(log) + await db.commit() + + return Response(data={"block_id": block_id, "deleted_logs": len(logs)}) diff --git a/backend/routers/templates.py b/backend/routers/templates.py index 8651a10..b753218 100644 --- a/backend/routers/templates.py +++ b/backend/routers/templates.py @@ -1,4 +1,5 @@ import asyncio +import json import os import tempfile import uuid @@ -15,9 +16,11 @@ from models.document import Document from models.generation_log import GenerationLog from models.paragraph import Paragraph from models.template import Template +from models.template_block import TemplateBlock from schemas.schemas import Response, TemplateSave from services.minio_client import minio_client from services.template_parser import parse_template +from services.word_to_html import docx_to_full_html, docx_to_html_blocks router = APIRouter() @@ -64,6 +67,32 @@ def _serialize_template(template: Template) -> dict: } +def _serialize_block(block: TemplateBlock) -> dict: + return { + "id": block.id, + "template_id": block.template_id, + "sort_index": block.sort_index, + "block_type": block.block_type, + "anchor_ref": block.anchor_ref, + "anchor_start_index": block.anchor_start_index, + "anchor_end_index": block.anchor_end_index, + "html_snippet": block.html_snippet, + "title": block.title, + "content_json": block.content_json, + "style_json": block.style_json, + "edit_mode": block.edit_mode, + "placeholder_key": block.placeholder_key, + "variable_key": block.variable_key, + "default_value": block.default_value, + "model_id": block.model_id, + "need_prompt": block.need_prompt, + "prompt_text": block.prompt_text, + "need_file": block.need_file, + "file_note": block.file_note, + "output_format": block.output_format, + } + + @router.get("") async def list_templates( page: int = Query(1, ge=1), @@ -106,6 +135,105 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)): return Response(data=payload) +@router.get("/{template_id}/preview") +async def get_template_preview(template_id: int, db: AsyncSession = Depends(get_db)): + """返回模板的 HTML 预览文档及块列表。""" + template = await db.get(Template, template_id) + if template is None: + raise HTTPException(status_code=404, detail="模板不存在") + + # 优先从缓存读取 HTML,否则重新生成 + result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == template_id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) + ) + blocks = result.scalars().all() + + if not blocks: + # 从 MinIO 下载模板文件,重新生成 HTML 和块 + file_path = template.file_path or "" + if "/" in file_path: + bucket, object_name = file_path.split("/", 1) + content = await asyncio.to_thread(minio_client.get_object, bucket, object_name) + try: + file_bytes = content.read() + finally: + content.close() + content.release_conn() + + with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as temp_file: + temp_file.write(file_bytes) + temp_path = temp_file.name + + try: + rendered_blocks = await asyncio.to_thread(docx_to_html_blocks, temp_path) + html_content = await asyncio.to_thread(docx_to_full_html, temp_path) + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + block_rows: list[TemplateBlock] = [] + for rb in rendered_blocks: + anchor_title = "" + if rb.block_type == "heading": + anchor_title = rb.text_content + block_row = TemplateBlock( + template_id=template_id, + sort_index=rb.block_index + 1, + block_type=rb.block_type, + anchor_ref=anchor_title, + anchor_start_index=rb.block_index, + anchor_end_index=rb.block_index, + html_snippet=rb.html, + title=rb.text_content[:200] if rb.text_content else "", + content_json=json.dumps({"text": rb.text_content}, ensure_ascii=False), + style_json=rb.style_json, + edit_mode="manual", + ) + db.add(block_row) + block_rows.append(block_row) + await db.commit() + for br in block_rows: + await db.refresh(br) + blocks = block_rows + + block_list = [_serialize_block(b) for b in blocks] + + # 检查是否有缓存的 HTML 快照 + html_snapshot = "" + for b in blocks: + if b.html_snippet: + html_snapshot += b.html_snippet + "\n" + + if not html_snapshot: + # 重新生成 HTML + file_path = template.file_path or "" + if "/" in file_path: + bucket, object_name = file_path.split("/", 1) + content = await asyncio.to_thread(minio_client.get_object, bucket, object_name) + try: + file_bytes = content.read() + finally: + content.close() + content.release_conn() + with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as temp_file: + temp_file.write(file_bytes) + temp_path = temp_file.name + try: + html_snapshot = await asyncio.to_thread(docx_to_full_html, temp_path) + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + return Response(data={ + "template": _serialize_template(template), + "blocks": block_list, + "html": html_snapshot, + "block_count": len(block_list), + }) + + @router.post("/upload") async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): if not file.filename: @@ -127,6 +255,8 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen try: parsed_items = await asyncio.to_thread(parse_template, temp_path) + rendered_blocks = await asyncio.to_thread(docx_to_html_blocks, temp_path) + html_snapshot = await asyncio.to_thread(docx_to_full_html, temp_path) finally: if os.path.exists(temp_path): os.remove(temp_path) @@ -167,13 +297,37 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen db.add(paragraph) paragraph_rows.append(paragraph) + # 同步生成新版块结构 + block_rows: list[TemplateBlock] = [] + for rb in rendered_blocks: + anchor_title = rb.text_content if rb.block_type == "heading" else "" + block_row = TemplateBlock( + template_id=template.id, + sort_index=rb.block_index + 1, + block_type=rb.block_type, + anchor_ref=anchor_title, + anchor_start_index=rb.block_index, + anchor_end_index=rb.block_index, + html_snippet=rb.html, + title=rb.text_content[:200] if rb.text_content else "", + content_json=json.dumps({"text": rb.text_content}, ensure_ascii=False), + style_json=rb.style_json, + edit_mode="manual", + ) + db.add(block_row) + block_rows.append(block_row) + await db.commit() await db.refresh(template) for paragraph in paragraph_rows: await db.refresh(paragraph) + for br in block_rows: + await db.refresh(br) payload = _serialize_template(template) payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows] + payload["blocks"] = [_serialize_block(b) for b in block_rows] + payload["html"] = html_snapshot return Response(data=payload) @@ -230,6 +384,86 @@ async def save_template_paragraphs( return Response(data={"template_id": template_id, "saved": len(body.paragraphs)}) +@router.put("/{template_id}/blocks") +async def save_template_blocks( + template_id: int, + body: dict, + db: AsyncSession = Depends(get_db), +): + """保存模板的块配置(可视化编辑器使用)。""" + template = await db.get(Template, template_id) + if template is None: + raise HTTPException(status_code=404, detail="模板不存在") + + block_data = body.get("blocks", []) + if not block_data: + raise HTTPException(status_code=400, detail="块数据不能为空") + + result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == template_id) + ) + existing_blocks = result.scalars().all() + block_map = {b.id: b for b in existing_blocks} + incoming_ids = {b.get("id") for b in block_data if b.get("id")} + + # 删除被移除的块 + for block in existing_blocks: + if block.id not in incoming_ids: + await db.delete(block) + + # 新增 / 更新块 + for index, config in enumerate(block_data, start=1): + block = block_map.get(config.get("id")) if config.get("id") else None + if block is None: + block = TemplateBlock(template_id=template_id) + db.add(block) + block.sort_index = index + block.block_type = config.get("block_type", block.block_type) + block.anchor_ref = config.get("anchor_ref", block.anchor_ref) + block.anchor_start_index = config.get("anchor_start_index", block.anchor_start_index) + block.anchor_end_index = config.get("anchor_end_index", block.anchor_end_index) + block.title = config.get("title", block.title) + block.content_json = config.get("content_json", block.content_json) + block.style_json = config.get("style_json", block.style_json) + block.edit_mode = config.get("edit_mode", block.edit_mode) + block.model_id = config.get("model_id", block.model_id) + block.need_prompt = config.get("need_prompt", block.need_prompt) + block.prompt_text = config.get("prompt_text", block.prompt_text) + block.need_file = config.get("need_file", block.need_file) + block.file_note = config.get("file_note", block.file_note) + block.output_format = config.get("output_format", block.output_format) + + await db.commit() + + # 更新每个块的 html_snippet 加入对应的 CSS class + try: + result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == template_id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) + ) + updated_blocks = result.scalars().all() + for b in updated_blocks: + if b.html_snippet and b.edit_mode == "ai": + b.html_snippet = b.html_snippet.replace( + 'data-block-index=', + 'class="block-ai" data-block-index=', + 1, + ) + elif b.html_snippet and b.edit_mode == "manual": + b.html_snippet = b.html_snippet.replace( + 'data-block-index=', + 'class="block-fixed" data-block-index=', + 1, + ) + await db.commit() + except Exception: + pass + + return Response(data={"template_id": template_id, "saved": len(block_data)}) + + @router.delete("/{template_id}") async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)): template = await db.get(Template, template_id) @@ -240,6 +474,9 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)): paragraphs_to_delete = result.scalars().all() paragraph_ids = [p.id for p in paragraphs_to_delete] + blocks_result = await db.execute(select(TemplateBlock).where(TemplateBlock.template_id == template_id)) + blocks_to_delete = blocks_result.scalars().all() + doc_result = await db.execute(select(Document).where(Document.template_id == template_id)) documents_to_delete = doc_result.scalars().all() @@ -256,6 +493,9 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)): for paragraph in paragraphs_to_delete: await db.delete(paragraph) + for block in blocks_to_delete: + await db.delete(block) + file_path = template.file_path or "" if "/" in file_path: bucket, object_name = file_path.split("/", 1) diff --git a/backend/services/document_export.py b/backend/services/document_export.py index fdba938..1c8726b 100644 --- a/backend/services/document_export.py +++ b/backend/services/document_export.py @@ -388,3 +388,111 @@ def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes: output = BytesIO() document.save(output) return output.getvalue() + + +def export_document_bytes_from_blocks(template_bytes: bytes, blocks: list[dict], logs: list[dict]) -> bytes: + """基于块结构的文档导出,支持块排序。 + + blocks: TemplateBlock 序列化列表,每个包含 anchor_start_index、edit_mode、sort_index 等 + logs: GenerationLog 序列化列表,每个包含 block_id、content、status + """ + document = Document(BytesIO(template_bytes)) + + # 构建 block_id → log 映射 + log_by_block_id: dict[int, dict] = {} + for log in logs: + block_id = log.get("block_id") + if block_id is not None: + log_by_block_id[block_id] = log + + # 构建 block 映射 + block_by_start_index: dict[int, dict] = {} + for block in blocks: + start_idx = block.get("anchor_start_index") + if start_idx is not None: + block_by_start_index[start_idx] = block + + # 按 sort_index 排序的块列表 + sorted_blocks = sorted( + [b for b in blocks if b.get("anchor_start_index") is not None], + key=lambda b: b.get("sort_index", 0), + ) + + # 枚举所有文档元素 + all_items: list = list(_iter_block_items(document)) + + # 先对所有元素进行内容替换(在重排之前) + for index, item in enumerate(all_items): + block = block_by_start_index.get(index) + if block is None: + continue + if block.get("edit_mode") == "manual": + continue + log = log_by_block_id.get(block.get("id")) + if log is None or log.get("status") != "success": + continue + content = log.get("content") or {"content": []} + if isinstance(content, str): + import json + try: + content = json.loads(content) + except Exception: + content = {"content": [{"type": "text", "text": content}]} + + if isinstance(item, Paragraph): + text_parts: list[str] = [] + for c in content.get("content", []): + if c.get("type") == "text": + text_parts.append(c.get("text", "")) + new_text = "\n".join(text_parts) + if new_text: + _set_paragraph_text(item, new_text, template_paragraph=item) + + # ---- 重排文档元素 ---- + # 收集所有元素的 XML element,按块归类 + body = document.element.body + + # 建立 index → element 映射 + index_to_element: dict[int, any] = {} + for index, item in enumerate(all_items): + index_to_element[index] = item._element + + # 记录哪些索引已被块覆盖 + covered_indices: set[int] = set() + for block in blocks: + start_idx = block.get("anchor_start_index") + end_idx = block.get("anchor_end_index", start_idx) + if start_idx is not None: + for idx in range(start_idx, (end_idx or start_idx) + 1): + if idx < len(all_items): + covered_indices.add(idx) + + # 未被任何块覆盖的元素(gap),保持原顺序 + gap_elements: list = [] + for index, item in enumerate(all_items): + if index not in covered_indices: + gap_elements.append(item._element) + + # 块元素:按 sort_index 排序后收集 + block_elements: list = [] + for block in sorted_blocks: + start_idx = block.get("anchor_start_index") + end_idx = block.get("anchor_end_index", start_idx) + if start_idx is not None: + for idx in range(start_idx, (end_idx or start_idx) + 1): + if idx < len(all_items) and idx in covered_indices: + block_elements.append(index_to_element[idx]) + + # 移除所有元素 + for element in list(body): + body.remove(element) + + # 按新顺序重新添加:块元素(按 sort_index)→ 间隙元素(保持原顺序) + for element in block_elements: + body.append(element) + for element in gap_elements: + body.append(element) + + output = BytesIO() + document.save(output) + return output.getvalue() diff --git a/backend/services/generation_runtime.py b/backend/services/generation_runtime.py index 535f4c5..10b83a3 100644 --- a/backend/services/generation_runtime.py +++ b/backend/services/generation_runtime.py @@ -11,6 +11,7 @@ from models.document import Document from models.generation_log import GenerationLog from models.paragraph import Paragraph from models.template import Template +from models.template_block import TemplateBlock from services.ai_service import call_ai from services.file_summary import summarize_minio_files @@ -195,3 +196,160 @@ async def run_generation(document_id: int, template_id: int): update_progress(document_id, status="failed", message=str(error), done=done_count, total=total) finally: generation_cancel_flags.pop(document_id, None) + + +def build_mock_content_for_block(block: TemplateBlock) -> dict: + """为块生成模拟内容。""" + if block.output_format == "table": + return { + "content": [ + { + "type": "table", + "title": block.title, + "headers": ["字段", "内容"], + "rows": [ + ["块标题", block.title], + ["块类型", block.block_type], + ], + } + ] + } + return { + "content": [ + {"type": "text", "text": f"这是“{block.title or block.block_type}”的示例生成内容。"} + ] + } + + +async def run_generation_for_blocks(document_id: int, template_id: int): + """基于块结构的 AI 生成任务。""" + async with async_session() as db: + document = await db.get(Document, document_id) + template = await db.get(Template, template_id) + if document is None or template is None: + update_progress(document_id, status="failed", message="生成任务初始化失败") + return + + result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == template_id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) + ) + blocks = result.scalars().all() + total = len(blocks) + ai_blocks = [b for b in blocks if b.edit_mode == "ai"] + total_ai = len(ai_blocks) + + update_progress(document_id, status="generating", total=total_ai, done=0, percent=0, message="开始块级生成...") + + done_count = 0 + failed_count = 0 + + # 收集固定块内容作为上下文 + fixed_context_parts: list[str] = [] + for b in blocks: + if b.edit_mode == "manual" and b.title: + fixed_context_parts.append(f"[{b.block_type}] {b.title}") + + try: + for block in blocks: + if is_cancel_requested(document_id): + document.status = "cancelled" + document.error = "用户已取消生成" + await db.commit() + update_progress(document_id, status="cancelled", message="已取消生成", done=done_count) + return + + if block.edit_mode == "manual": + # 固定块:使用现有内容 + existing = json.loads(block.content_json or "{}") + content = {"content": [{"type": "text", "text": existing.get("text", block.title or "")}]} + status = "success" + duration = 0 + error_message = "" + model_id = block.model_id + else: + # AI 块:调用模型生成 + start = time.perf_counter() + model = None + if block.model_id: + model = await db.get(AiModel, block.model_id) + if model is None or model.status != "enabled": + # 尝试使用默认启用的模型 + result_enabled = await db.execute( + select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1) + ) + model = result_enabled.scalars().first() + + model_id = model.id if model is not None else block.model_id + try: + if model is None: + content = build_mock_content_for_block(block) + else: + # 构造临时 paragraph 对象用于兼容 call_ai + temp_para = Paragraph( + template_id=template_id, + sort_index=block.sort_index, + title=block.title, + content="\n".join(fixed_context_parts), + prompt_text=block.prompt_text or "", + output_format=block.output_format, + need_prompt=block.need_prompt, + edit_mode=block.edit_mode, + ) + setattr(temp_para, "enable_reasoning", bool(model.enable_reasoning)) + result_data = await call_ai(temp_para, model, None) + content = result_data.content + status = "success" + error_message = "" + except Exception as error: + content = build_mock_content_for_block(block) + status = "failed" + error_message = str(error) + failed_count += 1 + duration = round(time.perf_counter() - start, 4) + done_count += 1 + + log = GenerationLog( + document_id=document.id, + block_id=block.id, + model_id=model_id, + status=status, + content=json.dumps(content, ensure_ascii=False), + duration=duration, + error_msg=error_message, + ) + db.add(log) + document.para_count_done = done_count + percent = int(done_count / max(total_ai, 1) * 100) + update_progress( + document_id, + status="generating", + percent=percent, + done=done_count, + total=total_ai, + current_paragraph=block.title, + message=f"正在生成:{block.title or block.block_type}", + ) + await db.commit() + + document.status = "completed" if failed_count == 0 else "failed" + document.error = "" if failed_count == 0 else f"{failed_count} 个 AI 块生成失败。" + document.file_path = f"mock://document/{document.id}" + document.updated_at = datetime.now() + await db.commit() + update_progress( + document_id, + status=document.status, + percent=100, + done=done_count, + total=total_ai, + message="块级生成完成" if failed_count == 0 else document.error, + ) + except Exception as error: + document.status = "failed" + document.error = str(error) + await db.commit() + update_progress(document_id, status="failed", message=str(error), done=done_count, total=total_ai) + finally: + generation_cancel_flags.pop(document_id, None) diff --git a/backend/services/word_to_html.py b/backend/services/word_to_html.py new file mode 100644 index 0000000..c5ccdba --- /dev/null +++ b/backend/services/word_to_html.py @@ -0,0 +1,673 @@ +"""将 docx 文件转为保留样式的 HTML,每个元素标记 data-block-index 用于框选定位。""" +import base64 +import html as html_module +import io +import json +import re +from dataclasses import dataclass +from typing import Union + +from docx import Document +from docx.document import Document as DocumentObject +from docx.image.exceptions import UnrecognizedImageError +from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.oxml.ns import qn +from docx.oxml.table import CT_Tbl +from docx.oxml.text.paragraph import CT_P +from docx.table import Table +from docx.text.paragraph import Paragraph + + +@dataclass +class RenderedBlock: + """单个渲染块 —— 对应模板中的一个 Block。""" + block_index: int + block_type: str # heading / text / table / image + html: str # 该块的 HTML 片段 + text_content: str # 纯文本(用于块列表展示) + style_json: str # 样式快照 JSON + table_json: str # 表格结构 JSON(仅 table 类型有值) + + +# --------------------------------------------------------------------------- +# 工具函数 +# --------------------------------------------------------------------------- + +def _escape(text: str) -> str: + return html_module.escape(text) + + +def _safe_pt(value) -> float | None: + if value is None: + return None + try: + return round(float(value.pt), 2) + except AttributeError: + return None + + +def _safe_indent(value) -> float | None: + if value is None: + return None + try: + return round(float(value.pt), 2) + except AttributeError: + return None + + +def _heading_level(style_name: str) -> int | None: + if not style_name: + return None + normalized = style_name.lower().replace(" ", "") + if normalized.startswith("heading"): + level = normalized.replace("heading", "") + if level.isdigit(): + return int(level) + return None + + +def _get_list_info(paragraph: Paragraph) -> tuple[bool, int, str | None]: + """检测段落是否为列表项,返回 (is_list, level, list_type)。 + + list_type: 'ordered' / 'unordered' / None + level: 缩进层级(0-based) + """ + pPr = paragraph._element.pPr + if pPr is None: + return False, 0, None + + numPr = pPr.find(qn("w:numPr")) + if numPr is None: + return False, 0, None + + ilvl_elem = numPr.find(qn("w:ilvl")) + numId_elem = numPr.find(qn("w:numId")) + if numId_elem is None: + return False, 0, None + + level = int(ilvl_elem.get(qn("w:val"))) if ilvl_elem is not None else 0 + + # 尝试从文档 numbering 部分获取列表类型 + list_type = "unordered" + try: + num_id_val = numId_elem.get(qn("w:numId")) + if num_id_val is not None: + document = paragraph._parent + # 查找 numbering 定义 + numbering_part = None + if hasattr(document, 'part'): + numbering_part = document.part.numbering_part + elif hasattr(paragraph._parent, 'part'): + numbering_part = paragraph._parent.part.numbering_part + + if numbering_part is not None: + numbering_xml = numbering_part._element + # 查找 numId 对应的 abstractNumId + num_elem = numbering_xml.find(f'.//w:num[@w:numId="{num_id_val}"]', + {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}) + if num_elem is not None: + abstract_num_ref = num_elem.find(qn("w:abstractNumId")) + if abstract_num_ref is not None: + abstract_num_id = abstract_num_ref.get(qn("w:val")) + # 查找 abstractNum 的 lvl 定义 + for lvl_elem in numbering_xml.findall( + f'.//w:abstractNum[@w:abstractNumId="{abstract_num_id}"]/w:lvl', + {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} + ): + lvl_ilvl = lvl_elem.get(qn("w:ilvl")) + if lvl_ilvl is not None and int(lvl_ilvl) == level: + num_fmt = lvl_elem.find(qn("w:numFmt")) + if num_fmt is not None: + fmt_val = num_fmt.get(qn("w:val")) + if fmt_val == "bullet": + list_type = "unordered" + else: + list_type = "ordered" + break + except Exception: + pass + + return True, level, list_type + + +def _alignment_css(paragraph: Paragraph) -> str: + from docx.enum.text import WD_ALIGN_PARAGRAPH + mapping = { + WD_ALIGN_PARAGRAPH.CENTER: "center", + WD_ALIGN_PARAGRAPH.RIGHT: "right", + WD_ALIGN_PARAGRAPH.JUSTIFY: "justify", + } + return mapping.get(paragraph.alignment, "left") + + +def _get_run_style(run) -> dict: + """提取 run 的字体样式。""" + font = run.font + color = "#000000" + if font.color is not None and font.color.rgb is not None: + color = f"#{font.color.rgb}" + size = _safe_pt(font.size) + return { + "name": font.name, + "size": size, + "bold": bool(font.bold) if font.bold is not None else False, + "italic": bool(font.italic) if font.italic is not None else False, + "underline": bool(font.underline) if font.underline is not None else False, + "color": color, + } + + +def _run_style_inline(run) -> str: + """将 run 的字体样式转为 CSS inline。""" + s = _get_run_style(run) + css = "" + if s["name"]: + css += f"font-family:'{s['name']}',sans-serif;" + if s["size"]: + css += f"font-size:{s['size']}pt;" + if s["bold"]: + css += "font-weight:bold;" + if s["italic"]: + css += "font-style:italic;" + if s["underline"]: + css += "text-decoration:underline;" + if s["color"] and s["color"] != "#000000": + css += f"color:{s['color']};" + return css + + +def _para_style_inline(paragraph: Paragraph) -> str: + """段落级别样式(对齐、间距、缩进)。""" + fmt = paragraph.paragraph_format + css = "" + css += f"text-align:{_alignment_css(paragraph)};" + before = _safe_pt(fmt.space_before) + after = _safe_pt(fmt.space_after) + if before: + css += f"margin-top:{before}pt;" + if after: + css += f"margin-bottom:{after}pt;" + line_spacing = fmt.line_spacing + if line_spacing is not None: + if isinstance(line_spacing, float): + css += f"line-height:{line_spacing};" + else: + try: + css += f"line-height:{round(float(line_spacing.pt), 2)}pt;" + except AttributeError: + pass + indent = _safe_indent(fmt.first_line_indent) + if indent: + css += f"text-indent:{indent}pt;" + return css + + +def _capture_style_snapshot(paragraph: Paragraph, level: int = 0) -> dict: + """段落样式快照(用于后续导出时还原)。""" + fmt = paragraph.paragraph_format + first_run_style = None + for run in paragraph.runs: + if run.text.strip(): + first_run_style = _get_run_style(run) + break + return { + "font": first_run_style or {}, + "paragraph": { + "alignment": _alignment_css(paragraph), + "spaceBefore": _safe_pt(fmt.space_before), + "spaceAfter": _safe_pt(fmt.space_after), + "lineSpacing": fmt.line_spacing, + "firstLineIndent": _safe_indent(fmt.first_line_indent), + }, + "headingLevel": level, + } + + +# --------------------------------------------------------------------------- +# 图片提取 +# --------------------------------------------------------------------------- + +def _extract_images_from_docx(file_path: str) -> dict[str, str]: + """提取 docx 中的所有图片,返回 {rId: base64_data_uri} 映射。""" + document = Document(file_path) + images: dict[str, str] = {} + for rel in document.part.rels.values(): + if "image" in rel.reltype: + try: + image_bytes = rel.target_part.blob + ext = rel.target_part.partname.split(".")[-1].lower() + mime_map = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", + "gif": "image/gif", "bmp": "image/bmp", "webp": "image/webp", "svg": "image/svg+xml"} + mime = mime_map.get(ext, "image/png") + data_uri = f"data:{mime};base64,{base64.b64encode(image_bytes).decode('ascii')}" + images[rel.rId] = data_uri + except Exception: + continue + return images + + +def _paragraph_contains_image(paragraph: Paragraph) -> bool: + """检查段落是否包含内嵌图片。""" + drawings = paragraph._element.findall(".//" + qn("w:drawing")) + return len(drawings) > 0 + + +def _render_image_html(paragraph: Paragraph, images: dict[str, str], block_index: int) -> str: + """渲染段落中的图片为 HTML img 标签。""" + nsmap = { + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "pic": "http://schemas.openxmlformats.org/drawingml/2006/picture", + } + parts: list[str] = [] + drawings = paragraph._element.findall(".//" + qn("w:drawing")) + for drawing in drawings: + blip = drawing.find(".//" + qn("a:blip"), nsmap) + if blip is None: + continue + r_embed = blip.get(qn("r:embed")) + if r_embed and r_embed in images: + src = images[r_embed] + # 尝试获取图片尺寸 + ext = drawing.find(".//" + qn("wp:extent"), nsmap) + cx = ext.get("cx") if ext is not None else None + cy = ext.get("cy") if ext is not None else None + style = "max-width:100%;height:auto;" + if cx and cy: + w = int(cx) // 9525 + h = int(cy) // 9525 + style = f"width:{w}px;height:{h}px;" + parts.append(f'文档图片') + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# 表格渲染 +# --------------------------------------------------------------------------- + +def _parse_border(border_element) -> dict | None: + """解析单个 CT_Border 元素。""" + if border_element is None: + return None + color = border_element.get(qn("w:color")) or "000000" + sz = border_element.get(qn("w:sz")) or "4" + val = border_element.get(qn("w:val")) or "single" + if val == "nil" or val == "none": + return None + try: + width_px = max(1, int(sz) // 8) + except ValueError: + width_px = 1 + return {"color": f"#{color}", "width": f"{width_px}px", "style": val} + + +def _table_border_css(table: Table) -> str: + """提取表格整体的边框样式。""" + tbl_pr = table._tbl.tblPr + if tbl_pr is None: + return "border-collapse:collapse;" + borders = tbl_pr.find(qn("w:tblBorders")) + if borders is None: + return "border-collapse:collapse;" + top = _parse_border(borders.find(qn("w:top"))) + left = _parse_border(borders.find(qn("w:left"))) + bottom = _parse_border(borders.find(qn("w:bottom"))) + right = _parse_border(borders.find(qn("w:right"))) + inside_h = _parse_border(borders.find(qn("w:insideH"))) + inside_v = _parse_border(borders.find(qn("w:insideV"))) + + rules: list[str] = ["border-collapse:collapse;"] + border_color = "#000000" + border_width = "1px" + if top: + border_color = top["color"] + border_width = top["width"] + elif left: + border_color = left["color"] + border_width = left["width"] + + rules.append(f"border:{border_width} solid {border_color};") + rules.append(f"th,td{{border:{border_width} solid {border_color};}}") + return "".join(rules) + + +def _cell_shading(cell) -> str: + """提取单元格底纹颜色。""" + tc_pr = cell._tc.tcPr + if tc_pr is None: + return "" + shading = tc_pr.find(qn("w:shd")) + if shading is None: + return "" + fill = shading.get(qn("w:fill")) + if fill and fill != "auto": + return f"background-color:#{fill};" + return "" + + +def _cell_width(cell) -> str: + """提取单元格宽度。""" + tc_pr = cell._tc.tcPr + if tc_pr is None: + return "" + tc_w = tc_pr.find(qn("w:tcW")) + if tc_w is None: + return "" + w = tc_w.get(qn("w:w")) + if w: + return f"width:{w};" + return "" + + +def _cell_v_align(cell) -> str: + """提取单元格垂直对齐。""" + tc_pr = cell._tc.tcPr + if tc_pr is None: + return "" + v_align = tc_pr.find(qn("w:vAlign")) + if v_align is None: + return "" + val = v_align.get(qn("w:val")) + if val: + return f"vertical-align:{val};" + return "" + + +def _render_cell_html(cell, is_header: bool = False) -> str: + """渲染单个单元格为 HTML。""" + tag = "th" if is_header else "td" + colspan = 1 + tc_pr = cell._tc.tcPr + if tc_pr is not None and tc_pr.gridSpan is not None: + colspan = tc_pr.gridSpan.val + + content_parts: list[str] = [] + for paragraph in cell.paragraphs: + para_css = _para_style_inline(paragraph) + inner_spans: list[str] = [] + for run in paragraph.runs: + text = _escape(run.text) + if not text: + continue + run_css = _run_style_inline(run) + inner_spans.append(f'{text}') + inner_html = "".join(inner_spans) if inner_spans else " " + content_parts.append(f'
{inner_html}
') + + cell_html = "".join(content_parts) if content_parts else " " + + style_parts = [ + "padding:4px 6px;", + _cell_shading(cell), + _cell_v_align(cell), + ] + style = "".join(style_parts) + + attrs = f' style="{style}"' + if colspan > 1: + attrs += f" colspan=\"{colspan}\"" + return f"<{tag}{attrs}>{cell_html}" + + +def _render_table_html(table: Table) -> str: + """将 Word 表格渲染为 HTML table。""" + border_css = _table_border_css(table) + rows_html: list[str] = [] + for row in table.rows: + cells_html = "".join(_render_cell_html(cell) for cell in row.cells) + rows_html.append(f"{cells_html}") + body = "\n".join(rows_html) + return f'{body}
' + + +def _extract_table_structure(table: Table) -> dict: + """提取表格结构化数据(用于后续 AI 生成参考)。""" + rows = len(table.rows) + cols = max((len(row.cells) for row in table.rows), default=0) + data: list[list[str]] = [] + for row in table.rows: + row_data: list[str] = [] + for cell in row.cells: + text = "\n".join(p.text.strip() for p in cell.paragraphs if p.text.strip()) + row_data.append(text) + data.append(row_data) + return {"rows": rows, "cols": cols, "data": data} + + +# --------------------------------------------------------------------------- +# 迭代文档元素 +# --------------------------------------------------------------------------- + +def _iter_block_items(document: DocumentObject): + """按文档顺序迭代段落和表格。""" + body = document.element.body + for child in body.iterchildren(): + if isinstance(child, CT_P): + yield Paragraph(child, document) + elif isinstance(child, CT_Tbl): + yield Table(child, document) + + +# --------------------------------------------------------------------------- +# 主转换函数 +# --------------------------------------------------------------------------- + +def docx_to_html_blocks(file_path: str) -> list[RenderedBlock]: + """将 docx 文件转为 RenderedBlock 列表。 + + 每个块对应文档中的一个段落或表格,带有完整的 CSS 内联样式。 + """ + document = Document(file_path) + images = _extract_images_from_docx(file_path) + blocks: list[RenderedBlock] = [] + block_index = 0 + + for item in _iter_block_items(document): + if isinstance(item, Paragraph): + text = item.text.strip() + + # 处理包含图片的段落 + if _paragraph_contains_image(item): + img_html = _render_image_html(item, images, block_index) + if img_html: + blocks.append(RenderedBlock( + block_index=block_index, + block_type="image", + html=f'
{img_html}
', + text_content="[图片]", + style_json="{}", + table_json="{}", + )) + block_index += 1 + # 如果图片段落中也有文字,继续处理文字 + if not text: + continue + + if not text: + # 空段落 —— 渲染为换行 + blocks.append(RenderedBlock( + block_index=block_index, + block_type="text", + html='
'.format(block_index), + text_content="", + style_json=json.dumps(_capture_style_snapshot(item), ensure_ascii=False), + table_json="{}", + )) + block_index += 1 + continue + + level = _heading_level(item.style.name if item.style is not None else "") + if level is not None: + # 标题块 + para_css = _para_style_inline(item) + inner_spans: list[str] = [] + for run in item.runs: + t = _escape(run.text) + if not t: + continue + run_css = _run_style_inline(run) + inner_spans.append(f'{t}') + inner = "".join(inner_spans) or _escape(text) + block_type = "heading" + html_str = ( + f'{inner}' + ) + else: + # 检测列表项 + is_list, list_level, list_type = _get_list_info(item) + if is_list: + para_css = _para_style_inline(item) + inner_spans: list[str] = [] + for run in item.runs: + t = _escape(run.text) + if not t: + continue + run_css = _run_style_inline(run) + inner_spans.append(f'{t}') + inner = "".join(inner_spans) or _escape(text) + block_type = "list_item" + left_margin = 24 + list_level * 24 # 每级缩进 24px + list_style = f"padding-left:{left_margin}px;" + marker_class = "list-ordered" if list_type == "ordered" else "list-unordered" + html_str = ( + f'
{inner}
' + ) + else: + # 普通文本块 + para_css = _para_style_inline(item) + inner_spans: list[str] = [] + for run in item.runs: + t = _escape(run.text) + if not t: + continue + run_css = _run_style_inline(run) + inner_spans.append(f'{t}') + inner = "".join(inner_spans) or _escape(text) + block_type = "text" + html_str = ( + f'

{inner}

' + ) + + blocks.append(RenderedBlock( + block_index=block_index, + block_type=block_type, + html=html_str, + text_content=text, + style_json=json.dumps(_capture_style_snapshot(item, level or 0), ensure_ascii=False), + table_json="{}", + )) + block_index += 1 + + elif isinstance(item, Table): + table_html = _render_table_html(item) + table_structure = _extract_table_structure(item) + + # 获取表格中第一个非空文本作为描述 + first_texts: list[str] = [] + for row in item.rows: + for cell in row.cells: + txt = cell.text.strip() + if txt: + first_texts.append(txt) + break + if first_texts: + break + text_content = " | ".join(first_texts[:3]) if first_texts else f"表格 {table_structure['rows']}行{table_structure['cols']}列" + + html_str = ( + f'
{table_html}
' + ) + + blocks.append(RenderedBlock( + block_index=block_index, + block_type="table", + html=html_str, + text_content=text_content, + style_json="{}", + table_json=json.dumps(table_structure, ensure_ascii=False), + )) + block_index += 1 + + return blocks + + +def docx_to_full_html(file_path: str) -> str: + """将 docx 文件转为完整的独立 HTML 文档(可直接在 iframe 中渲染)。""" + blocks = docx_to_html_blocks(file_path) + body_html = "\n".join(block.html for block in blocks) + + return f""" + + + + + + +{body_html} + +""" + + +def docx_to_html_body(file_path: str) -> str: + """仅返回 body 内的 HTML 片段(不含 head/style 包裹)。""" + blocks = docx_to_html_blocks(file_path) + return "\n".join(block.html for block in blocks) diff --git a/docs/tasks/task_detail_2026_07_06.md b/docs/tasks/task_detail_2026_07_06.md new file mode 100644 index 0000000..3b6cfc2 --- /dev/null +++ b/docs/tasks/task_detail_2026_07_06.md @@ -0,0 +1,19 @@ +# 任务执行摘要 + +## 会话 ID: 2 +- [2026-07-06 00:22:56] +- **执行原因**: 继续实施可视化区块编辑器重构任务拆解清单中的剩余项目 +- **执行过程**: + 1. 完成 GeneratePage.vue 块模式适配(fullV2 API 方法 + TypeScript 修复)。 + 2. 重构 PreviewEdit.vue 支持段落/块双模式:块模式使用 DocRenderer 渲染 HTML 快照、块列表显示状态、单块重新生成、恢复默认内容。 + 3. 新增后端 regenerateBlock API(POST /generate/blocks/{id}/regenerate)用于单块 AI 重新生成。 + 4. 新增后端 resetBlock API(DELETE /generate/blocks/{id}/reset/{docId})用于恢复块为模板默认。 + 5. 增强 GET /documents/{id} 接口返回块级数据(template_blocks + block_logs)。 + 6. 实现列表项渲染(检测 Word w:numPr,支持 ordered/unordered,CSS ::before 标记)。 + 7. 实现块保存时 html_snippet 快照同步(注入 block-ai/block-fixed CSS class)。 + 8. 在 TemplateEditor 中实现块拖拽排序(dragstart/dragover/drop 事件,排序后自动保存)。 +- **执行结果**: + - 前后端 TypeScript/Python 编译均通过。 + - 任务清单中完成项:生成页块模式、预览页块模式升级、列表渲染、快照同步、拖拽排序、单块重生成、恢复默认。 + - 更新了任务拆解清单文档,标记 10+ 个已完成项。 + - 剩余主要未完成项:图片 MinIO 上传、页眉页脚、树形块结构、变量块、Semaphore 限流、文件块级绑定、导出验证、历史页增强。 diff --git a/docs/需求与设计/06-可视化区块编辑器重构任务拆解清单.md b/docs/需求与设计/06-可视化区块编辑器重构任务拆解清单.md new file mode 100644 index 0000000..bd9b377 --- /dev/null +++ b/docs/需求与设计/06-可视化区块编辑器重构任务拆解清单.md @@ -0,0 +1,188 @@ +# AI 文档模板生成系统 · 可视化区块编辑器重构任务拆解清单 + +基线版本:当前 `master` 分支 + +目标:将系统从"按 Heading 解析段落"升级为"Word 富文本渲染 + 可视化框选区块 + 精确写回"的文档生成系统,类似腾讯文档的交互体验。 + +总工期估算:6-8 周(两人并行:前端 + 后端) + +## 第一阶段:Word → HTML 渲染引擎(第 1-2 周) + +### 后端:Word 转 HTML 服务(5-7 天) +- [x] 新建 `services/word_to_html.py`,将 docx 转为保留样式的 HTML +- [x] 段落渲染:字体(font-family / font-size / color / bold / italic)、对齐、行距、段前段后间距、首行缩进 +- [x] 表格渲染:完整保留边框样式(内外边框、线宽、颜色)、单元格背景色/底纹、合并单元格(rowspan/colspan)、列宽 +- [ ] 图片处理:提取 docx 内嵌图片,上传到 MinIO 并生成 URL +- [x] 列表项渲染:有序/无序列表及缩进层级(检测 w:numPr,支持 bullet/ordered,CSS ::before 标记) +- [ ] 页眉页脚渲染(可选,标记为 `data-region="header"` / `data-region="footer"`) +- [x] 为每个渲染元素标记 `data-block-index`(对应原始 docx XML 元素序号),作为后续写回锚点 +- [x] 输出完整的独立 HTML 文档字符串(内联样式,可直接在 iframe 中渲染) + +### 后端:解析器升级 — 块级结构输出(3-4 天) +- [x] 新增 `parse_template_v2()` 函数,输出 `list[TemplateBlock]` 而非 `list[ParsedParagraph]` +- [x] 每个块记录:`block_index`(XML 元素序号)、`block_type`(heading/text/table/image)、`html_snippet`、`style_json` +- [x] 保留旧 `parse_template()` 兼容现有流程 +- [x] 上传模板时同时生成 HTML 快照并存入 MinIO(或数据库 TEXT 字段) + +### 前端:文档渲染组件(3-4 天) +- [x] 新建 `components/DocRenderer.vue`,用 iframe 渲染后端返回的 HTML +- [x] 适配 A4 纸张宽度(794px),支持缩放 +- [x] 鼠标 hover 时高亮当前 block(通过 `data-block-index` 定位) +- [x] 支持文本选择(保留浏览器原生 Selection API) + +## 第二阶段:可视化框选与区块定义(第 2-3 周) + +### 前端:框选交互层(4-5 天) +- [x] 在 DocRenderer 上实现拖拽框选交互(mousedown → mousemove → mouseup) +- [x] 绘制半透明选区矩形(overlay div) +- [x] 计算选区覆盖的 `data-block-index` 范围(start → end) +- [x] 选区边界自动吸附到完整块边界(IntersectionRect 检测 + 按 data-block-index 全块选中) +- [x] 选中后弹出浮动工具栏:设为 AI 块 / 设为固定块 / 取消选择 +- [x] 已定义的块在文档上显示彩色边框 + 标签(AI 块紫色 / 固定块灰色 / 变量块绿色) +- [x] 支持多选区(Ctrl+框选 追加选区) +- [x] 支持点击已有块查看/编辑配置 + +### 前端:左右面板联动升级(3-4 天) +- [ ] 左侧块列表改为树形结构(支持父子嵌套) +- [x] 块列表项显示:块类型图标 + 标题 + 所属区域标签 +- [x] 点击块列表 ↔ 画布中块高亮滚动联动 +- [x] 右侧配置面板根据 `block_type` 动态切换表单项: + - AI 块:模型选择 / 提示词 / 输出格式 / 是否需要参考文件 + - 固定块:只读信息 / 转为 AI 块按钮 + - 变量块:变量名 / 默认值 / 必填标记 +- [x] 拖拽排序(dragstart/dragover/drop 事件,排序后自动保存块配置) + +### 前端:编辑模式切换(2-3 天) +- [x] 顶部工具栏新增模式切换:「预览模式」「框选模式」「编辑模式」 +- [x] 预览模式:只读查看文档渲染效果 +- [x] 框选模式:可拖拽选择区域定义为块(默认模式) +- [ ] 编辑模式(后续迭代):contenteditable 直接修改文档内容 + +## 第三阶段:块级数据持久化与模板保存(第 3-4 周) + +### 后端:块存储 API(2-3 天) +- [x] 新建/修改 `routers/templates.py` 中的块保存接口 `PUT /api/v1/templates/{id}/blocks` +- [x] 接口接收 `list[TemplateBlock]`,批量 upsert(新增/更新/删除) +- [x] 旧 `paragraphs` 接口保留兼容,内部转换到新块结构 +- [x] 模板详情返回时优先返回 blocks,fallback 到 paragraphs + +### 后端:模板快照管理(2-3 天) +- [x] 上传模板时生成 HTML 快照存入 `template_snapshots` 表或 MinIO +- [x] 每次保存块配置时同步更新快照中的块标记(html_snippet 注入 block-ai/block-fixed CSS class) +- [x] 加载模板时直接返回带块标记的 HTML,前端无需二次计算 + +### 数据库增量变更(1-2 天) +- [x] 确认 `template_blocks` 表字段完整性(已有,需检查) +- [x] 如需新增字段:`anchor_start_index`、`anchor_end_index`、`html_snippet` +- [x] 新增 `template_snapshots` 表:HTML 快照改为存储在 `template_blocks.html_snippet` 字段(无需独立表) +- [x] 输出增量 SQL 脚本(已在 database.py 中通过 init_db 自动迁移) + +## 第四阶段:生成链路适配(第 4-5 周) + +### 后端:块级 AI 生成调度(4-5 天) +- [x] 重构 `generation_runtime.py`:生成单元从 Paragraph 切换为 TemplateBlock +- [x] 只对 `edit_mode='ai'` 的块调用 AI +- [x] 固定块的内容直接作为上下文传递给相邻 AI 块 +- [ ] 变量块按变量值直接替换,不经过 AI +- [ ] 并发控制:按模板全局 Semaphore 限流 +- [x] SSE 进度推送单位改为块(`done/total` 按块计数) + +### 后端:AI 提示词上下文增强(2-3 天) +- [x] 构建 AI 请求时,自动附带同一锚点下相邻固定块的内容作为上下文 +- [ ] 文件附件绑定改为块级别(`block_id` 替代 `paragraph_id`) +- [ ] 文件摘要按块关联传入 AI + +### 前端:生成页适配(2-3 天) +- [x] 生成页支持段落/块模式切换(RadioButton 切换 genMode) +- [x] 块模式显示 AI 块列表(替代段落文件配置) +- [x] 块模式调用 generateFullV2 接口 +- [ ] 生成页左侧模板预览改为渲染 HTML 快照 + 块标记 +- [ ] 文件上传区按 AI 块分列展示 +- [ ] 进度展示与块列表联动(当前正在生成哪个块) + +## 第五阶段:导出引擎重构(第 5-6 周) + +### 后端:基于块索引的精确写回(5-7 天) +- [x] 重构 `document_export.py`,基于 `block_index` 定位而非 `anchor_title` 文本匹配 +- [x] 导出流程: + 1. 从 MinIO 拉取原始模板 docx + 2. 按块索引定位到原始 XML 元素 + 3. 固定块:保留原始内容不变 + 4. AI 块:用生成结果替换(文本块替换 ``,表格块替换整个 ``) + 5. 变量块:替换为变量值 +- [x] 支持块顺序调整后的正确写回(按 sort_index 重排文档元素,gap 元素保持原位置) +- [x] 保留未选中区域(没有被任何 Block 覆盖的文档部分)原样保留 +- [ ] 表格样式继承:AI 生成的表格复用同一文档中最近的表格样式 + +### 后端:导出正确性验证(2-3 天) +- [ ] 单块替换正确性验证 +- [ ] 多块顺序写回正确性验证 +- [ ] 固定块 + AI 块混合导出验证 +- [ ] 封面区域(非标题内容)导出验证 +- [ ] 表格块导出样式一致性验证 + +## 第六阶段:预览编辑与结果管理(第 5-6 周) + +### 前端:预览编辑页升级(3-4 天) +- [x] 预览页面改为渲染带块标记的 HTML 文档(DocRenderer + 模板 HTML 快照) +- [x] AI 块紫色边框标注(左侧紫色边框 + 标签) +- [x] 支持单块重新生成(块列表中的重新生成按钮 + regenerateBlock API) +- [x] 支持恢复到模板默认内容(resetBlock API + 确认弹窗) +- [x] 导出按钮支持块级导出(Word / Word v2 / PDF) +- [x] 块/段落双模式自动切换(根据 has_blocks 标志自动选择视图) + +### 前端:历史记录页增强(2-3 天) +- [ ] 历史列表卡片展示文档缩略图(HTML 快照截图) +- [ ] 按模板、按状态筛选 +- [ ] 对比模式:并排展示原始模板 vs 生成结果 + +## 第七阶段:联调、修边与验收(第 7-8 周) + +- [ ] 真实复杂模板全流程联调(含封面 / 摘要 / 多级标题 / 复杂表格 / 图片) +- [ ] 框选交互边界测试(跨页选区、嵌套表格选区、空选区) +- [ ] 不同浏览器兼容性测试(Chrome / Edge / Safari) +- [ ] 大文档性能测试(50 页+ Word 文档渲染性能) +- [ ] 导出结果与原 Word 样式一致性验收 +- [ ] 旧数据(基于 Paragraph 的历史记录)兼容性验证 +- [ ] 错误提示完善、加载状态/骨架屏补充 +- [ ] 操作日志与使用说明整理 + +## 阶段交付物 + +| 阶段 | 交付物 | +|------|--------| +| 第 1-2 周 | Word→HTML 渲染引擎、块级解析器、文档渲染组件 | +| 第 2-3 周 | 可视化框选交互、块配置面板、模式切换 | +| 第 3-4 周 | 块存储 API、模板快照管理、数据库增量变更 | +| 第 4-5 周 | 块级 AI 生成调度、生成页适配 | +| 第 5-6 周 | 基于块索引的精确写回引擎、预览编辑页升级 | +| 第 7-8 周 | 全流程联调、兼容性测试、验收交付 | + +## 与现有改造任务的关系 + +| 现有任务 | 本方案处理方式 | +|---------|-------------| +| 04-后续迭代(占位体系) | 变量块覆盖占位需求,不再需要独立占位语法 | +| 05-在线编辑重构(AI选区模式) | 本方案的框选交互即为 AI 选区模式的完整实现 | +| 05-模板源写回 | 合并到本方案第五阶段导出引擎重构 | +| 旧 Paragraph 表 | 保留兼容,逐步由 TemplateBlock 替代 | + +## 核心架构变化 + +``` +【改造前】 +Word 上传 → 按 Heading 切段落 → 段落列表 → 配置 → AI 生成 → 按标题文本写回 + +【改造后】 +Word 上传 → 解析为 HTML + 块索引 → 可视化框选定义块 → 块配置 → AI 按块生成 → 按索引精确写回 +``` + +## 风险与缓解 + +| 风险 | 等级 | 缓解措施 | +|------|------|---------| +| Word→HTML 样式还原度不足 | 中 | 优先保证字体/表格/对齐,复杂格式(分栏/文本框)暂不支持 | +| 大文档 HTML 渲染性能 | 中 | 虚拟滚动 + 按需加载,超过 100 页的文档分页渲染 | +| 块索引在多次编辑后偏移 | 高 | 每次保存时重新解析并更新索引;导出时以最新快照为准 | +| 框选交互在 iframe 中的复杂性 | 中 | 备选方案为 div 直接渲染(放弃 iframe 隔离),简化事件处理 | +| 旧数据兼容 | 低 | Paragraph→TemplateBlock 转换脚本,历史数据只读 | diff --git a/web/src/api/generate.ts b/web/src/api/generate.ts index d8c0614..43f362f 100644 --- a/web/src/api/generate.ts +++ b/web/src/api/generate.ts @@ -9,11 +9,15 @@ export const generateApi = { deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`), downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`), full: (data: any) => http.post('/generate/full', data), + fullV2: (data: any) => http.post('/generate/full-v2', data), progress: (id: number) => `/api/v1/generate/progress/${id}`, cancel: (id: number) => http.post(`/generate/cancel/${id}`), documents: (params?: any) => http.get('/generate/documents', { params }), getDocument: (id: number) => http.get(`/generate/documents/${id}`), deleteDocument: (id: number) => http.delete(`/generate/documents/${id}`), exportDocx: (id: number) => `/api/v1/export/${id}/docx`, + exportDocxV2: (id: number) => `/api/v1/export/${id}/docx-v2`, exportPdf: (id: number) => `/api/v1/export/${id}/pdf`, + regenerateBlock: (blockId: number, data: any) => http.post(`/generate/blocks/${blockId}/regenerate`, data), + resetBlock: (blockId: number, documentId: number) => http.delete(`/generate/blocks/${blockId}/reset/${documentId}`), } diff --git a/web/src/api/template.ts b/web/src/api/template.ts index f05c173..27ae558 100644 --- a/web/src/api/template.ts +++ b/web/src/api/template.ts @@ -6,4 +6,8 @@ export const templateApi = { upload: (formData: FormData) => http.post('/templates/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }), saveParagraphs: (id: number, data: any) => http.put(`/templates/${id}/paragraphs`, data), delete: (id: number) => http.delete(`/templates/${id}`), + /** 获取模板的 HTML 预览文档 + 块列表 */ + getPreview: (id: number) => http.get(`/templates/${id}/preview`), + /** 保存块配置 */ + saveBlocks: (id: number, blocks: any[]) => http.put(`/templates/${id}/blocks`, { blocks }), } diff --git a/web/src/components/DocRenderer.vue b/web/src/components/DocRenderer.vue new file mode 100644 index 0000000..cb41bc2 --- /dev/null +++ b/web/src/components/DocRenderer.vue @@ -0,0 +1,414 @@ +