From 401e8cf57bc8b06b62c2fbd5b8b3c6195df12996 Mon Sep 17 00:00:00 2001 From: zwt13703 Date: Thu, 2 Jul 2026 18:26:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=99=84=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1=E8=B7=9F?= =?UTF-8?q?=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 5 + backend/models/document.py | 1 + backend/routers/generate.py | 109 +++++- backend/schemas/schemas.py | 6 +- backend/services/file_summary.py | 28 +- backend/services/generation_runtime.py | 24 +- backend/services/minio_client.py | 4 + docs/tasks/task_detail_2026_07_02.md | 52 +++ init.sql | 1 + web/src/App.vue | 6 +- web/src/api/generate.ts | 3 + web/src/router/index.ts | 1 + web/src/types/index.ts | 12 + web/src/views/AttachmentHistoryPage.vue | 256 ++++++++++++++ web/src/views/GeneratePage.vue | 145 ++++---- web/src/views/PreviewEdit.vue | 423 +++++++++++++++++------- 16 files changed, 873 insertions(+), 203 deletions(-) create mode 100644 web/src/views/AttachmentHistoryPage.vue diff --git a/backend/database.py b/backend/database.py index 343eac1..3f01b4d 100644 --- a/backend/database.py +++ b/backend/database.py @@ -40,3 +40,8 @@ async def init_db(): await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning BOOLEAN DEFAULT 0")) else: await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning TINYINT(1) DEFAULT 0")) + document_columns = await conn.run_sync( + lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("documents")] + ) + if "request_payload_json" not in document_columns: + await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT")) diff --git a/backend/models/document.py b/backend/models/document.py index 5d83381..c4a522d 100644 --- a/backend/models/document.py +++ b/backend/models/document.py @@ -11,5 +11,6 @@ class Document(Base): status = Column(String(20), default="pending", comment="pending/generating/completed/failed/cancelled") file_path = Column(String(500), default="", comment="生成的文件路径") error = Column(Text, default="", comment="错误信息") + request_payload_json = Column(Text, nullable=True, comment="提交任务时的文件与段落配置快照") created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/routers/generate.py b/backend/routers/generate.py index ae1c5fe..58e680b 100644 --- a/backend/routers/generate.py +++ b/backend/routers/generate.py @@ -17,7 +17,7 @@ from models.generation_log import GenerationLog from models.paragraph import Paragraph from models.reference_file import ReferenceFile from models.template import Template -from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response +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 ( @@ -27,12 +27,18 @@ from services.generation_runtime import ( run_generation, update_progress, ) -from services.minio_client import upload_bytes +from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url router = APIRouter() def _serialize_document(document: Document) -> dict: + request_payload = {} + if document.request_payload_json: + try: + request_payload = json.loads(document.request_payload_json) + except Exception: + request_payload = {} return { "id": document.id, "template_id": document.template_id, @@ -42,6 +48,7 @@ def _serialize_document(document: Document) -> dict: "status": document.status, "file_path": document.file_path, "error": document.error, + "request_payload": request_payload, "created_at": document.created_at, "updated_at": document.updated_at, } @@ -54,9 +61,25 @@ def _serialize_reference_file(file: ReferenceFile) -> dict: "file_path": file.file_path, "file_size": file.file_size, "content_type": file.content_type, - "created_at": file.created_at, + "created_at": file.created_at.isoformat() if file.created_at else None, } + +async def _build_reference_name_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, str]: + if not file_paths: + return {} + result = await db.execute( + select(ReferenceFile.file_path, ReferenceFile.file_name).where(ReferenceFile.file_path.in_(file_paths)) + ) + return {file_path: file_name for file_path, file_name in result.all()} + + +async def _build_reference_records_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, ReferenceFile]: + if not file_paths: + return {} + result = await db.execute(select(ReferenceFile).where(ReferenceFile.file_path.in_(file_paths))) + return {item.file_path: item for item in result.scalars().all()} + @router.post("/test") async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)): paragraph = await db.get(Paragraph, body.paragraph_id) @@ -71,7 +94,12 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge elif paragraph.model_id: model = await db.get(AiModel, paragraph.model_id) - file_summaries = await asyncio.to_thread(summarize_minio_files, body.file_paths or []) if body.file_paths else [] + file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or []) + file_summaries = ( + await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping) + if body.file_paths + else [] + ) if model is None or model.status != "enabled": content = build_mock_content(paragraph) message = "当前未找到可用模型,返回本地模拟生成结果。" @@ -109,7 +137,12 @@ async def generate_test_stream(body: GenerateTestRequest, db: AsyncSession = Dep if not model.supports_streaming: raise HTTPException(status_code=400, detail="当前模型未开启流式传输") - file_summaries = await asyncio.to_thread(summarize_minio_files, body.file_paths or []) if body.file_paths else [] + file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or []) + file_summaries = ( + await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping) + if body.file_paths + else [] + ) setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning)) async def event_stream(): @@ -236,6 +269,41 @@ async def list_reference_files( return Response(data={"items": items, "total": total, "page": page, "page_size": page_size}) +@router.get("/reference-files/{file_id}/download") +async def download_reference_file(file_id: int, db: AsyncSession = Depends(get_db)): + record = await db.get(ReferenceFile, file_id) + if record is None: + raise HTTPException(status_code=404, detail="附件记录不存在") + bucket, object_name = split_bucket_path(record.file_path) + return Response(data={"url": get_presigned_url(bucket, object_name), "file_name": record.file_name}) + + +@router.put("/reference-files/{file_id}") +async def update_reference_file(file_id: int, body: ReferenceFileUpdate, db: AsyncSession = Depends(get_db)): + record = await db.get(ReferenceFile, file_id) + if record is None: + raise HTTPException(status_code=404, detail="附件记录不存在") + record.file_name = body.file_name.strip() + await db.commit() + await db.refresh(record) + return Response(data=_serialize_reference_file(record)) + + +@router.delete("/reference-files/{file_id}") +async def delete_reference_file(file_id: int, db: AsyncSession = Depends(get_db)): + record = await db.get(ReferenceFile, file_id) + if record is None: + raise HTTPException(status_code=404, detail="附件记录不存在") + try: + bucket, object_name = split_bucket_path(record.file_path) + await asyncio.to_thread(delete_object, bucket, object_name) + except Exception: + pass + await db.delete(record) + await db.commit() + return Response(data={"id": file_id}) + + @router.post("/full") async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(get_db)): template = await db.get(Template, body.template_id) @@ -251,6 +319,36 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge if not paragraphs: raise HTTPException(status_code=400, detail="模板下暂无可生成段落") + normalized_file_map: dict[str, list[str]] = {} + for key, values in (body.file_map or {}).items(): + if not isinstance(values, list): + continue + normalized_file_map[str(key)] = [item for item in values if item] + all_file_paths = [item for values in normalized_file_map.values() for item in values] + file_records = await _build_reference_records_mapping(db, all_file_paths) + paragraph_snapshot = [] + for paragraph in paragraphs: + selected_paths = normalized_file_map.get(str(paragraph.id), []) + paragraph_snapshot.append( + { + "paragraph_id": paragraph.id, + "title": paragraph.title, + "sort_index": paragraph.sort_index, + "need_file": bool(paragraph.need_file), + "file_note": paragraph.file_note or "", + "selected_files": [ + _serialize_reference_file(file_records[file_path]) + for file_path in selected_paths + if file_path in file_records + ], + } + ) + request_payload = { + "template_name": template.name, + "file_map": normalized_file_map, + "paragraphs": paragraph_snapshot, + } + document = Document( template_id=template.id, name=f"{template.name}-{datetime.now().strftime('%Y%m%d%H%M%S')}", @@ -259,6 +357,7 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge status="generating", file_path="", error="", + request_payload_json=json.dumps(request_payload, ensure_ascii=False), ) db.add(document) await db.flush() diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py index b52c007..64da231 100644 --- a/backend/schemas/schemas.py +++ b/backend/schemas/schemas.py @@ -87,7 +87,11 @@ class GenerateTestRequest(BaseModel): class GenerateFullRequest(BaseModel): template_id: int - file_map: dict[str, str] = {} # paragraph_id -> file_path + file_map: dict[str, list[str]] = {} # paragraph_id -> file_paths + + +class ReferenceFileUpdate(BaseModel): + file_name: str = Field(min_length=1, max_length=255) class DocumentOut(BaseModel): id: int diff --git a/backend/services/file_summary.py b/backend/services/file_summary.py index 4c6f1a1..487e4d8 100644 --- a/backend/services/file_summary.py +++ b/backend/services/file_summary.py @@ -1,6 +1,8 @@ import csv import io import json +import subprocess +import tempfile from pathlib import Path import pandas as pd @@ -38,7 +40,10 @@ def _summarize_csv(content: bytes) -> str: def _summarize_excel(content: bytes, suffix: str) -> str: excel_buffer = io.BytesIO(content) - sheet_map = pd.read_excel(excel_buffer, sheet_name=None) if suffix == ".xlsx" else pd.read_excel(excel_buffer, sheet_name=None, engine="xlrd") + if suffix in {".xlsx", ".xlsm"}: + sheet_map = pd.read_excel(excel_buffer, sheet_name=None) + else: + sheet_map = pd.read_excel(excel_buffer, sheet_name=None, engine="xlrd") parts: list[str] = [] for sheet_name, dataframe in list(sheet_map.items())[:5]: preview = dataframe.head(10).fillna("").astype(str) @@ -47,6 +52,21 @@ def _summarize_excel(content: bytes, suffix: str) -> str: return "\n".join(parts)[:5000] +def _summarize_doc(content: bytes) -> str: + with tempfile.NamedTemporaryFile(suffix=".doc") as temp_file: + temp_file.write(content) + temp_file.flush() + result = subprocess.run( + ["textutil", "-convert", "txt", "-stdout", temp_file.name], + capture_output=True, + check=False, + ) + if result.returncode != 0: + stderr = result.stderr.decode("utf-8", errors="ignore").strip() + return f"旧版 Word 文件解析失败:{stderr or 'textutil 无法提取正文'}" + return _decode_text(result.stdout)[:4000] + + def _summarize_pdf(content: bytes) -> str: if PdfReader is None: return "当前环境未安装 PDF 文本解析依赖,无法提取 PDF 正文。" @@ -68,18 +88,18 @@ def summarize_file_bytes(file_name: str, content: bytes) -> str: if suffix == ".docx": return _summarize_docx(content) if suffix == ".doc": - return "当前暂不支持直接解析 .doc 旧版 Word 文件正文,建议先另存为 .docx 后再上传。" + return _summarize_doc(content) if suffix == ".pdf": return _summarize_pdf(content) return f"暂不支持解析该文件内容:{file_name}" -def summarize_minio_files(file_paths: list[str]) -> list[dict]: +def summarize_minio_files(file_paths: list[str], file_name_mapping: dict[str, str] | None = None) -> list[dict]: summaries: list[dict] = [] for file_path in file_paths: bucket, object_name = split_bucket_path(file_path) content = download_object_bytes(bucket, object_name) - file_name = Path(object_name).name + file_name = (file_name_mapping or {}).get(file_path) or Path(object_name).name summaries.append( { "file_name": file_name, diff --git a/backend/services/generation_runtime.py b/backend/services/generation_runtime.py index ab2371d..535f4c5 100644 --- a/backend/services/generation_runtime.py +++ b/backend/services/generation_runtime.py @@ -12,6 +12,7 @@ from models.generation_log import GenerationLog from models.paragraph import Paragraph from models.template import Template from services.ai_service import call_ai +from services.file_summary import summarize_minio_files generation_progress: dict[int, dict] = {} generation_cancel_flags: dict[int, bool] = {} @@ -90,6 +91,13 @@ async def run_generation(document_id: int, template_id: int): ) paragraphs = result.scalars().all() total = len(paragraphs) + request_payload = {} + if document.request_payload_json: + try: + request_payload = json.loads(document.request_payload_json) + except Exception: + request_payload = {} + file_map = request_payload.get("file_map", {}) if isinstance(request_payload, dict) else {} update_progress(document_id, status="generating", total=total, done=0, percent=0, message="开始生成...") done_count = 0 @@ -114,11 +122,25 @@ async def run_generation(document_id: int, template_id: int): model = await get_effective_model(paragraph) model_id = model.id if model is not None else paragraph.model_id try: + selected_file_paths = file_map.get(str(paragraph.id), []) + file_summaries = [] + if selected_file_paths: + file_name_mapping = {} + for paragraph_item in request_payload.get("paragraphs", []): + for selected_file in paragraph_item.get("selected_files", []): + file_path = selected_file.get("file_path") + if file_path in selected_file_paths: + file_name_mapping[file_path] = selected_file.get("file_name") + file_summaries = await asyncio.to_thread( + summarize_minio_files, + selected_file_paths, + file_name_mapping, + ) if model is None: content = build_mock_content(paragraph) else: setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning)) - result_data = await call_ai(paragraph, model) + result_data = await call_ai(paragraph, model, file_summaries) content = result_data.content status = "success" error_message = "" diff --git a/backend/services/minio_client.py b/backend/services/minio_client.py index 8eb4a62..ba01e4c 100644 --- a/backend/services/minio_client.py +++ b/backend/services/minio_client.py @@ -68,3 +68,7 @@ def upload_bytes( len(content), content_type=content_type, ) + + +def delete_object(bucket: str, object_name: str): + minio_client.remove_object(bucket, object_name) diff --git a/docs/tasks/task_detail_2026_07_02.md b/docs/tasks/task_detail_2026_07_02.md index 345ade8..315fe32 100644 --- a/docs/tasks/task_detail_2026_07_02.md +++ b/docs/tasks/task_detail_2026_07_02.md @@ -156,3 +156,55 @@ 3. 优化上传接口错误提示,返回实际不支持的扩展名和当前支持列表。 4. 同步更新模板测试弹窗中的上传提示文案,并执行后端语法检查与前端类型检查。 - **执行结果**: 当前参考文件上传支持范围更完整,遇到不支持的文件类型时也会直接显示具体后缀和支持列表,便于快速判断问题。 + +## 会话 ID: local-20260702173843 +- [2026-07-02 17:38:43] +- **执行原因**: 用户要求先提交当前代码,并继续修复参考文件原名称丢失问题,同时核实 `.doc`、Excel、PDF 的内容提取能力。 +- **执行过程**: + 1. 提交当前阶段代码,提交信息为“完善模型测试与参考文件历史能力”,保持工作区清晰。 + 2. 修正参考文件摘要逻辑,优先从 `reference_files` 历史记录中读取原始文件名,不再把 MinIO 中的 UUID 文件名传给 AI。 + 3. 为 `.doc` 文件接入 macOS `textutil` 文本提取能力,补齐旧版 Word 文档正文解析。 + 4. 修正 Excel 提取分支,确保 `.xlsm` 与 `.xlsx` 走正确读取方式;保留现有 PDF 文本提取逻辑。 + 5. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前参考文件在提交给 AI 时会保留用户原始文件名;`.doc`、`.docx`、Excel、PDF 等常见文件均可进入提取链路,其中 Excel 和 PDF 原本已支持,本轮补强了 `.doc` 与 `.xlsm`。 + +## 会话 ID: local-20260702175543 +- [2026-07-02 17:55:43] +- **执行原因**: 用户提出系统缺少单独的附件历史菜单,希望可以直接查看已经上传过的文件。 +- **执行过程**: + 1. 新增前端附件历史页面,展示参考文件总数、当前页数量、文件名、对象路径、类型、大小和上传时间。 + 2. 将页面接入现有 `reference-files` 接口,支持按文件名搜索和分页查看。 + 3. 在顶部导航中新增“附件历史”菜单入口,并补充路由配置。 + 4. 执行前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前系统已新增独立的“附件历史”菜单,用户可直接查看和检索已上传的参考文件记录。 + +## 会话 ID: local-20260702181456 +- [2026-07-02 18:14:56] +- **执行原因**: 用户希望附件历史支持下载、改名、查看与删除,同时要求生成页面支持多文件和文件说明,并让生成任务脱离当前页面、在记录页可持续查看状态与附件映射。 +- **执行过程**: + 1. 扩展参考附件接口,新增附件下载、改名、删除能力,并在附件历史页补齐对应操作按钮。 + 2. 为生成任务数据模型新增请求快照字段,在提交任务时持久化保存模板名、段落文件说明以及每个段落选中的附件列表。 + 3. 调整正式生成运行时,让每个段落在后台生成时真正读取该段落绑定的多文件内容,并把原始文件名传给 AI。 + 4. 重写执行生成页,展示每个段落的文件说明,支持多文件上传与移除,并将页面改为“提交任务后立即转到任务详情”模式,不再绑死在提交页。 + 5. 重写任务详情页,支持查看任务状态、实时进度、段落与附件映射、已完成段落内容,以及在运行中取消任务。 + 6. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前附件历史已支持基础管理;生成任务提交后会在后台继续执行,用户可离开提交页并在任务详情中持续查看状态、段落附件映射与生成结果。 + +## 会话 ID: local-20260702182202 +- [2026-07-02 18:22:02] +- **执行原因**: 用户反馈 MySQL 启动时报 `TEXT` 列不能设置默认值,需要修复 `request_payload_json` 字段的数据库兼容性。 +- **执行过程**: + 1. 调整 `documents.request_payload_json` 的模型定义,取消 `TEXT` 列默认值,改为允许空值并由代码层负责兜底。 + 2. 修正数据库初始化与自动补列逻辑,避免执行 `ALTER TABLE ... TEXT DEFAULT '{}'` 这一类 MySQL 非法 SQL。 + 3. 同步更新 `init.sql` 中 `documents` 表定义,去掉 `request_payload_json` 的默认值。 + 4. 执行后端语法检查,确认修复稳定。 +- **执行结果**: 当前 `request_payload_json` 字段已兼容 MySQL,应用启动时不会再因为 `TEXT DEFAULT` 语句失败。 + +## 会话 ID: local-20260702182417 +- [2026-07-02 18:24:17] +- **执行原因**: 用户反馈提交生成任务时报 `Object of type datetime is not JSON serializable`,需要修复任务快照里的附件时间字段序列化。 +- **执行过程**: + 1. 检查生成任务快照构建逻辑,确认报错来源于附件记录中的 `created_at` 为 `datetime` 对象。 + 2. 调整参考附件序列化方法,将 `created_at` 统一转为 ISO 字符串后再写入任务快照 JSON。 + 3. 执行后端语法检查,确认修复稳定。 +- **执行结果**: 当前生成任务提交时不会再因为附件记录中的 `datetime` 字段导致 JSON 序列化失败。 diff --git a/init.sql b/init.sql index 468a2fd..12e1a38 100644 --- a/init.sql +++ b/init.sql @@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS documents ( status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/generating/completed/failed/cancelled', file_path VARCHAR(500) DEFAULT '' COMMENT 'MinIO 对象路径', error TEXT DEFAULT '' COMMENT '错误信息', + request_payload_json TEXT COMMENT '提交任务时的文件与段落配置快照', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE diff --git a/web/src/App.vue b/web/src/App.vue index 19c9ec0..5799512 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -20,6 +20,10 @@ 执行生成 + + + 无需上传 @@ -85,7 +89,7 @@ @@ -400,7 +365,32 @@ async function cancelGen() { .para-info { display: flex; - align-items: center; + align-items: flex-start; + gap: 8px; +} + +.para-main { + display: flex; + flex-direction: column; + gap: 4px; +} + +.file-note { + font-size: 12px; + color: #6b7280; +} + +.file-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.uploaded-list { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; gap: 8px; } @@ -419,7 +409,22 @@ async function cancelGen() { .uploaded-name { color: #1a8c4a; - margin-left: 8px; + border: 1px solid #cdebd8; + background: #eefbf2; + padding: 4px 8px; + border-radius: 999px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; +} + +.remove-file-btn { + border: none; + background: transparent; + color: #999; + cursor: pointer; + padding: 0; } .no-file-tag { diff --git a/web/src/views/PreviewEdit.vue b/web/src/views/PreviewEdit.vue index b9e68c8..e7fab52 100644 --- a/web/src/views/PreviewEdit.vue +++ b/web/src/views/PreviewEdit.vue @@ -1,70 +1,78 @@