feat: 参考文件支持多文件上传 + 生成前检查

- ref_file_path 改为 Text 类型存储 JSON 数组
- parse_reference_files: 支持解析多个文件路径
- 测试端点: 接受多个 ref_files,多文件内容合并
- 新增 upload-ref 端点: 生成前先上传参考文件
- 生成任务: 检查 need_ref_file,多文件提取文本
- 前端: 文件选择器改为 multiple,测试/上传均支持多文件
- 生成前自动检测缺少参考文件的生成点并提示上传
This commit is contained in:
zwt13703 2026-07-06 21:02:19 +08:00
parent d46018d410
commit dd2af6be4a
6 changed files with 180 additions and 27 deletions

View File

@ -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 ###

View File

@ -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)}

View File

@ -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)

View File

@ -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}]"

View File

@ -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:

View File

@ -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<void>((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={
<Space size={4}>
{point.need_ref_file && (
<Button
type="text"
size="small"
icon={<PaperClipOutlined />}
onClick={() => uploadRefForPoints([point.id])}
/>
)}
<Button
type="text"
size="small"