From dd2af6be4aa87ff3db3582f1201710f09e1fafd0 Mon Sep 17 00:00:00 2001 From: zwt13703 Date: Mon, 6 Jul 2026 21:02:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=82=E8=80=83=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=20+=20=E7=94=9F=E6=88=90=E5=89=8D=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ref_file_path 改为 Text 类型存储 JSON 数组 - parse_reference_files: 支持解析多个文件路径 - 测试端点: 接受多个 ref_files,多文件内容合并 - 新增 upload-ref 端点: 生成前先上传参考文件 - 生成任务: 检查 need_ref_file,多文件提取文本 - 前端: 文件选择器改为 multiple,测试/上传均支持多文件 - 生成前自动检测缺少参考文件的生成点并提示上传 --- ...change_ref_file_path_to_text_for_multi_.py | 33 +++++++++ backend/app/api/tasks.py | 64 +++++++++++++---- backend/app/models/generation_point.py | 2 +- backend/app/services/ref_parser.py | 27 +++++++- backend/app/tasks/generate.py | 13 ++-- web/src/pages/TemplateEditor.tsx | 68 +++++++++++++++++-- 6 files changed, 180 insertions(+), 27 deletions(-) create mode 100644 backend/alembic/versions/9066ecf60786_change_ref_file_path_to_text_for_multi_.py diff --git a/backend/alembic/versions/9066ecf60786_change_ref_file_path_to_text_for_multi_.py b/backend/alembic/versions/9066ecf60786_change_ref_file_path_to_text_for_multi_.py new file mode 100644 index 0000000..02a6d31 --- /dev/null +++ b/backend/alembic/versions/9066ecf60786_change_ref_file_path_to_text_for_multi_.py @@ -0,0 +1,33 @@ +"""Change ref_file_path to Text for multi-file support + +Revision ID: 9066ecf60786 +Revises: b4b91d4466e4 +Create Date: 2026-07-06 21:01:41.552375 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '9066ecf60786' +down_revision: Union[str, None] = 'b4b91d4466e4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('generation_points', 'ref_file_path', + existing_type=sa.VARCHAR(length=500), + type_=sa.Text(), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('generation_points', 'ref_file_path', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=500), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index a485e6a..03a1dfa 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1,8 +1,10 @@ +import json import os import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from fastapi.responses import Response +from typing import List from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.database import get_db @@ -15,7 +17,7 @@ from app.schemas.generation_task import TaskResponse, GenerateResponse, SingleTe from app.services.document_processor import docx_to_pdf_bytes from app.services.file_storage import get_file_content, save_upload, REF_FILES_DIR from app.services.ai_adapter import call_ai_model -from app.services.ref_parser import parse_reference_file +from app.services.ref_parser import parse_reference_file, parse_reference_files from app.tasks.generate import generate_document router = APIRouter() @@ -124,7 +126,7 @@ async def cancel_task(task_id: str, db: AsyncSession = Depends(get_db)): @router.post("/generation-points/{point_id}/test", response_model=SingleTestResponse) async def test_single_point( point_id: str, - ref_file: UploadFile | None = File(None), + ref_files: List[UploadFile] = File(default=[]), db: AsyncSession = Depends(get_db), ): point_result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id)) @@ -132,14 +134,16 @@ async def test_single_point( if not point: raise HTTPException(status_code=404, detail="生成点不存在") - if point.need_ref_file and not point.ref_file_path and not ref_file: - raise HTTPException(status_code=400, detail="此生成点需要上传参考文件") + tmp_paths = [] + if point.need_ref_file: + if not ref_files and not point.ref_file_path: + raise HTTPException(status_code=400, detail="此生成点需要上传参考文件") - if ref_file and ref_file.filename: - validate_file_extension(ref_file.filename) - tmp_path = await save_upload(ref_file, REF_FILES_DIR) - else: - tmp_path = point.ref_file_path + for f in ref_files: + if f.filename: + validate_file_extension(f.filename) + path = await save_upload(f, REF_FILES_DIR) + tmp_paths.append(path) model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}} if point.model_id: @@ -153,12 +157,48 @@ async def test_single_point( "extra_params": model.extra_params, } - ref_content = None - if tmp_path: - ref_content = await parse_reference_file(tmp_path) + ref_content = "" + if tmp_paths: + ref_content = await parse_reference_files(json.dumps(tmp_paths)) + elif point.ref_file_path: + ref_content = await parse_reference_files(point.ref_file_path) try: ai_result = await call_ai_model(model_config, point.prompt, ref_content) return SingleTestResponse(result=ai_result) except Exception as e: raise HTTPException(status_code=500, detail=f"AI 调用失败: {str(e)}") + + +@router.post("/generation-points/{point_id}/upload-ref") +async def upload_ref_files( + point_id: str, + ref_files: List[UploadFile] = File(...), + db: AsyncSession = Depends(get_db), +): + point_result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id)) + point = point_result.scalar_one_or_none() + if not point: + raise HTTPException(status_code=404, detail="生成点不存在") + + paths = [] + for f in ref_files: + if f.filename: + validate_file_extension(f.filename) + path = await save_upload(f, REF_FILES_DIR) + paths.append(path) + + existing = [] + if point.ref_file_path: + try: + existing = json.loads(point.ref_file_path) + if not isinstance(existing, list): + existing = [point.ref_file_path] + except json.JSONDecodeError: + existing = [point.ref_file_path] if point.ref_file_path else [] + + all_paths = existing + paths + point.ref_file_path = json.dumps(all_paths) + await db.commit() + + return {"success": True, "count": len(all_paths)} diff --git a/backend/app/models/generation_point.py b/backend/app/models/generation_point.py index 58cef53..bbb7078 100644 --- a/backend/app/models/generation_point.py +++ b/backend/app/models/generation_point.py @@ -17,7 +17,7 @@ class GenerationPoint(Base, UUIDMixin, TimestampMixin): model_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("models.id", ondelete="SET NULL"), nullable=True ) - ref_file_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + ref_file_path: Mapped[str | None] = mapped_column(Text, nullable=True) need_ref_file: Mapped[bool] = mapped_column(default=False, nullable=False) remark: Mapped[str | None] = mapped_column(Text, nullable=True) order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/backend/app/services/ref_parser.py b/backend/app/services/ref_parser.py index 4488114..918eb5e 100644 --- a/backend/app/services/ref_parser.py +++ b/backend/app/services/ref_parser.py @@ -1,4 +1,5 @@ import os +import json from docx import Document from PyPDF2 import PdfReader from app.services.file_storage import get_file_content @@ -6,20 +7,40 @@ from app.services.file_storage import get_file_content async def parse_reference_file(file_path: str) -> str: ext = os.path.splitext(file_path)[1].lower() - content = await get_file_content(file_path) if ext == ".txt": return content.decode("utf-8", errors="ignore") - if ext == ".docx": from io import BytesIO doc = Document(BytesIO(content)) return "\n".join(p.text for p in doc.paragraphs if p.text.strip()) - if ext == ".pdf": from io import BytesIO reader = PdfReader(BytesIO(content)) return "\n".join(page.extract_text() or "" for page in reader.pages) raise ValueError(f"不支持的文件格式: {ext}") + + +async def parse_reference_files(ref_file_path: str | None) -> str: + if not ref_file_path: + return "" + + try: + paths = json.loads(ref_file_path) + if isinstance(paths, list): + texts = [] + for path in paths: + try: + texts.append(await parse_reference_file(path)) + except Exception: + texts.append(f"[无法解析文件: {path}]") + return "\n\n".join(texts) + except json.JSONDecodeError: + pass + + try: + return await parse_reference_file(ref_file_path) + except Exception: + return f"[无法解析文件: {ref_file_path}]" diff --git a/backend/app/tasks/generate.py b/backend/app/tasks/generate.py index 647a559..aa368a9 100644 --- a/backend/app/tasks/generate.py +++ b/backend/app/tasks/generate.py @@ -10,7 +10,7 @@ from app.models.generation_task import GenerationTask from app.models.generation_point import GenerationPoint from app.models.template import Template from app.services.ai_adapter import call_ai_model -from app.services.ref_parser import parse_reference_file +from app.services.ref_parser import parse_reference_files from app.services.file_storage import get_storage_dir, get_file_content, RESULTS_DIR @@ -150,12 +150,11 @@ async def _generate_document(task_id: str) -> None: if not selected_text: continue - ref_content = None - if point.ref_file_path: - try: - ref_content = await parse_reference_file(point.ref_file_path) - except Exception: - pass + ref_content = "" + if point.need_ref_file and point.ref_file_path: + ref_content = await parse_reference_files(point.ref_file_path) + elif point.ref_file_path: + ref_content = await parse_reference_files(point.ref_file_path) model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}} if point.model_id: diff --git a/web/src/pages/TemplateEditor.tsx b/web/src/pages/TemplateEditor.tsx index 8ac7e49..5b8fc9e 100644 --- a/web/src/pages/TemplateEditor.tsx +++ b/web/src/pages/TemplateEditor.tsx @@ -8,7 +8,7 @@ import { PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined, ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined, ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined, - MenuOutlined, + MenuOutlined, PaperClipOutlined, } from "@ant-design/icons"; import { Editor } from "@tinymce/tinymce-react"; import type { Editor as TinyMCEEditor } from "tinymce"; @@ -232,14 +232,15 @@ export default function TemplateEditor() { if (point.need_ref_file) { const fileInput = document.createElement("input"); fileInput.type = "file"; + fileInput.multiple = true; fileInput.accept = ".txt,.docx,.pdf"; fileInput.onchange = async (e: Event) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; + const files = (e.target as HTMLInputElement).files; + if (!files || files.length === 0) return; try { message.loading({ content: "测试中...", key: "test" }); const formData = new FormData(); - formData.append("ref_file", file); + Array.from(files).forEach((f) => formData.append("ref_files", f)); const { default: request } = await import("../api/request"); const { data } = await request.post<{ result: string }>( `/generation-points/${pointId}/test`, @@ -271,6 +272,22 @@ export default function TemplateEditor() { message.warning("请先添加至少一个生成点"); return; } + + const needsRef = points.filter((p) => p.need_ref_file && !p.ref_file_path); + if (needsRef.length > 0) { + Modal.confirm({ + title: "需要上传参考文件", + content: `有 ${needsRef.length} 个生成点需要参考文件但未上传,请先上传后再生成。`, + okText: "去上传", + cancelText: "取消", + onOk: () => { + // 逐个提示上传 + uploadRefForPoints(needsRef.map((p) => p.id)); + }, + }); + return; + } + setGenerating(true); try { const { task_id } = await taskApi.triggerGeneration(id); @@ -298,6 +315,41 @@ export default function TemplateEditor() { } }; + const uploadRefForPoints = async (pointIds: string[]) => { + for (const pointId of pointIds) { + const point = points.find((p) => p.id === pointId); + if (!point) continue; + + await new Promise((resolve) => { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.multiple = true; + fileInput.accept = ".txt,.docx,.pdf"; + fileInput.onchange = async (e: Event) => { + const files = (e.target as HTMLInputElement).files; + if (!files || files.length === 0) { resolve(); return; } + try { + message.loading({ content: `上传 ${point.prompt.slice(0, 20)}...`, key: "upload" }); + const formData = new FormData(); + Array.from(files).forEach((f) => formData.append("ref_files", f)); + const { default: request } = await import("../api/request"); + await request.post(`/generation-points/${pointId}/upload-ref`, formData); + message.success({ content: "上传成功", key: "upload" }); + // 刷新列表 + const updated = await pointApi.listGenerationPoints(id!); + setPoints(updated); + } catch { + message.error({ content: "上传失败", key: "upload" }); + } + resolve(); + }; + message.info(`请为「${point.prompt.slice(0, 30)}」上传参考文件`); + fileInput.click(); + }); + } + message.success("所有参考文件上传完成,请再次点击生成文档"); + }; + const handleDownload = (format: "docx" | "pdf" = "docx") => { if (taskStatus?.id) { window.open(taskApi.getTaskDownloadUrl(taskStatus.id, format)); @@ -440,6 +492,14 @@ export default function TemplateEditor() { } extra={ + {point.need_ref_file && ( +