diff --git a/backend/config.py b/backend/config.py index b111198..f2d8022 100644 --- a/backend/config.py +++ b/backend/config.py @@ -33,7 +33,7 @@ class Settings(BaseSettings): # 文件上传限制 MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024 # 50MB - ALLOWED_EXTENSIONS: list = [".docx", ".xlsx", ".xls", ".csv", ".pdf", ".txt", ".md"] + ALLOWED_EXTENSIONS: list = [".docx", ".doc", ".xlsx", ".xls", ".xlsm", ".csv", ".pdf", ".txt", ".md", ".json"] # 加密(用于 API Key 加密) ENCRYPTION_KEY: str = "change-this-to-a-32-byte-key-in-production!!" diff --git a/backend/database.py b/backend/database.py index 364d77c..343eac1 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,4 +1,5 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy import inspect, text from sqlalchemy.orm import DeclarativeBase from config import settings @@ -24,5 +25,18 @@ async def init_db(): from models.ai_model import AiModel from models.document import Document from models.generation_log import GenerationLog + from models.reference_file import ReferenceFile async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + dialect_name = conn.dialect.name + columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("ai_models")]) + if "supports_streaming" not in columns: + if dialect_name == "sqlite": + await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming BOOLEAN DEFAULT 0")) + else: + await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming TINYINT(1) DEFAULT 0")) + if "enable_reasoning" not in columns: + if dialect_name == "sqlite": + 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")) diff --git a/backend/models/ai_model.py b/backend/models/ai_model.py index bd8bf0d..fcf2136 100644 --- a/backend/models/ai_model.py +++ b/backend/models/ai_model.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Text, DateTime, func +from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, func from database import Base class AiModel(Base): @@ -9,6 +9,8 @@ class AiModel(Base): api_format = Column(String(20), default="openai", comment="anthropic/openai") api_endpoint = Column(String(500), default="", comment="API接口地址") api_key_encrypted = Column(Text, default="", comment="加密后的API Key") + supports_streaming = Column(Boolean, default=False, comment="是否支持流式传输") + enable_reasoning = Column(Boolean, default=False, comment="是否开启思考模式") status = Column(String(20), default="enabled", comment="enabled/disabled") created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/models/reference_file.py b/backend/models/reference_file.py new file mode 100644 index 0000000..f040494 --- /dev/null +++ b/backend/models/reference_file.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base + + +class ReferenceFile(Base): + __tablename__ = "reference_files" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + file_name: Mapped[str] = mapped_column(String(255), default="", comment="原始文件名") + file_path: Mapped[str] = mapped_column(String(500), default="", comment="MinIO 对象路径") + file_size: Mapped[int] = mapped_column(Integer, default=0, comment="文件大小") + content_type: Mapped[str] = mapped_column(String(120), default="", comment="文件类型") + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/routers/generate.py b/backend/routers/generate.py index 576a3b4..ae1c5fe 100644 --- a/backend/routers/generate.py +++ b/backend/routers/generate.py @@ -15,9 +15,10 @@ from models.ai_model import AiModel from models.document import Document 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 services.ai_service import call_ai +from services.ai_service import call_ai, stream_ai_preview from services.file_summary import summarize_minio_files from services.generation_runtime import ( build_mock_content, @@ -45,6 +46,17 @@ def _serialize_document(document: Document) -> dict: "updated_at": document.updated_at, } + +def _serialize_reference_file(file: ReferenceFile) -> dict: + return { + "id": file.id, + "file_name": file.file_name, + "file_path": file.file_path, + "file_size": file.file_size, + "content_type": file.content_type, + "created_at": file.created_at, + } + @router.post("/test") async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)): paragraph = await db.get(Paragraph, body.paragraph_id) @@ -64,6 +76,7 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge content = build_mock_content(paragraph) message = "当前未找到可用模型,返回本地模拟生成结果。" else: + setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning)) result = await call_ai(paragraph, model, file_summaries) content = result.content message = f"已通过模型 {result.used_model} 生成。" @@ -77,14 +90,101 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge ) +@router.post("/test-stream") +async def generate_test_stream(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)): + paragraph = await db.get(Paragraph, body.paragraph_id) + if paragraph is None or paragraph.template_id != body.template_id: + raise HTTPException(status_code=404, detail="段落不存在") + if body.prompt_text: + paragraph.prompt_text = body.prompt_text + + model = None + if body.model_id: + model = await db.get(AiModel, body.model_id) + elif paragraph.model_id: + model = await db.get(AiModel, paragraph.model_id) + + if model is None or model.status != "enabled": + raise HTTPException(status_code=400, detail="当前段落未配置可用的流式模型") + 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 [] + setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning)) + + async def event_stream(): + yield { + "event": "message", + "data": json.dumps( + { + "type": "meta", + "message": f"正在通过模型 {model.name} 流式生成...", + "file_summaries": file_summaries, + }, + ensure_ascii=False, + ), + } + try: + async for chunk in stream_ai_preview(paragraph, model, file_summaries): + yield { + "event": "message", + "data": json.dumps({"type": "delta", "content": chunk}, ensure_ascii=False), + } + yield { + "event": "message", + "data": json.dumps({"type": "done"}, ensure_ascii=False), + } + except Exception as error: + fallback_message = str(error) + if "503" in fallback_message or "temporarily unavailable" in fallback_message.lower(): + try: + result = await call_ai(paragraph, model, file_summaries) + yield { + "event": "message", + "data": json.dumps( + { + "type": "meta", + "message": "流式通道暂时不可用,已自动回退为普通返回。", + "file_summaries": file_summaries, + }, + ensure_ascii=False, + ), + } + yield { + "event": "message", + "data": json.dumps({"type": "delta", "content": result.raw_text}, ensure_ascii=False), + } + yield { + "event": "message", + "data": json.dumps({"type": "done"}, ensure_ascii=False), + } + return + except Exception as fallback_error: + fallback_message = f"{fallback_message};普通调用回退也失败:{fallback_error}" + yield { + "event": "message", + "data": json.dumps({"type": "error", "message": fallback_message}, ensure_ascii=False), + } + + return EventSourceResponse( + event_stream(), + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @router.post("/upload") -async def upload_reference_file(file: UploadFile = File(...)): +async def upload_reference_file(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): if not file.filename: raise HTTPException(status_code=400, detail="文件名不能为空") ext = os.path.splitext(file.filename)[1].lower() if ext not in settings.ALLOWED_EXTENSIONS: - raise HTTPException(status_code=400, detail="文件类型不支持") + allowed = " / ".join(settings.ALLOWED_EXTENSIONS) + raise HTTPException(status_code=400, detail=f"文件类型不支持:{ext or '无扩展名'}。当前支持:{allowed}") content = await file.read() if not content: @@ -100,12 +200,40 @@ async def upload_reference_file(file: UploadFile = File(...)): content, file.content_type or "application/octet-stream", ) - return Response( - data={ - "file_name": file.filename, - "file_path": f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}", - } + record = ReferenceFile( + file_name=file.filename, + file_path=f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}", + file_size=len(content), + content_type=file.content_type or "application/octet-stream", ) + db.add(record) + await db.commit() + await db.refresh(record) + return Response( + data=_serialize_reference_file(record) + ) + + +@router.get("/reference-files") +async def list_reference_files( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="按文件名搜索"), + db: AsyncSession = Depends(get_db), +): + stmt = select(ReferenceFile) + count_stmt = select(func.count(ReferenceFile.id)) + if keyword: + like_keyword = f"%{keyword.strip()}%" + stmt = stmt.where(ReferenceFile.file_name.like(like_keyword)) + count_stmt = count_stmt.where(ReferenceFile.file_name.like(like_keyword)) + + total = (await db.execute(count_stmt)).scalar_one() + result = await db.execute( + stmt.order_by(ReferenceFile.id.desc()).offset((page - 1) * page_size).limit(page_size) + ) + items = [_serialize_reference_file(item) for item in result.scalars().all()] + return Response(data={"items": items, "total": total, "page": page, "page_size": page_size}) @router.post("/full") diff --git a/backend/routers/models.py b/backend/routers/models.py index 768512a..3f39e21 100644 --- a/backend/routers/models.py +++ b/backend/routers/models.py @@ -20,6 +20,8 @@ def _serialize_model(model: AiModel) -> dict: "api_format": model.api_format, "api_endpoint": model.api_endpoint, "api_key_preview": mask_secret(api_key), + "supports_streaming": bool(model.supports_streaming), + "enable_reasoning": bool(model.enable_reasoning), "status": model.status, "created_at": model.created_at, } @@ -40,6 +42,8 @@ async def create_model(body: AiModelCreate, db: AsyncSession = Depends(get_db)): api_format=body.api_format, api_endpoint=body.api_endpoint, api_key_encrypted=encrypt_text(body.api_key), + supports_streaming=body.supports_streaming, + enable_reasoning=body.enable_reasoning, status=body.status, ) db.add(model) @@ -62,6 +66,10 @@ async def update_model(model_id: int, body: AiModelUpdate, db: AsyncSession = De model.api_format = body.api_format if body.api_endpoint is not None: model.api_endpoint = body.api_endpoint + if body.supports_streaming is not None: + model.supports_streaming = body.supports_streaming + if body.enable_reasoning is not None: + model.enable_reasoning = body.enable_reasoning if body.status is not None: model.status = body.status if body.api_key: @@ -95,6 +103,7 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)): need_prompt = False prompt_text = "" output_format = "text" + enable_reasoning = bool(model.enable_reasoning) try: result = await call_ai(FakeParagraph(), model) diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py index 8322d74..b52c007 100644 --- a/backend/schemas/schemas.py +++ b/backend/schemas/schemas.py @@ -50,6 +50,8 @@ class AiModelCreate(BaseModel): api_format: str = "openai" api_endpoint: str = "" api_key: str = "" + supports_streaming: bool = False + enable_reasoning: bool = False status: str = "enabled" @@ -59,6 +61,8 @@ class AiModelUpdate(BaseModel): api_format: Optional[str] = None api_endpoint: Optional[str] = None api_key: str = "" + supports_streaming: Optional[bool] = None + enable_reasoning: Optional[bool] = None status: Optional[str] = None class AiModelOut(BaseModel): @@ -68,6 +72,8 @@ class AiModelOut(BaseModel): api_format: str = "openai" api_endpoint: str = "" api_key_preview: str = "" + supports_streaming: bool = False + enable_reasoning: bool = False status: str = "enabled" created_at: Optional[datetime] = None diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py index 2487010..8172968 100644 --- a/backend/services/ai_service.py +++ b/backend/services/ai_service.py @@ -43,12 +43,23 @@ def _ensure_json_content(text: str) -> dict: return {"content": [{"type": "text", "text": stripped}]} +def _format_file_context(file_summaries: list[dict]) -> str: + file_blocks: list[str] = [] + for item in file_summaries: + file_name = item.get("file_name") or "未命名文件" + summary = item.get("summary") or "文件内容为空。" + file_blocks.append(f"文件:{file_name}\n内容:\n{summary}") + return "\n\n".join(file_blocks) + + def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]: system_prompt = ( "你是一个企业文档撰写助手。" "请严格输出 JSON,不要输出 JSON 之外的说明。" '格式为:{"content":[{"type":"text","text":"..."},{"type":"table","title":"...","headers":["..."],"rows":[["..."]]}]}。' ) + if getattr(paragraph, "enable_reasoning", False): + system_prompt += "你可以先进行充分思考,再给出最终答案,但最终只输出要求的结果内容。" user_parts = [f"段落标题:{paragraph.title}"] if paragraph.content: @@ -56,10 +67,7 @@ def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None if paragraph.need_prompt and paragraph.prompt_text: user_parts.append(f"附加要求:{paragraph.prompt_text}") if file_summaries: - file_blocks = [] - for item in file_summaries: - file_blocks.append(f"文件名:{item['file_name']}\n文件摘要:{item['summary']}") - user_parts.append("参考文件内容:\n" + "\n\n".join(file_blocks)) + user_parts.append("参考文件内容:\n" + _format_file_context(file_summaries)) user_parts.append(f"输出格式:{paragraph.output_format}") return system_prompt, "\n\n".join(user_parts) @@ -136,6 +144,65 @@ async def _call_openai_compatible(model: AiModel, system_prompt: str, user_promp return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name) +async def _stream_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str): + api_key = decrypt_text(model.api_key_encrypted) + if not api_key: + raise RuntimeError("模型 API Key 不可用") + + payload = { + "model": model.name, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.3, + "stream": True, + } + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + last_error: Exception | None = None + for attempt in range(settings.AI_MAX_RETRIES): + try: + async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: + async with client.stream("POST", _normalize_openai_endpoint(model.api_endpoint), headers=headers, json=payload) as response: + if response.status_code in (429, 500, 502, 503, 504): + body = await response.aread() + raise httpx.HTTPStatusError( + f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}", + request=response.request, + response=response, + ) + response.raise_for_status() + async for line in response.aiter_lines(): + if not line or not line.startswith("data:"): + continue + payload_line = line[5:].strip() + if payload_line == "[DONE]": + break + try: + chunk = json.loads(payload_line) + except json.JSONDecodeError: + continue + delta_payload = chunk.get("choices", [{}])[0].get("delta", {}) + delta = delta_payload.get("content", "") + reasoning = delta_payload.get("reasoning_content", "") + if isinstance(delta, list): + delta = "".join( + item.get("text", "") if isinstance(item, dict) else str(item) + for item in delta + ) + if delta: + yield delta + if reasoning: + yield reasoning + return + except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error: + last_error = error + if attempt == settings.AI_MAX_RETRIES - 1: + break + await asyncio.sleep(2 ** attempt) + raise RuntimeError(f"模型流式调用失败:{last_error}") + + async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult: api_key = decrypt_text(model.api_key_encrypted) if not api_key: @@ -162,8 +229,79 @@ async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str) return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name) +async def _stream_anthropic(model: AiModel, system_prompt: str, user_prompt: str): + api_key = decrypt_text(model.api_key_encrypted) + if not api_key: + raise RuntimeError("模型 API Key 不可用") + + payload = { + "model": model.name, + "max_tokens": 2048, + "system": system_prompt, + "messages": [{"role": "user", "content": user_prompt}], + "stream": True, + } + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + last_error: Exception | None = None + for attempt in range(settings.AI_MAX_RETRIES): + try: + async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: + async with client.stream("POST", _normalize_anthropic_endpoint(model.api_endpoint), headers=headers, json=payload) as response: + if response.status_code in (429, 500, 502, 503, 504): + body = await response.aread() + raise httpx.HTTPStatusError( + f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}", + request=response.request, + response=response, + ) + response.raise_for_status() + async for line in response.aiter_lines(): + if not line or not line.startswith("data:"): + continue + payload_line = line[5:].strip() + if payload_line == "[DONE]": + break + try: + chunk = json.loads(payload_line) + except json.JSONDecodeError: + continue + if chunk.get("type") == "content_block_delta": + delta = chunk.get("delta", {}).get("text", "") + if delta: + yield delta + return + except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error: + last_error = error + if attempt == settings.AI_MAX_RETRIES - 1: + break + await asyncio.sleep(2 ** attempt) + raise RuntimeError(f"模型流式调用失败:{last_error}") + + async def call_ai(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None) -> AiCallResult: system_prompt, user_prompt = _build_prompt(paragraph, file_summaries) if model.api_format == "anthropic": return await _call_anthropic(model, system_prompt, user_prompt) return await _call_openai_compatible(model, system_prompt, user_prompt) + + +def build_test_stream_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]: + system_prompt = "你是一个企业文档撰写助手。请直接输出适合预览的正文内容或 Markdown 表格,不要输出 JSON。" + if getattr(paragraph, "enable_reasoning", False): + system_prompt += "你可以先进行充分思考,再持续输出最终可展示的内容。" + _, user_prompt = _build_prompt(paragraph, file_summaries or []) + return system_prompt, user_prompt + + +async def stream_ai_preview(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None): + system_prompt, user_prompt = build_test_stream_prompt(paragraph, file_summaries) + if model.api_format == "anthropic": + async for chunk in _stream_anthropic(model, system_prompt, user_prompt): + yield chunk + return + async for chunk in _stream_openai_compatible(model, system_prompt, user_prompt): + yield chunk diff --git a/backend/services/file_summary.py b/backend/services/file_summary.py index 1086b87..4c6f1a1 100644 --- a/backend/services/file_summary.py +++ b/backend/services/file_summary.py @@ -63,10 +63,12 @@ def summarize_file_bytes(file_name: str, content: bytes) -> str: return _decode_text(content)[:4000] if suffix == ".csv": return _summarize_csv(content) - if suffix in {".xlsx", ".xls"}: + if suffix in {".xlsx", ".xls", ".xlsm"}: return _summarize_excel(content, suffix) if suffix == ".docx": return _summarize_docx(content) + if suffix == ".doc": + return "当前暂不支持直接解析 .doc 旧版 Word 文件正文,建议先另存为 .docx 后再上传。" if suffix == ".pdf": return _summarize_pdf(content) return f"暂不支持解析该文件内容:{file_name}" diff --git a/backend/services/generation_runtime.py b/backend/services/generation_runtime.py index d1680e0..ab2371d 100644 --- a/backend/services/generation_runtime.py +++ b/backend/services/generation_runtime.py @@ -117,6 +117,7 @@ async def run_generation(document_id: int, template_id: int): 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) content = result_data.content status = "success" diff --git a/docs/tasks/task_detail_2026_07_02.md b/docs/tasks/task_detail_2026_07_02.md index 00bc025..345ade8 100644 --- a/docs/tasks/task_detail_2026_07_02.md +++ b/docs/tasks/task_detail_2026_07_02.md @@ -106,3 +106,53 @@ 5. 重写模板编辑页,使其更接近原型的三栏编辑结构,并实现“立即测试”三步弹窗、多文件上传、文件摘要展示和测试结果预览。 6. 增补 PDF 与老式 Excel 解析依赖,并执行后端语法检查与前端类型检查。 - **执行结果**: 模板编辑页已更接近原型编辑态,“立即测试”现支持多文件上传、文件内容解析、带提示词调用 AI 模型并返回结果。 + +## 会话 ID: local-20260702163801 +- [2026-07-02 16:38:01] +- **执行原因**: 用户要求先提交代码后继续完善,并新增模型“是否支持流式传输”配置及模板测试流式返回能力。 +- **执行过程**: + 1. 先提交“模板测试弹窗与多文件解析链路”改动,保持工作区清晰。 + 2. 为模型表新增 `supports_streaming` 字段,并在数据库初始化时兼容旧表自动补列。 + 3. 更新模型创建、编辑、列表返回结构,以及模型管理页表单和展示,支持配置是否开启流式传输。 + 4. 扩展 AI 服务,新增 OpenAI / Anthropic 的流式输出能力。 + 5. 为段落测试新增流式接口;当模型开启流式能力时,模板编辑页测试弹窗在处理中阶段实时显示模型返回内容。 + 6. 执行后端语法检查与前端类型检查,确认本轮流式能力改动稳定。 +- **执行结果**: 当前系统已支持在模型配置中开启流式传输,并在模板编辑页对开启流式的模型进行实时测试返回。 + +## 会话 ID: local-20260702165813 +- [2026-07-02 16:58:13] +- **执行原因**: 用户希望在模型设置中增加“是否开启思考”能力,并确认是否能接入现有测试与生成流程。 +- **执行过程**: + 1. 为模型表、初始化 SQL、后端 Schema 和模型管理页补齐 `enable_reasoning` 字段,支持创建、编辑和展示思考模式开关。 + 2. 修正段落测试、流式测试、模型连接测试和整份文档生成链路,将模型上的思考模式显式传递给 AI 提示词构建逻辑。 + 3. 执行后端语法检查与前端类型检查,确认本轮改动稳定可用。 +- **执行结果**: 当前系统已支持在模型配置中开启或关闭思考模式,且会同时作用于模型连接测试、模板编辑页测试和正式生成流程。 + +## 会话 ID: local-20260702170209 +- [2026-07-02 17:02:09] +- **执行原因**: 用户希望在提交给 AI 的参考文件内容中显式带上文件名,便于模型理解每份内容对应的来源文件。 +- **执行过程**: + 1. 调整 AI 提示词中的参考文件拼接格式,将多文件上下文改为“文件:xxx”加“内容:...”的结构化文本。 + 2. 修正流式预览调用,确保测试流式场景也复用同一套系统提示词与文件上下文格式。 + 3. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前无论普通测试还是流式测试,AI 在接收参考文件时都能明确看到每个文件的文件名与对应内容摘要。 + +## 会话 ID: local-20260702170627 +- [2026-07-02 17:06:27] +- **执行原因**: 用户希望参考文件支持一次选择多个,并将已上传文件保存为系统历史,后续可直接复用或重新上传。 +- **执行过程**: + 1. 新增 `reference_files` 数据表与后端模型,在参考文件上传成功后持久化保存文件名、对象路径、大小、类型和创建时间。 + 2. 新增历史文件列表接口,支持按文件名搜索和分页读取已上传的参考文件记录。 + 3. 重写模板编辑页测试弹窗的文件区,保留多文件本地上传,同时新增历史文件库选择区,允许“历史文件 + 新上传文件”混合提交给 AI。 + 4. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前段落测试已支持多文件一起提交,且上传过的参考文件会进入系统历史库,后续可以直接勾选历史文件或重新上传新文件。 + +## 会话 ID: local-20260702171007 +- [2026-07-02 17:10:07] +- **执行原因**: 用户反馈上传参考文件时报“文件类型不支持”,需要补齐支持范围并提升报错可读性。 +- **执行过程**: + 1. 扩展后端参考文件白名单,补充 `.doc`、`.xlsm`、`.json` 等常见文件后缀。 + 2. 调整文件摘要服务,支持解析 `.xlsm`,并对 `.doc` 返回明确的转换建议说明。 + 3. 优化上传接口错误提示,返回实际不支持的扩展名和当前支持列表。 + 4. 同步更新模板测试弹窗中的上传提示文案,并执行后端语法检查与前端类型检查。 +- **执行结果**: 当前参考文件上传支持范围更完整,遇到不支持的文件类型时也会直接显示具体后缀和支持列表,便于快速判断问题。 diff --git a/init.sql b/init.sql index 840e0f9..468a2fd 100644 --- a/init.sql +++ b/init.sql @@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS ai_models ( api_format VARCHAR(20) DEFAULT 'openai' COMMENT 'anthropic/openai', api_endpoint VARCHAR(500) DEFAULT '' COMMENT 'API 地址', api_key_encrypted TEXT DEFAULT '' COMMENT '加密后的 API Key', + supports_streaming TINYINT(1) DEFAULT 0 COMMENT '是否支持流式传输', + enable_reasoning TINYINT(1) DEFAULT 0 COMMENT '是否开启思考模式', status VARCHAR(20) DEFAULT 'enabled' COMMENT 'enabled/disabled', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP @@ -74,3 +76,12 @@ CREATE TABLE IF NOT EXISTS generation_logs ( FOREIGN KEY (paragraph_id) REFERENCES paragraphs(id) ON DELETE CASCADE, FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS reference_files ( + id INT AUTO_INCREMENT PRIMARY KEY, + file_name VARCHAR(255) DEFAULT '' COMMENT '原始文件名', + file_path VARCHAR(500) DEFAULT '' COMMENT 'MinIO 对象路径', + file_size INT DEFAULT 0 COMMENT '文件大小', + content_type VARCHAR(120) DEFAULT '' COMMENT '文件类型', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/web/src/api/generate.ts b/web/src/api/generate.ts index 678e1d5..acaf261 100644 --- a/web/src/api/generate.ts +++ b/web/src/api/generate.ts @@ -2,7 +2,9 @@ import http from './index' export const generateApi = { test: (data: any) => http.post('/generate/test', data), + testStream: () => '/api/v1/generate/test-stream', upload: (formData: FormData) => http.post('/generate/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }), + referenceFiles: (params?: any) => http.get('/generate/reference-files', { params }), full: (data: any) => http.post('/generate/full', data), progress: (id: number) => `/api/v1/generate/progress/${id}`, cancel: (id: number) => http.post(`/generate/cancel/${id}`), diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 651c394..88384b2 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -13,7 +13,7 @@ export interface Paragraph { export interface AiModel { id: number; name: string; provider: string; api_format: 'anthropic' | 'openai' - api_endpoint: string; api_key_preview: string; status: 'enabled' | 'disabled' + api_endpoint: string; api_key_preview: string; supports_streaming: boolean; enable_reasoning: boolean; status: 'enabled' | 'disabled' } export interface Document { @@ -23,5 +23,9 @@ export interface Document { file_path: string; error: string } +export interface ReferenceFile { + id: number; file_name: string; file_path: string; file_size: number; content_type: string; created_at: string +} + export interface ApiResponse { code: number; data: T; message: string } export interface PageData { items: T[]; total: number; page: number; page_size: number } diff --git a/web/src/views/ModelManage.vue b/web/src/views/ModelManage.vue index 3f17eb2..762ce89 100644 --- a/web/src/views/ModelManage.vue +++ b/web/src/views/ModelManage.vue @@ -18,6 +18,8 @@
{{ item.name }}
{{ item.provider }} · {{ item.api_endpoint }}
密钥:{{ item.api_key_preview || '未设置' }}
+
流式传输:{{ item.supports_streaming ? '支持' : '关闭' }}
+
思考模式:{{ item.enable_reasoning ? '开启' : '关闭' }}
{{ item.status === 'enabled' ? '已启用' : '已禁用' }} @@ -48,6 +50,12 @@ + + + + + + @@ -66,7 +74,7 @@ const models = ref([]) const modalOpen = ref(false) const isEdit = ref(false) const editId = ref(0) -const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '' }) +const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }) async function refreshList() { await store.fetchList() @@ -79,7 +87,7 @@ onMounted(async () => { function openAdd() { isEdit.value = false - form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '' } + form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false } modalOpen.value = true } @@ -92,6 +100,8 @@ function openEdit(item: any) { api_format: item.api_format, api_endpoint: item.api_endpoint, api_key: '', + supports_streaming: !!item.supports_streaming, + enable_reasoning: !!item.enable_reasoning, } modalOpen.value = true } diff --git a/web/src/views/TemplateEditor.vue b/web/src/views/TemplateEditor.vue index c3c2c47..3e31555 100644 --- a/web/src/views/TemplateEditor.vue +++ b/web/src/views/TemplateEditor.vue @@ -140,7 +140,7 @@

点击或拖拽上传参考文件

-

支持多文件:docx / xlsx / xls / csv / pdf / txt / md

+

支持多文件:docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json

@@ -151,12 +151,62 @@ - 开始测试 +
+
+
+
历史文件
+
已上传过的文件会保存在系统里,下次可直接选择复用。
+
+ 刷新 +
+ + + + +
+ +
+ +
+
+ +
+
+ {{ item.file_name }} + 取消选择 +
+
+ + 开始测试(支持多文件)

{{ testStatusText }}

+
+
模型实时返回内容
+
{{ streamedText }}
+
@@ -194,6 +244,7 @@ import { import { useTemplateStore } from '@/stores/template' import { useModelStore } from '@/stores/model' import { generateApi } from '@/api/generate' +import type { ReferenceFile } from '@/types' interface LocalUploadFile { uid: string @@ -219,8 +270,16 @@ const testFiles = ref([]) const testResultHtml = ref('') const testResultMessage = ref('') const testFileSummaries = ref([]) +const streamedText = ref('') +const referenceHistory = ref([]) +const selectedHistoryPaths = ref([]) +const historyKeyword = ref('') +const historyLoading = ref(false) const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value)) +const selectedHistoryItems = computed(() => + referenceHistory.value.filter((item) => selectedHistoryPaths.value.includes(item.file_path)) +) function selectPara(id: number) { selectedId.value = id @@ -240,6 +299,8 @@ function openTestModal() { testResultHtml.value = '' testResultMessage.value = '' testFileSummaries.value = [] + selectedHistoryPaths.value = [] + fetchReferenceHistory() } function resetTestModal() { @@ -250,6 +311,8 @@ function resetTestModal() { testResultHtml.value = '' testResultMessage.value = '' testFileSummaries.value = [] + streamedText.value = '' + selectedHistoryPaths.value = [] } function beforeTestUpload(file: File) { @@ -265,6 +328,40 @@ function removeTestFile(uid: string) { testFiles.value = testFiles.value.filter((item) => item.uid !== uid) } +function toggleHistoryFile(filePath: string) { + if (selectedHistoryPaths.value.includes(filePath)) { + selectedHistoryPaths.value = selectedHistoryPaths.value.filter((item) => item !== filePath) + return + } + selectedHistoryPaths.value = [...selectedHistoryPaths.value, filePath] +} + +function formatFileSize(fileSize: number) { + if (fileSize < 1024) return `${fileSize} B` + if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB` + return `${(fileSize / (1024 * 1024)).toFixed(1)} MB` +} + +function formatDateTime(value: string) { + return value ? value.replace('T', ' ').slice(0, 19) : '' +} + +async function fetchReferenceHistory() { + historyLoading.value = true + try { + const response: any = await generateApi.referenceFiles({ + page: 1, + page_size: 30, + keyword: historyKeyword.value.trim(), + }) + referenceHistory.value = response.data?.items || [] + } catch (error: any) { + message.error(error.message || '加载历史文件失败') + } finally { + historyLoading.value = false + } +} + function renderTestResult(content: any) { const blocks = content?.content || [] return blocks @@ -291,7 +388,7 @@ function renderTestResult(content: any) { async function startTest() { if (!selectedPara.value) return - if (selectedPara.value.need_file && !testFiles.value.length) { + if (selectedPara.value.need_file && !testFiles.value.length && !selectedHistoryPaths.value.length) { message.warning('请先上传至少一个参考文件') return } @@ -308,21 +405,34 @@ async function startTest() { const uploadRes: any = await generateApi.upload(formData) filePaths.push(uploadRes.data.file_path) } + for (const filePath of selectedHistoryPaths.value) { + if (!filePaths.includes(filePath)) { + filePaths.push(filePath) + } + } + await fetchReferenceHistory() testStatusText.value = '正在解析文件内容并请求 AI 模型...' const templateId = Number(route.params.id) - const response: any = await generateApi.test({ - paragraph_id: selectedPara.value.id, - template_id: templateId, - prompt_text: selectedPara.value.prompt_text || '', - model_id: selectedPara.value.model_id || 0, - file_paths: filePaths, - }) + const currentModel = models.value.find((item) => item.id === selectedPara.value.model_id) + if (currentModel?.supports_streaming) { + testStatusText.value = '正在流式接收模型返回内容...' + streamedText.value = '' + await runStreamingTest(templateId, filePaths) + } else { + const response: any = await generateApi.test({ + paragraph_id: selectedPara.value.id, + template_id: templateId, + prompt_text: selectedPara.value.prompt_text || '', + model_id: selectedPara.value.model_id || 0, + file_paths: filePaths, + }) - testResultMessage.value = response.data?.message || '测试完成' - testFileSummaries.value = response.data?.file_summaries || [] - testResultHtml.value = renderTestResult(response.data?.content) - testStep.value = 2 + testResultMessage.value = response.data?.message || '测试完成' + testFileSummaries.value = response.data?.file_summaries || [] + testResultHtml.value = renderTestResult(response.data?.content) + testStep.value = 2 + } } catch (error: any) { message.error(error.message || '段落测试失败') resetTestModal() @@ -331,6 +441,68 @@ async function startTest() { } } +async function runStreamingTest(templateId: number, filePaths: string[]) { + const response = await fetch(generateApi.testStream(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + paragraph_id: selectedPara.value.id, + template_id: templateId, + prompt_text: selectedPara.value.prompt_text || '', + model_id: selectedPara.value.model_id || 0, + file_paths: filePaths, + }), + }) + if (!response.ok || !response.body) { + throw new Error('流式测试启动失败') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + + const processEventBlock = (block: string) => { + const normalizedBlock = block.replace(/\r\n/g, '\n').trim() + if (!normalizedBlock) return + const dataLines = normalizedBlock + .split('\n') + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice(6)) + if (!dataLines.length) return + + const payload = JSON.parse(dataLines.join('\n')) + if (payload.type === 'meta') { + testFileSummaries.value = payload.file_summaries || [] + testResultMessage.value = payload.message || '流式生成中' + } else if (payload.type === 'delta') { + streamedText.value += payload.content || '' + } else if (payload.type === 'error') { + throw new Error(payload.message || '流式测试失败') + } else if (payload.type === 'done') { + testResultHtml.value = `
${streamedText.value}
` + testStep.value = 2 + } + } + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const segments = buffer.split(/\r?\n\r?\n/) + buffer = segments.pop() || '' + for (const segment of segments) { + processEventBlock(segment) + } + } + if (buffer.trim()) { + processEventBlock(buffer) + } + if (!testResultHtml.value) { + testResultHtml.value = `
${streamedText.value}
` + testStep.value = 2 + } +} + onMounted(async () => { const id = Number(route.params.id) const template = await store.fetchOne(id) @@ -654,6 +826,75 @@ onMounted(async () => { margin-bottom: 16px; } +.history-card { + margin-bottom: 16px; + padding: 16px; + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #fafbfc; +} + +.history-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.history-title { + font-size: 14px; + font-weight: 600; + color: #111827; +} + +.history-desc { + margin-top: 4px; + font-size: 12px; + color: #6b7280; +} + +.history-search { + margin: 12px 0; +} + +.history-list { + display: grid; + gap: 8px; + max-height: 240px; + overflow-y: auto; +} + +.history-item { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #fff; + cursor: pointer; +} + +.history-item-main { + min-width: 0; + flex: 1; +} + +.history-item-name { + font-size: 13px; + font-weight: 500; + color: #111827; + word-break: break-all; +} + +.history-item-meta { + display: flex; + gap: 12px; + margin-top: 4px; + font-size: 12px; + color: #6b7280; +} + .uploaded-item { display: flex; justify-content: space-between; @@ -678,6 +919,29 @@ onMounted(async () => { color: #5b626e; } +.streaming-card { + margin-top: 20px; + background: #f0f0ff; + border-left: 3px solid #5b5bd6; + border-radius: 4px; + padding: 16px; + text-align: left; +} + +.streaming-title { + font-size: 12px; + font-weight: 600; + color: #5b5bd6; + margin-bottom: 8px; +} + +.streaming-pre { + white-space: pre-wrap; + margin: 0; + font-family: inherit; + line-height: 1.8; +} + .result-meta { display: flex; align-items: center; @@ -739,6 +1003,13 @@ onMounted(async () => { border-radius: 4px; } +:deep(.streamed-pre) { + white-space: pre-wrap; + margin: 0; + font-family: inherit; + line-height: 1.8; +} + :deep(.result-text) { margin: 0 0 12px; line-height: 1.8;