diff --git a/README.md b/README.md index 3ce3331..30493e0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式 - 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容 - 执行整份文档生成:已支持按模型配置发起真实调用,异常时自动回退为模拟结果 +- 生成过程中支持 SSE 进度推送与取消生成 - 查看生成记录与预览页真实结果 - 导出 Word:基于原模板替换标题下内容并生成可下载文件 diff --git a/backend/routers/generate.py b/backend/routers/generate.py index a0a5ded..dc1a3b2 100644 --- a/backend/routers/generate.py +++ b/backend/routers/generate.py @@ -1,5 +1,4 @@ import json -import time import os import uuid from datetime import datetime @@ -7,6 +6,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sse_starlette.sse import EventSourceResponse from config import settings from database import get_db @@ -17,6 +17,13 @@ from models.paragraph import Paragraph from models.template import Template from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response from services.ai_service import call_ai +from services.generation_runtime import ( + build_mock_content, + generation_progress, + request_cancel, + run_generation, + update_progress, +) from services.minio_client import upload_bytes router = APIRouter() @@ -36,47 +43,6 @@ def _serialize_document(document: Document) -> dict: "updated_at": document.updated_at, } - -def _build_mock_content(paragraph: Paragraph) -> dict: - if paragraph.output_format == "table": - return { - "content": [ - { - "type": "table", - "title": paragraph.title, - "headers": ["字段", "内容"], - "rows": [ - ["段落标题", paragraph.title], - ["生成说明", paragraph.prompt_text or "根据模板内容生成"], - ], - } - ] - } - - blocks = [ - { - "type": "text", - "text": f"这是“{paragraph.title}”的示例生成内容,可用于前端联调与流程验证。" - } - ] - if paragraph.content: - blocks.append({"type": "text", "text": f"模板上下文:{paragraph.content[:200]}"}) - if paragraph.need_prompt and paragraph.prompt_text: - blocks.append({"type": "text", "text": f"预设提示词:{paragraph.prompt_text[:200]}"}) - return {"content": blocks} - - -async def _get_effective_model(db: AsyncSession, paragraph: Paragraph) -> AiModel | None: - if paragraph.model_id: - model = await db.get(AiModel, paragraph.model_id) - if model is not None and model.status == "enabled": - return model - result = await db.execute( - select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1) - ) - return result.scalars().first() - - @router.post("/test") async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)): paragraph = await db.get(Paragraph, body.paragraph_id) @@ -90,7 +56,7 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge model = await db.get(AiModel, paragraph.model_id) if model is None or model.status != "enabled": - content = _build_mock_content(paragraph) + content = build_mock_content(paragraph) message = "当前未找到可用模型,返回本地模拟生成结果。" else: result = await call_ai(paragraph, model) @@ -162,54 +128,32 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge ) db.add(document) await db.flush() - - done_count = 0 - failed_count = 0 - for paragraph in paragraphs: - if paragraph.edit_mode == "manual": - content = {"content": [{"type": "text", "text": paragraph.content or "该段落为人工编辑模式。"}]} - status = "success" - duration = 0 - error_message = "" - else: - start = time.perf_counter() - model = await _get_effective_model(db, paragraph) - try: - if model is None: - content = _build_mock_content(paragraph) - else: - result = await call_ai(paragraph, model) - content = result.content - status = "success" - error_message = "" - except Exception as error: - content = _build_mock_content(paragraph) - status = "failed" - error_message = str(error) - failed_count += 1 - duration = round(time.perf_counter() - start, 4) - - log = GenerationLog( - document_id=document.id, - paragraph_id=paragraph.id, - model_id=paragraph.model_id, - status=status, - content=json.dumps(content, ensure_ascii=False), - duration=duration, - error_msg=error_message, - ) - db.add(log) - done_count += 1 - - document.para_count_done = done_count - document.status = "completed" if failed_count == 0 else "failed" - document.error = "" if failed_count == 0 else f"{failed_count} 个段落生成失败,已回退为模拟结果。" - document.file_path = f"mock://document/{document.id}" await db.commit() await db.refresh(document) + update_progress(document.id, status="pending", percent=0, done=0, total=len(paragraphs), message="任务已创建") + asyncio.create_task(run_generation(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(): + while True: + state = generation_progress.get( + document_id, + {"status": "pending", "percent": 0, "message": "等待中", "done": 0, "total": 0}, + ) + yield { + "event": "progress", + "data": json.dumps(state, ensure_ascii=False), + } + if state.get("status") in {"completed", "failed", "cancelled"}: + break + await asyncio.sleep(1) + + return EventSourceResponse(event_generator()) + + @router.get("/documents") async def list_documents( page: int = Query(1, ge=1), @@ -263,9 +207,10 @@ async def cancel_document(document_id: int, db: AsyncSession = Depends(get_db)): if document is None: raise HTTPException(status_code=404, detail="生成记录不存在") - document.status = "cancelled" - await db.commit() - await db.refresh(document) + if document.status in {"completed", "failed", "cancelled"}: + return Response(data=_serialize_document(document)) + + request_cancel(document_id) return Response(data=_serialize_document(document)) diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py index 95ccbeb..5e612ac 100644 --- a/backend/services/ai_service.py +++ b/backend/services/ai_service.py @@ -2,6 +2,7 @@ import asyncio import json import re from dataclasses import dataclass +from urllib.parse import urlparse import httpx @@ -60,6 +61,10 @@ def _build_prompt(paragraph: Paragraph) -> tuple[str, str]: def _normalize_openai_endpoint(api_endpoint: str) -> str: endpoint = api_endpoint.rstrip("/") + parsed = urlparse(endpoint if "://" in endpoint else f"https://{endpoint}") + host = parsed.netloc or parsed.path.split("/")[0] + if host == "api.deepseek.com": + return "https://api.deepseek.com/chat/completions" if endpoint.endswith("/chat/completions"): return endpoint if endpoint.endswith("/v1"): @@ -88,7 +93,7 @@ async def _post_with_retry( response = await client.post(url, headers=headers, json=payload) if response.status_code in (429, 500, 502, 503, 504): raise httpx.HTTPStatusError( - f"上游模型响应异常: {response.status_code}", + f"上游模型响应异常: {response.status_code} - {response.text[:500]}", request=response.request, response=response, ) @@ -99,7 +104,10 @@ async def _post_with_retry( if attempt == settings.AI_MAX_RETRIES - 1: break await asyncio.sleep(2 ** attempt) - raise RuntimeError(f"模型调用失败:{last_error}") + error_message = str(last_error) + if isinstance(last_error, httpx.HTTPStatusError) and last_error.response is not None: + error_message = f"{error_message}\n响应内容: {last_error.response.text[:1000]}" + raise RuntimeError(f"模型调用失败:{error_message}") async def _call_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult: @@ -116,7 +124,7 @@ async def _call_openai_compatible(model: AiModel, system_prompt: str, user_promp "temperature": 0.3, } headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} - async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT) as client: + async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: response = await _post_with_retry(client, _normalize_openai_endpoint(model.api_endpoint), headers, payload) body = response.json() text = body["choices"][0]["message"]["content"] @@ -139,7 +147,7 @@ async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str) "anthropic-version": "2023-06-01", "content-type": "application/json", } - async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT) as client: + async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: response = await _post_with_retry(client, _normalize_anthropic_endpoint(model.api_endpoint), headers, payload) body = response.json() text = "" diff --git a/backend/services/generation_runtime.py b/backend/services/generation_runtime.py new file mode 100644 index 0000000..d1680e0 --- /dev/null +++ b/backend/services/generation_runtime.py @@ -0,0 +1,174 @@ +import asyncio +import json +import time +from datetime import datetime + +from sqlalchemy import select + +from database import async_session +from models.ai_model import AiModel +from models.document import Document +from models.generation_log import GenerationLog +from models.paragraph import Paragraph +from models.template import Template +from services.ai_service import call_ai + +generation_progress: dict[int, dict] = {} +generation_cancel_flags: dict[int, bool] = {} + + +def build_mock_content(paragraph: Paragraph) -> dict: + if paragraph.output_format == "table": + return { + "content": [ + { + "type": "table", + "title": paragraph.title, + "headers": ["字段", "内容"], + "rows": [ + ["段落标题", paragraph.title], + ["生成说明", paragraph.prompt_text or "根据模板内容生成"], + ], + } + ] + } + + blocks = [ + { + "type": "text", + "text": f"这是“{paragraph.title}”的示例生成内容,可用于前端联调与流程验证。" + } + ] + if paragraph.content: + blocks.append({"type": "text", "text": f"模板上下文:{paragraph.content[:200]}"}) + if paragraph.need_prompt and paragraph.prompt_text: + blocks.append({"type": "text", "text": f"预设提示词:{paragraph.prompt_text[:200]}"}) + return {"content": blocks} + + +async def get_effective_model(paragraph: Paragraph) -> AiModel | None: + async with async_session() as db: + if paragraph.model_id: + model = await db.get(AiModel, paragraph.model_id) + if model is not None and model.status == "enabled": + return model + result = await db.execute( + select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1) + ) + return result.scalars().first() + + +def update_progress(document_id: int, **kwargs): + state = generation_progress.setdefault( + document_id, + {"percent": 0, "status": "pending", "message": "等待中", "done": 0, "total": 0}, + ) + state.update(kwargs) + + +def request_cancel(document_id: int): + generation_cancel_flags[document_id] = True + update_progress(document_id, status="cancelling", message="正在取消...") + + +def is_cancel_requested(document_id: int) -> bool: + return generation_cancel_flags.get(document_id, False) + + +async def run_generation(document_id: int, template_id: int): + 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(Paragraph) + .where(Paragraph.template_id == template_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + paragraphs = result.scalars().all() + total = len(paragraphs) + update_progress(document_id, status="generating", total=total, done=0, percent=0, message="开始生成...") + + done_count = 0 + failed_count = 0 + try: + for index, paragraph in enumerate(paragraphs, start=1): + if is_cancel_requested(document_id): + document.status = "cancelled" + document.error = "用户已取消生成" + await db.commit() + update_progress(document_id, status="cancelled", percent=min(99, int(done_count / max(total, 1) * 100)), message="已取消生成", done=done_count) + return + + if paragraph.edit_mode == "manual": + content = {"content": [{"type": "text", "text": paragraph.content or "该段落为人工编辑模式。"}]} + status = "success" + duration = 0 + error_message = "" + model_id = paragraph.model_id + else: + start = time.perf_counter() + model = await get_effective_model(paragraph) + model_id = model.id if model is not None else paragraph.model_id + try: + if model is None: + content = build_mock_content(paragraph) + else: + result_data = await call_ai(paragraph, model) + content = result_data.content + status = "success" + error_message = "" + except Exception as error: + content = build_mock_content(paragraph) + status = "failed" + error_message = str(error) + failed_count += 1 + duration = round(time.perf_counter() - start, 4) + + log = GenerationLog( + document_id=document.id, + paragraph_id=paragraph.id, + model_id=model_id, + status=status, + content=json.dumps(content, ensure_ascii=False), + duration=duration, + error_msg=error_message, + ) + db.add(log) + done_count += 1 + document.para_count_done = done_count + percent = int(done_count / max(total, 1) * 100) + update_progress( + document_id, + status="generating", + percent=percent, + done=done_count, + total=total, + current_paragraph=paragraph.title, + message=f"正在生成:{paragraph.title}", + ) + await db.commit() + + document.status = "completed" if failed_count == 0 else "failed" + document.error = "" if failed_count == 0 else f"{failed_count} 个段落生成失败,已回退为模拟结果。" + 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, + 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) + finally: + generation_cancel_flags.pop(document_id, None) diff --git a/docs/tasks/task_detail_2026_07_02.md b/docs/tasks/task_detail_2026_07_02.md index 0cfcd7d..fb4536a 100644 --- a/docs/tasks/task_detail_2026_07_02.md +++ b/docs/tasks/task_detail_2026_07_02.md @@ -50,3 +50,47 @@ 6. 更新 README 与任务拆解清单,标记真实模型调用与文件上传相关能力的完成状态。 7. 再次执行后端语法检查与前端类型检查,确认本轮改动稳定。 - **执行结果**: 当前系统已支持真实模型调用、模型连接测试和参考文件上传,生成链路从纯模拟升级为“真实调用优先、失败自动回退”的可用形态。 + +## 会话 ID: local-20260702152050 +- [2026-07-02 15:20:50] +- **执行原因**: 用户反馈模板列表页缺少编辑/删除入口,且模型测试被本机 SOCKS 代理环境阻断。 +- **执行过程**: + 1. 重写模板管理列表页,补齐“编辑”和“删除”操作入口,并接入删除确认提示。 + 2. 保持模板上传后自动跳转到编辑页,同时支持从列表直接进入模板编辑页。 + 3. 调整 AI 调用服务的 `httpx.AsyncClient` 配置,关闭 `trust_env`,避免读取系统 SOCKS 代理环境变量。 + 4. 再次执行后端语法检查与前端类型检查,验证修复稳定。 +- **执行结果**: `/templates` 页面现已支持直接编辑和删除模板,模型连接测试不再依赖系统 SOCKS 代理配置。 + +## 会话 ID: local-20260702152537 +- [2026-07-02 15:25:37] +- **执行原因**: 按继续完善要求,补齐生成过程中的实时进度展示与取消能力。 +- **执行过程**: + 1. 新增生成运行时服务,维护任务进度状态、取消标记和后台生成逻辑。 + 2. 将整份文档生成改为“创建任务后后台执行”,避免接口阻塞等待。 + 3. 新增 SSE 进度路由,向前端持续推送生成百分比、当前段落和状态变化。 + 4. 将执行生成页接入 `EventSource`,显示真实进度,并补充“取消生成”按钮。 + 5. 更新 README 与任务拆解清单,标记 SSE、取消生成、生成结果入库等已完成项。 + 6. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前生成流程已支持后台执行、SSE 实时进度推送和取消生成,执行页的进度展示从假进度升级为真实任务状态。 + +## 会话 ID: local-20260702152846 +- [2026-07-02 15:28:46] +- **执行原因**: 用户反馈 DeepSeek 模型连接测试返回 400,需要修正接口兼容逻辑。 +- **执行过程**: + 1. 对照 DeepSeek 官方文档检查 OpenAI 兼容接口地址格式。 + 2. 调整 OpenAI 兼容端点拼接逻辑,对 `api.deepseek.com` 特判为 `/chat/completions`,避免误拼成 `/v1/chat/completions`。 + 3. 补充模型调用错误信息,失败时输出更多响应正文,便于区分模型名错误、余额不足或参数不合法。 + 4. 执行后端语法检查,确认修复稳定。 +- **执行结果**: DeepSeek OpenAI 兼容地址的拼接逻辑已修正,后续模型测试若仍失败,将返回更具体的上游响应内容便于排查。 + +## 会话 ID: local-20260702153354 +- [2026-07-02 15:33:54] +- **执行原因**: 用户指出页面没有参考原型稿,需要开始按 [原型v3-HTML](/Users/zhouwentao/Workspaces/Yangliu/doc-forge/docs/原型v3-HTML/) 对齐界面。 +- **执行过程**: + 1. 重新阅读模板管理、模型管理、执行生成三个原型页面,提取顶部导航、面包屑、卡片、按钮和双栏布局结构。 + 2. 重写全局 `App.vue`,将侧边栏导航改为更接近原型的顶部导航结构。 + 3. 重写模板管理页,将表格列表改为原型风格的卡片式模板列表,并保留编辑、删除、前去生成等真实操作。 + 4. 重写模型管理页,将页面调整为原型风格的模型卡片列表,同时保留连接测试、启用禁用和编辑能力。 + 5. 重写执行生成页,使其更接近原型中的左侧模板概览 + 右侧文件配置 + 左下状态区布局,并保留真实 SSE 进度与取消生成能力。 + 6. 执行前端类型检查,确认本轮页面重构稳定。 +- **执行结果**: 现有三大主页面已开始按原型稿收口,整体信息层级和布局结构明显向原型靠齐,同时保留了当前已完成的真实业务能力。 diff --git a/docs/需求与设计/03-任务拆解清单.md b/docs/需求与设计/03-任务拆解清单.md index be7f95f..e4af801 100644 --- a/docs/需求与设计/03-任务拆解清单.md +++ b/docs/需求与设计/03-任务拆解清单.md @@ -33,11 +33,11 @@ - [ ] 文件摘要生成(Excel 解析 + 数据统计) ### 文档生成器(3 天) -- [ ] 单段落生成流程 +- [x] 单段落生成流程 - [ ] 多段落并行生成编排(asyncio.gather) -- [ ] SSE 进度推送 -- [ ] 取消生成支持 -- [ ] 生成结果入库 +- [x] SSE 进度推送 +- [x] 取消生成支持 +- [x] 生成结果入库 ### Word 导出引擎(5-7 天) - [ ] 复制原模板文件作为骨架 @@ -50,7 +50,7 @@ ### 路由与 API(3 天) - [x] 模板 CRUD 路由 - [x] 模型 CRUD 路由 -- [ ] 生成相关路由(测试/全量/进度SSE/取消) +- [x] 生成相关路由(测试/全量/进度SSE/取消) - [ ] 导出路由(Word/PDF) - [x] 文件上传/管理 diff --git a/web/src/App.vue b/web/src/App.vue index 85cb723..19c9ec0 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,51 +1,172 @@ diff --git a/web/src/views/GeneratePage.vue b/web/src/views/GeneratePage.vue index 0c81f80..a2803ca 100644 --- a/web/src/views/GeneratePage.vue +++ b/web/src/views/GeneratePage.vue @@ -1,29 +1,442 @@ - + + - + + diff --git a/web/src/views/ModelManage.vue b/web/src/views/ModelManage.vue index 3530e02..3f17eb2 100644 --- a/web/src/views/ModelManage.vue +++ b/web/src/views/ModelManage.vue @@ -1,33 +1,40 @@