feat: 参考文件支持多文件上传 + 生成前检查
- ref_file_path 改为 Text 类型存储 JSON 数组 - parse_reference_files: 支持解析多个文件路径 - 测试端点: 接受多个 ref_files,多文件内容合并 - 新增 upload-ref 端点: 生成前先上传参考文件 - 生成任务: 检查 need_ref_file,多文件提取文本 - 前端: 文件选择器改为 multiple,测试/上传均支持多文件 - 生成前自动检测缺少参考文件的生成点并提示上传
This commit is contained in:
parent
d46018d410
commit
dd2af6be4a
|
|
@ -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 ###
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
from typing import List
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.core.database import get_db
|
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.document_processor import docx_to_pdf_bytes
|
||||||
from app.services.file_storage import get_file_content, save_upload, REF_FILES_DIR
|
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.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
|
from app.tasks.generate import generate_document
|
||||||
|
|
||||||
router = APIRouter()
|
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)
|
@router.post("/generation-points/{point_id}/test", response_model=SingleTestResponse)
|
||||||
async def test_single_point(
|
async def test_single_point(
|
||||||
point_id: str,
|
point_id: str,
|
||||||
ref_file: UploadFile | None = File(None),
|
ref_files: List[UploadFile] = File(default=[]),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
point_result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
point_result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
||||||
|
|
@ -132,14 +134,16 @@ async def test_single_point(
|
||||||
if not point:
|
if not point:
|
||||||
raise HTTPException(status_code=404, detail="生成点不存在")
|
raise HTTPException(status_code=404, detail="生成点不存在")
|
||||||
|
|
||||||
if point.need_ref_file and not point.ref_file_path and not ref_file:
|
tmp_paths = []
|
||||||
|
if point.need_ref_file:
|
||||||
|
if not ref_files and not point.ref_file_path:
|
||||||
raise HTTPException(status_code=400, detail="此生成点需要上传参考文件")
|
raise HTTPException(status_code=400, detail="此生成点需要上传参考文件")
|
||||||
|
|
||||||
if ref_file and ref_file.filename:
|
for f in ref_files:
|
||||||
validate_file_extension(ref_file.filename)
|
if f.filename:
|
||||||
tmp_path = await save_upload(ref_file, REF_FILES_DIR)
|
validate_file_extension(f.filename)
|
||||||
else:
|
path = await save_upload(f, REF_FILES_DIR)
|
||||||
tmp_path = point.ref_file_path
|
tmp_paths.append(path)
|
||||||
|
|
||||||
model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}}
|
model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}}
|
||||||
if point.model_id:
|
if point.model_id:
|
||||||
|
|
@ -153,12 +157,48 @@ async def test_single_point(
|
||||||
"extra_params": model.extra_params,
|
"extra_params": model.extra_params,
|
||||||
}
|
}
|
||||||
|
|
||||||
ref_content = None
|
ref_content = ""
|
||||||
if tmp_path:
|
if tmp_paths:
|
||||||
ref_content = await parse_reference_file(tmp_path)
|
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:
|
try:
|
||||||
ai_result = await call_ai_model(model_config, point.prompt, ref_content)
|
ai_result = await call_ai_model(model_config, point.prompt, ref_content)
|
||||||
return SingleTestResponse(result=ai_result)
|
return SingleTestResponse(result=ai_result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"AI 调用失败: {str(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)}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class GenerationPoint(Base, UUIDMixin, TimestampMixin):
|
||||||
model_id: Mapped[uuid.UUID | None] = mapped_column(
|
model_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("models.id", ondelete="SET NULL"), nullable=True
|
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)
|
need_ref_file: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from PyPDF2 import PdfReader
|
from PyPDF2 import PdfReader
|
||||||
from app.services.file_storage import get_file_content
|
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:
|
async def parse_reference_file(file_path: str) -> str:
|
||||||
ext = os.path.splitext(file_path)[1].lower()
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
|
||||||
content = await get_file_content(file_path)
|
content = await get_file_content(file_path)
|
||||||
|
|
||||||
if ext == ".txt":
|
if ext == ".txt":
|
||||||
return content.decode("utf-8", errors="ignore")
|
return content.decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
if ext == ".docx":
|
if ext == ".docx":
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
doc = Document(BytesIO(content))
|
doc = Document(BytesIO(content))
|
||||||
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
||||||
|
|
||||||
if ext == ".pdf":
|
if ext == ".pdf":
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
reader = PdfReader(BytesIO(content))
|
reader = PdfReader(BytesIO(content))
|
||||||
return "\n".join(page.extract_text() or "" for page in reader.pages)
|
return "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||||
|
|
||||||
raise ValueError(f"不支持的文件格式: {ext}")
|
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}]"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from app.models.generation_task import GenerationTask
|
||||||
from app.models.generation_point import GenerationPoint
|
from app.models.generation_point import GenerationPoint
|
||||||
from app.models.template import Template
|
from app.models.template import Template
|
||||||
from app.services.ai_adapter import call_ai_model
|
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
|
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:
|
if not selected_text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ref_content = None
|
ref_content = ""
|
||||||
if point.ref_file_path:
|
if point.need_ref_file and point.ref_file_path:
|
||||||
try:
|
ref_content = await parse_reference_files(point.ref_file_path)
|
||||||
ref_content = await parse_reference_file(point.ref_file_path)
|
elif point.ref_file_path:
|
||||||
except Exception:
|
ref_content = await parse_reference_files(point.ref_file_path)
|
||||||
pass
|
|
||||||
|
|
||||||
model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}}
|
model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}}
|
||||||
if point.model_id:
|
if point.model_id:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
|
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
|
||||||
ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined,
|
ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined,
|
||||||
ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
||||||
MenuOutlined,
|
MenuOutlined, PaperClipOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { Editor } from "@tinymce/tinymce-react";
|
import { Editor } from "@tinymce/tinymce-react";
|
||||||
import type { Editor as TinyMCEEditor } from "tinymce";
|
import type { Editor as TinyMCEEditor } from "tinymce";
|
||||||
|
|
@ -232,14 +232,15 @@ export default function TemplateEditor() {
|
||||||
if (point.need_ref_file) {
|
if (point.need_ref_file) {
|
||||||
const fileInput = document.createElement("input");
|
const fileInput = document.createElement("input");
|
||||||
fileInput.type = "file";
|
fileInput.type = "file";
|
||||||
|
fileInput.multiple = true;
|
||||||
fileInput.accept = ".txt,.docx,.pdf";
|
fileInput.accept = ".txt,.docx,.pdf";
|
||||||
fileInput.onchange = async (e: Event) => {
|
fileInput.onchange = async (e: Event) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const files = (e.target as HTMLInputElement).files;
|
||||||
if (!file) return;
|
if (!files || files.length === 0) return;
|
||||||
try {
|
try {
|
||||||
message.loading({ content: "测试中...", key: "test" });
|
message.loading({ content: "测试中...", key: "test" });
|
||||||
const formData = new FormData();
|
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 { default: request } = await import("../api/request");
|
||||||
const { data } = await request.post<{ result: string }>(
|
const { data } = await request.post<{ result: string }>(
|
||||||
`/generation-points/${pointId}/test`,
|
`/generation-points/${pointId}/test`,
|
||||||
|
|
@ -271,6 +272,22 @@ export default function TemplateEditor() {
|
||||||
message.warning("请先添加至少一个生成点");
|
message.warning("请先添加至少一个生成点");
|
||||||
return;
|
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);
|
setGenerating(true);
|
||||||
try {
|
try {
|
||||||
const { task_id } = await taskApi.triggerGeneration(id);
|
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") => {
|
const handleDownload = (format: "docx" | "pdf" = "docx") => {
|
||||||
if (taskStatus?.id) {
|
if (taskStatus?.id) {
|
||||||
window.open(taskApi.getTaskDownloadUrl(taskStatus.id, format));
|
window.open(taskApi.getTaskDownloadUrl(taskStatus.id, format));
|
||||||
|
|
@ -440,6 +492,14 @@ export default function TemplateEditor() {
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
|
{point.need_ref_file && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<PaperClipOutlined />}
|
||||||
|
onClick={() => uploadRefForPoints([point.id])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue