Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff6dacd136 | |||
| 401e8cf57b | |||
| d3530ab0b7 | |||
| 8743a02110 | |||
| a8716aa3c6 | |||
| 5a43cc70d5 | |||
| b313083766 | |||
| a2bad58591 | |||
| b15a3a1f18 | |||
| e558733f05 |
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
.idea
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
|||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
Generated
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="azul-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/doc-forge.iml" filepath="$PROJECT_DIR$/.idea/doc-forge.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# AGENTS.md — doc-forge
|
||||||
|
|
||||||
|
你正在参与一个外包项目,客户需要一套完整的 AI 文档模板生成系统。
|
||||||
|
|
||||||
|
## 你的角色
|
||||||
|
全栈开发 AI 助手,负责生成 Vue 3 + Ant Design Vue 前端代码和 Python FastAPI 后端代码。
|
||||||
|
|
||||||
|
## 基础设施
|
||||||
|
|
||||||
|
### MySQL
|
||||||
|
- 数据库名:`doc_forge`,字符集 `utf8mb4`
|
||||||
|
- 异步驱动:`asyncmy`
|
||||||
|
- 连接池:pool_size=10, max_overflow=20
|
||||||
|
- 本地开发:`docker-compose up mysql`
|
||||||
|
- DDL 见 `init.sql`
|
||||||
|
|
||||||
|
### MinIO(对象存储)
|
||||||
|
- 三个 bucket:`doc-forge-templates`(模板)/ `doc-forge-uploads`(参考文件)/ `doc-forge-outputs`(导出文档)
|
||||||
|
- 文件路径规则:`{bucket}/{YYYYMMDD}/{uuid}.{ext}`
|
||||||
|
- 预签名 URL 用于前端下载,过期 1 小时
|
||||||
|
- 本地开发:`docker-compose up minio`,Console http://localhost:9001
|
||||||
|
- SDK:`from minio import Minio`,客户端在 `services/minio_client.py`
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- `docker-compose up -d mysql minio` 启动开发依赖
|
||||||
|
- `docker-compose up backend web` 启动全栈
|
||||||
|
|
||||||
|
## 通讯协议
|
||||||
|
- 所有 API 响应格式:`{ code: 0, data: {...}, message: "ok" }`
|
||||||
|
- 错误响应:`{ code: -1, message: "错误描述" }`
|
||||||
|
- 分页响应:`{ code: 0, data: { items: [], total: N, page: 1, page_size: 20 } }`
|
||||||
|
|
||||||
|
## 必须遵守的规则
|
||||||
|
|
||||||
|
1. **AI 输出格式** — AI 必须返回 JSON,不得返回纯文本。前端解析 `content` 数组,按 type 分段渲染。
|
||||||
|
2. **Word 导出** — 严禁重新生成文档。从 MinIO 拉取原始模板,只替换对应位置的文本节点。
|
||||||
|
3. **段落边界** — 只认 Word 标题样式(Heading)。不要尝试用正则或关键词判断段落。
|
||||||
|
4. **API Key 安全** — 所有 API Key 用 `cryptography.fernet.Fernet` 加密存储,前端只展示脱敏字符串。
|
||||||
|
5. **并发控制** — 段落生成使用 `asyncio.gather` + `Semaphore`,单文档最大并发 5。
|
||||||
|
6. **文件存储** — 所有用户文件存 MinIO,后端本地只做临时缓存。
|
||||||
|
7. docs/规范与约束/开发规范.md
|
||||||
|
8. docs/需求与设计/02-模板格式规范.md
|
||||||
|
|
||||||
|
## 段落配置字段
|
||||||
|
每个 paragraph 包含:
|
||||||
|
- `edit_mode`: 'manual' | 'ai'
|
||||||
|
- `model_id`: int | null(null 表示使用系统默认模型)
|
||||||
|
- `need_prompt`: boolean + `prompt_text`: string
|
||||||
|
- `need_file`: boolean + `file_note`: string(备注提示上传什么文件)
|
||||||
|
- `output_format`: 'text' | 'table' | 'mixed' | 'chart'
|
||||||
|
|
||||||
|
## 容易踩的坑
|
||||||
|
- python-docx 中文字体名在 `run.fonts.eastAsia`,不是 `run.fonts.name`
|
||||||
|
- Ant Design Vue 4.x 的 modal 使用 `v-model:open`,不是 `v-model:visible`
|
||||||
|
- SSE 事件流要用 `sse-starlette` 的 `EventSourceResponse`
|
||||||
|
- asyncio 中不能混用同步的 openpyxl,Excel 解析放在线程池执行 (`run_in_executor`)
|
||||||
|
- MinIO SDK 是同步的,用 `run_in_executor` 包装,不要直接 in asyncio
|
||||||
|
- asyncmy 连接 MySQL 需要 `charset=utf8mb4`,不然中文会乱码
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# doc-forge — AI 文档模板生成系统
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
上传 Word 模板 → AI 自动解析段落(按标题样式切割)→ 用户标注段落配置 → 上传参考文件 → AI 多段落并行生成 → 预览编辑 → 导出 Word(保留原始样式)。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
| 层 | 技术 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| 前端 | Vue 3.4 + TypeScript + Ant Design Vue 4.x + Pinia + Vite 5 | web/ |
|
||||||
|
| 后端 | Python 3.11+ + FastAPI + SQLAlchemy 2.0 async | backend/app/ |
|
||||||
|
| 数据库 | MySQL 8.0(asyncmy 驱动) | docker-compose mysql |
|
||||||
|
| 对象存储 | MinIO | 存模板文件、参考文件、生成文档 |
|
||||||
|
| Word 处理 | python-docx | 解析/导出 |
|
||||||
|
| Excel 处理 | openpyxl | 解析参考文件 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
```
|
||||||
|
doc-forge/
|
||||||
|
├── web/ 前端
|
||||||
|
│ └── src/
|
||||||
|
│ ├── views/ TemplateList, TemplateEditor, ModelManage, GeneratePage, HistoryPage, PreviewEdit
|
||||||
|
│ ├── components/ ParagraphList, ParagraphConfig, DocPreview, FileUploader, ModelModal, TestModal
|
||||||
|
│ ├── api/ Axios(template, model, generate)
|
||||||
|
│ ├── stores/ Pinia(template, model, document)
|
||||||
|
│ └── router/ 6 条路由
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── models/ ORM(template, paragraph, ai_model, document, generation_log)
|
||||||
|
│ │ ├── routers/ API(templates, models, generate, export)
|
||||||
|
│ │ ├── schemas/ Pydantic 校验
|
||||||
|
│ │ ├── services/ 业务逻辑(parser, ai_service, generator, exporter, minio_client)
|
||||||
|
│ │ └── main.py
|
||||||
|
│ ├── config.py 配置(MySQL + MinIO + AI)
|
||||||
|
│ └── database.py 异步引擎
|
||||||
|
├── docs/ 项目文档
|
||||||
|
├── docker-compose.yml MySQL + MinIO + backend + web
|
||||||
|
├── init.sql 数据库建表 DDL
|
||||||
|
├── CLAUDE.md
|
||||||
|
└── AGENTS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键约定
|
||||||
|
|
||||||
|
### 段落解析规则
|
||||||
|
- 段落边界由 Word 标题样式(Heading 1~6)确定
|
||||||
|
- 标题与下一标题之间的正文、表格归属到该标题段落
|
||||||
|
- 表格独立存储为 `is_table=True`,归属于前一个标题
|
||||||
|
|
||||||
|
### AI 输出格式
|
||||||
|
AI 必须返回结构化 JSON:
|
||||||
|
```json
|
||||||
|
{"content": [{"type": "text", "text": "..."}, {"type": "table", "headers": [], "rows": []}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件存储
|
||||||
|
- 所有文件存 MinIO,不存本地磁盘
|
||||||
|
- bucket 分三类:templates / uploads / outputs
|
||||||
|
- MinIO 开发环境在 docker-compose 中启动
|
||||||
|
|
||||||
|
### AI 模型调用
|
||||||
|
- OpenAI 格式:GPT-4o, DeepSeek-V3, 通义千问
|
||||||
|
- Anthropic 格式:Claude 3.5 Sonnet
|
||||||
|
- 超时 60s,最多重试 3 次,单文档最大并发 5
|
||||||
|
|
||||||
|
### Word 导出
|
||||||
|
- 从 MinIO 拉取原始模板 → 在内存中修改 → 上传回 MinIO
|
||||||
|
- 样式完全保留(字体/颜色/行距/页边距/页眉页脚)
|
||||||
@@ -1,3 +1,192 @@
|
|||||||
# doc-forge
|
# doc-forge
|
||||||
|
|
||||||
《文档锻造》
|
上传 Word 模板 → AI 逐段落生成 → 预览编辑 → 导出尽量保留原始样式的 Word 文档。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **前端**: Vue 3.4 + Vite 5 + TypeScript + Ant Design Vue 4.x + Pinia + Axios
|
||||||
|
- **后端**: Python 3.11+ + FastAPI + SQLAlchemy 2.0 async
|
||||||
|
- **数据库**: MySQL 8.0(asyncmy 驱动)
|
||||||
|
- **对象存储**: MinIO(模板 / 参考文件 / 导出文档)
|
||||||
|
- **文档处理**: python-docx、openpyxl
|
||||||
|
|
||||||
|
## 当前可用能力
|
||||||
|
|
||||||
|
- 上传 `.docx` 模板并按 Heading 1~6 解析段落
|
||||||
|
- 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式
|
||||||
|
- 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容
|
||||||
|
- 执行整份文档生成:已支持按模型配置发起真实调用,异常时自动回退为模拟结果
|
||||||
|
- 生成过程中支持 SSE 进度推送与取消生成
|
||||||
|
- 查看生成记录与预览页真实结果
|
||||||
|
- 导出 Word:基于原模板替换标题下内容并生成可下载文件
|
||||||
|
|
||||||
|
## 运行方式
|
||||||
|
|
||||||
|
推荐开发方式:`Docker 启动依赖 + 本地启动前后端`
|
||||||
|
|
||||||
|
### 1. 启动 MySQL 和 MinIO
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d mysql minio
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后可访问:
|
||||||
|
|
||||||
|
- MySQL: `localhost:3306`
|
||||||
|
- MinIO API: `http://localhost:9000`
|
||||||
|
- MinIO Console: `http://localhost:9001`
|
||||||
|
|
||||||
|
默认账号:
|
||||||
|
|
||||||
|
- MinIO 用户名: `docforge`
|
||||||
|
- MinIO 密码: `docforge123`
|
||||||
|
|
||||||
|
### 2. 启动后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/zhouwentao/Workspaces/Yangliu/doc-forge/backend
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
后端地址:
|
||||||
|
|
||||||
|
- API 根地址: `http://localhost:8000`
|
||||||
|
- 健康检查: `http://localhost:8000/health`
|
||||||
|
|
||||||
|
后端默认读取 [backend/.env](/Users/zhouwentao/Workspaces/Yangliu/doc-forge/backend/.env):
|
||||||
|
|
||||||
|
```env
|
||||||
|
DEBUG=True
|
||||||
|
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=docforge
|
||||||
|
DB_PASSWORD=docforge123
|
||||||
|
DB_NAME=doc_forge
|
||||||
|
|
||||||
|
MINIO_ENDPOINT=localhost:9000
|
||||||
|
MINIO_ACCESS_KEY=docforge
|
||||||
|
MINIO_SECRET_KEY=docforge123
|
||||||
|
MINIO_USE_SSL=False
|
||||||
|
|
||||||
|
ENCRYPTION_KEY=change-this-to-a-32-byte-key-in-production!!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/zhouwentao/Workspaces/Yangliu/doc-forge/web
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端地址:
|
||||||
|
|
||||||
|
- `http://localhost:5173`
|
||||||
|
|
||||||
|
### 4. 初步使用流程
|
||||||
|
|
||||||
|
1. 打开“模板管理”,上传一个 `.docx` 模板。
|
||||||
|
2. 进入模板编辑页,为段落配置 AI / 手动、提示词、模型等。
|
||||||
|
3. 打开“模型管理”,添加至少一个模型配置。
|
||||||
|
4. 打开“执行生成”,选择模板并发起生成。
|
||||||
|
5. 到“生成记录”查看历史,点击“预览”查看实际生成内容。
|
||||||
|
6. 在预览页点击“导出 Word”下载导出文件。
|
||||||
|
|
||||||
|
## Docker 全套启动
|
||||||
|
|
||||||
|
如果想直接用 Docker 跑全套,可以在项目根目录准备 `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENCRYPTION_KEY=change-this-to-a-32-byte-key-in-production!!
|
||||||
|
```
|
||||||
|
|
||||||
|
然后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
暴露端口:
|
||||||
|
|
||||||
|
- 前端: `5173`
|
||||||
|
- 后端: `8000`
|
||||||
|
- MinIO: `9000`
|
||||||
|
- MinIO Console: `9001`
|
||||||
|
- MySQL: `3306`
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### `.idea` 不小心提交了怎么办?
|
||||||
|
|
||||||
|
不影响项目运行,但建议尽快移除并加入忽略:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo ".idea/" >> .gitignore
|
||||||
|
git rm -r --cached .idea
|
||||||
|
git add .gitignore
|
||||||
|
git commit -m "移除 IDE 配置文件"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 为什么我本地 `python main.py` 报缺少模块?
|
||||||
|
|
||||||
|
说明当前 Python 环境还没安装依赖,先执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐用虚拟环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 为什么导出 PDF 还不可用?
|
||||||
|
|
||||||
|
当前阶段已经支持基础 Word 导出,PDF 导出还未接入 LibreOffice 转换流程。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
doc-forge/
|
||||||
|
├── web/ Vue 3 前端
|
||||||
|
│ └── src/
|
||||||
|
│ ├── views/ 页面
|
||||||
|
│ ├── components/ 通用组件
|
||||||
|
│ ├── api/ Axios 请求层
|
||||||
|
│ ├── stores/ Pinia 状态管理
|
||||||
|
│ └── router/ 路由配置
|
||||||
|
├── backend/ Python FastAPI 后端
|
||||||
|
│ ├── models/ ORM 数据模型
|
||||||
|
│ ├── routers/ API 路由
|
||||||
|
│ ├── schemas/ Pydantic 校验
|
||||||
|
│ ├── services/ 业务逻辑层
|
||||||
|
│ ├── config.py 配置
|
||||||
|
│ └── database.py 异步数据库引擎
|
||||||
|
├── docker-compose.yml MySQL + MinIO + 后端 + 前端
|
||||||
|
├── init.sql 数据库初始化 SQL
|
||||||
|
└── docs/ 项目文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
上传模板 → 解析段落 → 标注配置 → 保存模板
|
||||||
|
→ 执行生成(上传文件 → AI 生成)
|
||||||
|
→ 预览编辑 → 导出 Word
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- Node.js 18+
|
||||||
|
- pnpm 8+
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- LibreOffice(可选,用于未来的 PDF 导出)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# 应用
|
||||||
|
APP_NAME: str = "AI 文档模板生成系统"
|
||||||
|
APP_VERSION: str = "1.0.0"
|
||||||
|
DEBUG: bool = True
|
||||||
|
|
||||||
|
# === 数据库 MySQL ===
|
||||||
|
DB_HOST: str = "localhost"
|
||||||
|
DB_PORT: int = 3306
|
||||||
|
DB_USER: str = "docforge"
|
||||||
|
DB_PASSWORD: str = "docforge123"
|
||||||
|
DB_NAME: str = "doc_forge"
|
||||||
|
@property
|
||||||
|
def DATABASE_URL(self) -> str:
|
||||||
|
return f"mysql+asyncmy://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
|
||||||
|
|
||||||
|
# === MinIO 文件存储 ===
|
||||||
|
MINIO_ENDPOINT: str = "localhost:9000"
|
||||||
|
MINIO_ACCESS_KEY: str = "docforge"
|
||||||
|
MINIO_SECRET_KEY: str = "docforge123"
|
||||||
|
MINIO_BUCKET_TEMPLATES: str = "doc-forge-templates"
|
||||||
|
MINIO_BUCKET_UPLOADS: str = "doc-forge-uploads"
|
||||||
|
MINIO_BUCKET_OUTPUTS: str = "doc-forge-outputs"
|
||||||
|
MINIO_USE_SSL: bool = False
|
||||||
|
|
||||||
|
# 本地缓存目录(MinIO 文件的本地临时缓存)
|
||||||
|
LOCAL_CACHE_DIR: str = "local_cache"
|
||||||
|
|
||||||
|
# 文件上传限制
|
||||||
|
MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024 # 50MB
|
||||||
|
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!!"
|
||||||
|
|
||||||
|
# AI 模型默认配置
|
||||||
|
AI_REQUEST_TIMEOUT: int = 60
|
||||||
|
AI_MAX_RETRIES: int = 3
|
||||||
|
AI_MAX_CONCURRENT: int = 5
|
||||||
|
AI_GLOBAL_CONCURRENT: int = 10
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
HOST: str = "0.0.0.0"
|
||||||
|
PORT: int = 8000
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# 创建本地缓存目录
|
||||||
|
os.makedirs(settings.LOCAL_CACHE_DIR, exist_ok=True)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG, pool_size=10, max_overflow=20)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
from models.template import Template
|
||||||
|
from models.paragraph import Paragraph
|
||||||
|
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"))
|
||||||
|
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"))
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import uvicorn
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from database import init_db, engine
|
||||||
|
from config import settings
|
||||||
|
from routers import templates, models, generate, export
|
||||||
|
from services.minio_client import init_buckets
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
await init_buckets() # 初始化 MinIO 存储桶
|
||||||
|
yield
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION, lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(templates.router, prefix="/api/v1/templates", tags=["模板管理"])
|
||||||
|
app.include_router(models.router, prefix="/api/v1/models", tags=["模型管理"])
|
||||||
|
app.include_router(generate.router, prefix="/api/v1/generate", tags=["生成管理"])
|
||||||
|
app.include_router(export.router, prefix="/api/v1/export", tags=["导出管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(_: Request, exc: HTTPException):
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"code": -1, "message": exc.detail})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(_: Request, exc: Exception):
|
||||||
|
return JSONResponse(status_code=500, content={"code": -1, "message": str(exc) or "服务器内部错误"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"code": 0, "data": {"name": settings.APP_NAME, "version": settings.APP_VERSION}, "message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"code": 0, "data": {"status": "ok"}, "message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from models.template import Template
|
||||||
|
from models.paragraph import Paragraph
|
||||||
|
from models.ai_model import AiModel
|
||||||
|
from models.document import Document
|
||||||
|
from models.generation_log import GenerationLog
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
class AiModel(Base):
|
||||||
|
__tablename__ = "ai_models"
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(255), nullable=False, comment="模型名称")
|
||||||
|
provider = Column(String(100), default="", comment="供应厂商")
|
||||||
|
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())
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
class Document(Base):
|
||||||
|
__tablename__ = "documents"
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
|
||||||
|
name = Column(String(255), default="", comment="文档名称")
|
||||||
|
para_count_done = Column(Integer, default=0, comment="已完成段落数")
|
||||||
|
para_count_total = Column(Integer, default=0, comment="总段落数")
|
||||||
|
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())
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, ForeignKey, func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
class GenerationLog(Base):
|
||||||
|
__tablename__ = "generation_logs"
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
|
||||||
|
paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=False)
|
||||||
|
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True)
|
||||||
|
status = Column(String(20), default="pending", comment="pending/generating/success/failed")
|
||||||
|
content = Column(Text, default="", comment="生成的内容")
|
||||||
|
duration = Column(Float, default=0, comment="耗时秒数")
|
||||||
|
error_msg = Column(Text, default="", comment="错误信息")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
class Paragraph(Base):
|
||||||
|
__tablename__ = "paragraphs"
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
|
||||||
|
sort_index = Column(Integer, default=0, comment="排序")
|
||||||
|
title = Column(String(500), default="", comment="段落标题")
|
||||||
|
content = Column(Text, default="", comment="正文内容/上下文")
|
||||||
|
style_json = Column(Text, default="{}", comment="段落样式定义JSON")
|
||||||
|
is_table = Column(Boolean, default=False, comment="是否为表格")
|
||||||
|
table_json = Column(Text, default="{}", comment="表格结构JSON")
|
||||||
|
edit_mode = Column(String(20), default="ai", comment="manual/ai")
|
||||||
|
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型")
|
||||||
|
need_prompt = Column(Boolean, default=True, comment="是否需要提示词")
|
||||||
|
prompt_text = Column(Text, default="", comment="预设提示词")
|
||||||
|
need_file = Column(Boolean, default=False, comment="是否需要上传参考文件")
|
||||||
|
file_note = Column(Text, default="", comment="备注说明(传什么文件)")
|
||||||
|
output_format = Column(String(20), default="text", comment="text/table/mixed/chart")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
class Template(Base):
|
||||||
|
__tablename__ = "templates"
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(255), nullable=False, comment="模板名称")
|
||||||
|
description = Column(Text, default="", comment="描述")
|
||||||
|
file_path = Column(String(500), nullable=False, comment="原始模板文件路径")
|
||||||
|
paragraph_count = Column(Integer, default=0, comment="段落数")
|
||||||
|
status = Column(String(20), default="draft", comment="draft/ready")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
sqlalchemy>=2.0.25
|
||||||
|
asyncmy>=0.2.9 # MySQL async driver
|
||||||
|
aiomysql>=0.2.0 # MySQL async fallback
|
||||||
|
cryptography>=42.0.0
|
||||||
|
python-docx>=1.1.0
|
||||||
|
openpyxl>=3.1.0
|
||||||
|
pandas>=2.1.0
|
||||||
|
xlrd>=2.0.1
|
||||||
|
httpx>=0.27.0
|
||||||
|
pydantic>=2.5.0
|
||||||
|
pydantic-settings>=2.1.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
aiofiles>=23.2.0
|
||||||
|
sse-starlette>=2.0.0
|
||||||
|
minio>=7.2.0 # MinIO 对象存储 SDK
|
||||||
|
alembic>=1.13.0
|
||||||
|
pypdf>=5.0.0
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import PlainTextResponse, RedirectResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import get_db
|
||||||
|
from models.document import Document
|
||||||
|
from models.generation_log import GenerationLog
|
||||||
|
from models.paragraph import Paragraph
|
||||||
|
from models.template import Template
|
||||||
|
from services.document_export import export_document_bytes
|
||||||
|
from services.minio_client import (
|
||||||
|
download_object_bytes,
|
||||||
|
get_presigned_url,
|
||||||
|
split_bucket_path,
|
||||||
|
upload_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}/docx")
|
||||||
|
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
document = await db.get(Document, document_id)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=404, detail="生成记录不存在")
|
||||||
|
|
||||||
|
template = await db.get(Template, document.template_id)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
|
template_bucket, template_object = split_bucket_path(template.file_path)
|
||||||
|
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(GenerationLog, Paragraph)
|
||||||
|
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
|
||||||
|
.where(GenerationLog.document_id == document_id)
|
||||||
|
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||||
|
)
|
||||||
|
logs = []
|
||||||
|
for log, paragraph in result.all():
|
||||||
|
logs.append(
|
||||||
|
{
|
||||||
|
"title": paragraph.title,
|
||||||
|
"content": json.loads(log.content) if log.content else {"content": []},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
|
||||||
|
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx"
|
||||||
|
await asyncio.to_thread(
|
||||||
|
upload_bytes,
|
||||||
|
settings.MINIO_BUCKET_OUTPUTS,
|
||||||
|
object_name,
|
||||||
|
exported_bytes,
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
||||||
|
|
||||||
|
document.file_path = f"{settings.MINIO_BUCKET_OUTPUTS}/{object_name}"
|
||||||
|
await db.commit()
|
||||||
|
return RedirectResponse(
|
||||||
|
url=get_presigned_url(settings.MINIO_BUCKET_OUTPUTS, object_name),
|
||||||
|
status_code=307,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}/pdf")
|
||||||
|
async def export_pdf(document_id: int):
|
||||||
|
return PlainTextResponse(
|
||||||
|
f"文档 {document_id} 的 PDF 导出功能正在开发中,当前版本请先使用预览页查看结果。",
|
||||||
|
media_type="text/plain; charset=utf-8",
|
||||||
|
)
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
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
|
||||||
|
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, 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 (
|
||||||
|
build_mock_content,
|
||||||
|
generation_progress,
|
||||||
|
request_cancel,
|
||||||
|
run_generation,
|
||||||
|
update_progress,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
"name": document.name,
|
||||||
|
"para_count_done": document.para_count_done,
|
||||||
|
"para_count_total": document.para_count_total,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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.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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 = "当前未找到可用模型,返回本地模拟生成结果。"
|
||||||
|
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} 生成。"
|
||||||
|
return Response(
|
||||||
|
data={
|
||||||
|
"paragraph_id": paragraph.id,
|
||||||
|
"content": content,
|
||||||
|
"message": message,
|
||||||
|
"file_summaries": file_summaries,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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_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():
|
||||||
|
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(...), 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:
|
||||||
|
allowed = " / ".join(settings.ALLOWED_EXTENSIONS)
|
||||||
|
raise HTTPException(status_code=400, detail=f"文件类型不支持:{ext or '无扩展名'}。当前支持:{allowed}")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="上传文件不能为空")
|
||||||
|
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(status_code=400, detail="文件大小超过限制")
|
||||||
|
|
||||||
|
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}{ext}"
|
||||||
|
await asyncio.to_thread(
|
||||||
|
upload_bytes,
|
||||||
|
settings.MINIO_BUCKET_UPLOADS,
|
||||||
|
object_name,
|
||||||
|
content,
|
||||||
|
file.content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
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.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)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Paragraph)
|
||||||
|
.where(Paragraph.template_id == body.template_id)
|
||||||
|
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||||
|
)
|
||||||
|
paragraphs = result.scalars().all()
|
||||||
|
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')}",
|
||||||
|
para_count_done=0,
|
||||||
|
para_count_total=len(paragraphs),
|
||||||
|
status="generating",
|
||||||
|
file_path="",
|
||||||
|
error="",
|
||||||
|
request_payload_json=json.dumps(request_payload, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
db.add(document)
|
||||||
|
await db.flush()
|
||||||
|
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),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
total = (await db.execute(select(func.count(Document.id)))).scalar_one()
|
||||||
|
result = await db.execute(
|
||||||
|
select(Document)
|
||||||
|
.order_by(Document.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
items = [_serialize_document(item) for item in result.scalars().all()]
|
||||||
|
return Response(data={"items": items, "total": total, "page": page, "page_size": page_size})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{document_id}")
|
||||||
|
async def get_document(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
document = await db.get(Document, document_id)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=404, detail="生成记录不存在")
|
||||||
|
|
||||||
|
log_result = await db.execute(
|
||||||
|
select(GenerationLog, Paragraph)
|
||||||
|
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
|
||||||
|
.where(GenerationLog.document_id == document_id)
|
||||||
|
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
for log, paragraph in log_result.all():
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": log.id,
|
||||||
|
"paragraph_id": paragraph.id,
|
||||||
|
"title": paragraph.title,
|
||||||
|
"sort_index": paragraph.sort_index,
|
||||||
|
"status": log.status,
|
||||||
|
"content": json.loads(log.content) if log.content else {"content": []},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = _serialize_document(document)
|
||||||
|
payload["logs"] = items
|
||||||
|
return Response(data=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cancel/{document_id}")
|
||||||
|
async def cancel_document(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
document = await db.get(Document, document_id)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=404, detail="生成记录不存在")
|
||||||
|
|
||||||
|
if document.status in {"completed", "failed", "cancelled"}:
|
||||||
|
return Response(data=_serialize_document(document))
|
||||||
|
|
||||||
|
request_cancel(document_id)
|
||||||
|
return Response(data=_serialize_document(document))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/documents/{document_id}")
|
||||||
|
async def delete_document(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
document = await db.get(Document, document_id)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=404, detail="生成记录不存在")
|
||||||
|
|
||||||
|
result = await db.execute(select(GenerationLog).where(GenerationLog.document_id == document_id))
|
||||||
|
for log in result.scalars().all():
|
||||||
|
await db.delete(log)
|
||||||
|
|
||||||
|
await db.delete(document)
|
||||||
|
await db.commit()
|
||||||
|
return Response(data={"id": document_id})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.ai_model import AiModel
|
||||||
|
from schemas.schemas import AiModelCreate, AiModelUpdate, Response
|
||||||
|
from services.ai_service import call_ai
|
||||||
|
from services.security import decrypt_text, encrypt_text, mask_secret
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_model(model: AiModel) -> dict:
|
||||||
|
api_key = decrypt_text(model.api_key_encrypted)
|
||||||
|
return {
|
||||||
|
"id": model.id,
|
||||||
|
"name": model.name,
|
||||||
|
"provider": model.provider,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_models(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(AiModel).order_by(AiModel.id.desc()))
|
||||||
|
items = [_serialize_model(item) for item in result.scalars().all()]
|
||||||
|
return Response(data=items)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_model(body: AiModelCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
model = AiModel(
|
||||||
|
name=body.name,
|
||||||
|
provider=body.provider,
|
||||||
|
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)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(model)
|
||||||
|
return Response(data=_serialize_model(model))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{model_id}")
|
||||||
|
async def update_model(model_id: int, body: AiModelUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
|
model = await db.get(AiModel, model_id)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模型不存在")
|
||||||
|
|
||||||
|
if body.name is not None:
|
||||||
|
model.name = body.name
|
||||||
|
if body.provider is not None:
|
||||||
|
model.provider = body.provider
|
||||||
|
if body.api_format is not None:
|
||||||
|
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:
|
||||||
|
model.api_key_encrypted = encrypt_text(body.api_key)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(model)
|
||||||
|
return Response(data=_serialize_model(model))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{model_id}")
|
||||||
|
async def delete_model(model_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
model = await db.get(AiModel, model_id)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模型不存在")
|
||||||
|
|
||||||
|
await db.delete(model)
|
||||||
|
await db.commit()
|
||||||
|
return Response(data={"id": model_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{model_id}/test")
|
||||||
|
async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
model = await db.get(AiModel, model_id)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模型不存在")
|
||||||
|
|
||||||
|
class FakeParagraph:
|
||||||
|
title = "连接测试"
|
||||||
|
content = "请返回一段非常简短的测试文本。"
|
||||||
|
need_prompt = False
|
||||||
|
prompt_text = ""
|
||||||
|
output_format = "text"
|
||||||
|
enable_reasoning = bool(model.enable_reasoning)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await call_ai(FakeParagraph(), model)
|
||||||
|
return Response(
|
||||||
|
data={
|
||||||
|
"id": model.id,
|
||||||
|
"success": True,
|
||||||
|
"message": f"模型 {model.name} 连接测试成功",
|
||||||
|
"preview": result.content,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
return Response(
|
||||||
|
code=-1,
|
||||||
|
message=str(error),
|
||||||
|
data={"id": model.id, "success": False},
|
||||||
|
)
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import get_db
|
||||||
|
from models.paragraph import Paragraph
|
||||||
|
from models.template import Template
|
||||||
|
from schemas.schemas import Response, TemplateSave
|
||||||
|
from services.minio_client import minio_client
|
||||||
|
from services.template_parser import parse_template
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_object_path(filename: str) -> tuple[str, str]:
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
date_prefix = datetime.now().strftime("%Y%m%d")
|
||||||
|
object_name = f"{date_prefix}/{uuid.uuid4().hex}{ext}"
|
||||||
|
return ext, object_name
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_paragraph(paragraph: Paragraph) -> dict:
|
||||||
|
return {
|
||||||
|
"id": paragraph.id,
|
||||||
|
"template_id": paragraph.template_id,
|
||||||
|
"sort_index": paragraph.sort_index,
|
||||||
|
"title": paragraph.title,
|
||||||
|
"content": paragraph.content,
|
||||||
|
"style_json": paragraph.style_json,
|
||||||
|
"is_table": paragraph.is_table,
|
||||||
|
"table_json": paragraph.table_json,
|
||||||
|
"edit_mode": paragraph.edit_mode,
|
||||||
|
"model_id": paragraph.model_id,
|
||||||
|
"need_prompt": paragraph.need_prompt,
|
||||||
|
"prompt_text": paragraph.prompt_text,
|
||||||
|
"need_file": paragraph.need_file,
|
||||||
|
"file_note": paragraph.file_note,
|
||||||
|
"output_format": paragraph.output_format,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_template(template: Template) -> dict:
|
||||||
|
return {
|
||||||
|
"id": template.id,
|
||||||
|
"name": template.name,
|
||||||
|
"description": template.description,
|
||||||
|
"file_path": template.file_path,
|
||||||
|
"paragraph_count": template.paragraph_count,
|
||||||
|
"status": template.status,
|
||||||
|
"created_at": template.created_at,
|
||||||
|
"updated_at": template.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_templates(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
keyword: str = Query("", alias="q"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
filters = []
|
||||||
|
if keyword:
|
||||||
|
filters.append(Template.name.like(f"%{keyword}%"))
|
||||||
|
|
||||||
|
total_stmt = select(func.count(Template.id))
|
||||||
|
list_stmt = select(Template).order_by(Template.id.desc())
|
||||||
|
if filters:
|
||||||
|
total_stmt = total_stmt.where(*filters)
|
||||||
|
list_stmt = list_stmt.where(*filters)
|
||||||
|
|
||||||
|
total = (await db.execute(total_stmt)).scalar_one()
|
||||||
|
result = await db.execute(list_stmt.offset((page - 1) * page_size).limit(page_size))
|
||||||
|
items = [_serialize_template(item) for item in result.scalars().all()]
|
||||||
|
return Response(
|
||||||
|
data={"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}")
|
||||||
|
async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
template = await db.get(Template, template_id)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Paragraph)
|
||||||
|
.where(Paragraph.template_id == template_id)
|
||||||
|
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||||
|
)
|
||||||
|
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()]
|
||||||
|
payload = _serialize_template(template)
|
||||||
|
payload["paragraphs"] = paragraphs
|
||||||
|
return Response(data=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="文件名不能为空")
|
||||||
|
|
||||||
|
ext, object_name = _build_object_path(file.filename)
|
||||||
|
if ext != ".docx":
|
||||||
|
raise HTTPException(status_code=400, detail="模板仅支持 .docx 格式")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="上传文件不能为空")
|
||||||
|
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(status_code=400, detail="文件大小超过限制")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp_file:
|
||||||
|
temp_file.write(content)
|
||||||
|
temp_path = temp_file.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_items = await asyncio.to_thread(parse_template, temp_path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
|
||||||
|
await asyncio.to_thread(
|
||||||
|
minio_client.put_object,
|
||||||
|
settings.MINIO_BUCKET_TEMPLATES,
|
||||||
|
object_name,
|
||||||
|
BytesIO(content),
|
||||||
|
len(content),
|
||||||
|
file.content_type or "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
name=os.path.splitext(file.filename)[0],
|
||||||
|
description="",
|
||||||
|
file_path=f"{settings.MINIO_BUCKET_TEMPLATES}/{object_name}",
|
||||||
|
paragraph_count=len(parsed_items),
|
||||||
|
status="draft",
|
||||||
|
)
|
||||||
|
db.add(template)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
paragraph_rows: list[Paragraph] = []
|
||||||
|
for item in parsed_items:
|
||||||
|
paragraph = Paragraph(
|
||||||
|
template_id=template.id,
|
||||||
|
sort_index=item.sort_index,
|
||||||
|
title=item.title,
|
||||||
|
content=item.content,
|
||||||
|
style_json=item.style_json,
|
||||||
|
is_table=item.is_table,
|
||||||
|
table_json=item.table_json,
|
||||||
|
)
|
||||||
|
db.add(paragraph)
|
||||||
|
paragraph_rows.append(paragraph)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(template)
|
||||||
|
for paragraph in paragraph_rows:
|
||||||
|
await db.refresh(paragraph)
|
||||||
|
|
||||||
|
payload = _serialize_template(template)
|
||||||
|
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
|
||||||
|
return Response(data=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{template_id}/paragraphs")
|
||||||
|
async def save_template_paragraphs(
|
||||||
|
template_id: int,
|
||||||
|
body: TemplateSave,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
template = await db.get(Template, template_id)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
|
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
|
||||||
|
paragraph_map = {item.id: item for item in result.scalars().all()}
|
||||||
|
|
||||||
|
for config in body.paragraphs:
|
||||||
|
paragraph = paragraph_map.get(config.id)
|
||||||
|
if paragraph is None:
|
||||||
|
continue
|
||||||
|
paragraph.sort_index = config.sort_index
|
||||||
|
paragraph.title = config.title
|
||||||
|
paragraph.edit_mode = config.edit_mode
|
||||||
|
paragraph.model_id = config.model_id
|
||||||
|
paragraph.need_prompt = config.need_prompt
|
||||||
|
paragraph.prompt_text = config.prompt_text
|
||||||
|
paragraph.need_file = config.need_file
|
||||||
|
paragraph.file_note = config.file_note
|
||||||
|
paragraph.output_format = config.output_format
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{template_id}")
|
||||||
|
async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
template = await db.get(Template, template_id)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
|
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
|
||||||
|
for paragraph in result.scalars().all():
|
||||||
|
await db.delete(paragraph)
|
||||||
|
|
||||||
|
file_path = template.file_path or ""
|
||||||
|
if "/" in file_path:
|
||||||
|
bucket, object_name = file_path.split("/", 1)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(minio_client.remove_object, bucket, object_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await db.delete(template)
|
||||||
|
await db.commit()
|
||||||
|
return Response(data={"id": template_id})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class Response(BaseModel):
|
||||||
|
code: int = 0
|
||||||
|
data: Any = None
|
||||||
|
message: str = "ok"
|
||||||
|
|
||||||
|
class PageData(BaseModel):
|
||||||
|
items: list = []
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 20
|
||||||
|
|
||||||
|
# 模板
|
||||||
|
class TemplateCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
class TemplateOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
file_path: str = ""
|
||||||
|
paragraph_count: int = 0
|
||||||
|
status: str = "draft"
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class ParagraphConfig(BaseModel):
|
||||||
|
id: int = 0
|
||||||
|
sort_index: int = 0
|
||||||
|
title: str = ""
|
||||||
|
edit_mode: str = "ai"
|
||||||
|
model_id: Optional[int] = None
|
||||||
|
need_prompt: bool = True
|
||||||
|
prompt_text: str = ""
|
||||||
|
need_file: bool = False
|
||||||
|
file_note: str = ""
|
||||||
|
output_format: str = "text"
|
||||||
|
|
||||||
|
class TemplateSave(BaseModel):
|
||||||
|
paragraphs: list[ParagraphConfig] = []
|
||||||
|
|
||||||
|
# 模型
|
||||||
|
class AiModelCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
provider: str = ""
|
||||||
|
api_format: str = "openai"
|
||||||
|
api_endpoint: str = ""
|
||||||
|
api_key: str = ""
|
||||||
|
supports_streaming: bool = False
|
||||||
|
enable_reasoning: bool = False
|
||||||
|
status: str = "enabled"
|
||||||
|
|
||||||
|
|
||||||
|
class AiModelUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
provider: Optional[str] = None
|
||||||
|
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):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
provider: str = ""
|
||||||
|
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
|
||||||
|
|
||||||
|
# 生成
|
||||||
|
class GenerateTestRequest(BaseModel):
|
||||||
|
paragraph_id: int
|
||||||
|
template_id: int
|
||||||
|
prompt_text: str = ""
|
||||||
|
model_id: int = 0
|
||||||
|
file_paths: list[str] = []
|
||||||
|
|
||||||
|
class GenerateFullRequest(BaseModel):
|
||||||
|
template_id: int
|
||||||
|
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
|
||||||
|
template_id: int
|
||||||
|
name: str
|
||||||
|
para_count_done: int = 0
|
||||||
|
para_count_total: int = 0
|
||||||
|
status: str = "pending"
|
||||||
|
file_path: str = ""
|
||||||
|
error: str = ""
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from models.ai_model import AiModel
|
||||||
|
from models.paragraph import Paragraph
|
||||||
|
from services.security import decrypt_text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AiCallResult:
|
||||||
|
content: dict
|
||||||
|
raw_text: str
|
||||||
|
used_model: str
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_json_content(text: str) -> dict:
|
||||||
|
stripped = text.strip()
|
||||||
|
if not stripped:
|
||||||
|
return {"content": [{"type": "text", "text": ""}]}
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(stripped)
|
||||||
|
if isinstance(parsed, dict) and "content" in parsed:
|
||||||
|
return parsed
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
code_block_match = re.search(r"```json\s*(.*?)\s*```", stripped, re.S)
|
||||||
|
if code_block_match:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(code_block_match.group(1))
|
||||||
|
if isinstance(parsed, dict) and "content" in parsed:
|
||||||
|
return parsed
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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:
|
||||||
|
user_parts.append(f"模板上下文:{paragraph.content}")
|
||||||
|
if paragraph.need_prompt and paragraph.prompt_text:
|
||||||
|
user_parts.append(f"附加要求:{paragraph.prompt_text}")
|
||||||
|
if file_summaries:
|
||||||
|
user_parts.append("参考文件内容:\n" + _format_file_context(file_summaries))
|
||||||
|
user_parts.append(f"输出格式:{paragraph.output_format}")
|
||||||
|
return system_prompt, "\n\n".join(user_parts)
|
||||||
|
|
||||||
|
|
||||||
|
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"):
|
||||||
|
return f"{endpoint}/chat/completions"
|
||||||
|
return f"{endpoint}/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_anthropic_endpoint(api_endpoint: str) -> str:
|
||||||
|
endpoint = api_endpoint.rstrip("/")
|
||||||
|
if endpoint.endswith("/messages"):
|
||||||
|
return endpoint
|
||||||
|
if endpoint.endswith("/v1"):
|
||||||
|
return f"{endpoint}/messages"
|
||||||
|
return f"{endpoint}/v1/messages"
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_with_retry(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
url: str,
|
||||||
|
headers: dict,
|
||||||
|
payload: dict,
|
||||||
|
) -> httpx.Response:
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(settings.AI_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
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} - {response.text[:500]}",
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
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)
|
||||||
|
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:
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
|
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"]
|
||||||
|
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:
|
||||||
|
raise RuntimeError("模型 API Key 不可用")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model.name,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"system": system_prompt,
|
||||||
|
"messages": [{"role": "user", "content": user_prompt}],
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
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 = ""
|
||||||
|
for item in body.get("content", []):
|
||||||
|
if item.get("type") == "text":
|
||||||
|
text += item.get("text", "")
|
||||||
|
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
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.document import Document as DocumentObject
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
from docx.oxml.table import CT_Tbl
|
||||||
|
from docx.oxml.text.paragraph import CT_P
|
||||||
|
from docx.table import Table, _Cell
|
||||||
|
from docx.text.paragraph import Paragraph
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_block_items(parent: DocumentObject | _Cell):
|
||||||
|
parent_elm = parent.element.body if isinstance(parent, DocumentObject) else parent._tc
|
||||||
|
for child in parent_elm.iterchildren():
|
||||||
|
if isinstance(child, CT_P):
|
||||||
|
yield Paragraph(child, parent)
|
||||||
|
elif isinstance(child, CT_Tbl):
|
||||||
|
yield Table(child, parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_heading(paragraph: Paragraph) -> bool:
|
||||||
|
style_name = paragraph.style.name if paragraph.style is not None else ""
|
||||||
|
normalized = style_name.lower().replace(" ", "")
|
||||||
|
return normalized.startswith("heading")
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_block(block):
|
||||||
|
element = block._element
|
||||||
|
parent = element.getparent()
|
||||||
|
if parent is not None:
|
||||||
|
parent.remove(element)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_paragraph(paragraph: Paragraph):
|
||||||
|
element = paragraph._element
|
||||||
|
for child in list(element):
|
||||||
|
if child.tag.endswith("}r"):
|
||||||
|
element.remove(child)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_paragraph_format(target: Paragraph, source: Paragraph | None):
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
source_ppr = source._element.pPr
|
||||||
|
if source_ppr is not None:
|
||||||
|
target._element.insert(0, deepcopy(source_ppr))
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_run_format(target_run, source_paragraph: Paragraph | None):
|
||||||
|
if source_paragraph is None:
|
||||||
|
return
|
||||||
|
for source_run in source_paragraph.runs:
|
||||||
|
if source_run._element.rPr is not None:
|
||||||
|
target_run._element.insert(0, deepcopy(source_run._element.rPr))
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _append_paragraph_after(
|
||||||
|
paragraph: Paragraph,
|
||||||
|
text: str,
|
||||||
|
style_name: str | None = None,
|
||||||
|
template_paragraph: Paragraph | None = None,
|
||||||
|
) -> Paragraph:
|
||||||
|
new_p = OxmlElement("w:p")
|
||||||
|
paragraph._element.addnext(new_p)
|
||||||
|
new_para = Paragraph(new_p, paragraph._parent)
|
||||||
|
_copy_paragraph_format(new_para, template_paragraph)
|
||||||
|
if style_name:
|
||||||
|
try:
|
||||||
|
new_para.style = style_name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if text:
|
||||||
|
run = new_para.add_run(text)
|
||||||
|
_copy_run_format(run, template_paragraph)
|
||||||
|
return new_para
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cell_text_with_template(cell, value: str, template_paragraph: Paragraph | None = None):
|
||||||
|
if not cell.paragraphs:
|
||||||
|
cell.text = value
|
||||||
|
return
|
||||||
|
paragraph = cell.paragraphs[0]
|
||||||
|
_clear_paragraph(paragraph)
|
||||||
|
run = paragraph.add_run(value)
|
||||||
|
_copy_run_format(run, template_paragraph)
|
||||||
|
|
||||||
|
|
||||||
|
def _resize_table_rows(table: Table, row_count: int):
|
||||||
|
current_rows = len(table.rows)
|
||||||
|
if current_rows == 0:
|
||||||
|
return
|
||||||
|
if current_rows < row_count:
|
||||||
|
template_row = table.rows[-1]._tr
|
||||||
|
for _ in range(row_count - current_rows):
|
||||||
|
table._tbl.append(deepcopy(template_row))
|
||||||
|
elif current_rows > row_count:
|
||||||
|
for _ in range(current_rows - row_count):
|
||||||
|
table._tbl.remove(table.rows[-1]._tr)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_table(table: Table, matrix: list[list[str]]):
|
||||||
|
if not matrix:
|
||||||
|
return
|
||||||
|
_resize_table_rows(table, len(matrix))
|
||||||
|
template_cell_paragraph = table.rows[0].cells[0].paragraphs[0] if table.rows and table.rows[0].cells else None
|
||||||
|
for row_index, row_values in enumerate(matrix):
|
||||||
|
row = table.rows[row_index]
|
||||||
|
for col_index, cell in enumerate(row.cells):
|
||||||
|
value = row_values[col_index] if col_index < len(row_values) else ""
|
||||||
|
_set_cell_text_with_template(cell, value, template_cell_paragraph)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_table_after(
|
||||||
|
paragraph: Paragraph,
|
||||||
|
rows: list[list[str]],
|
||||||
|
headers: list[str] | None = None,
|
||||||
|
template_table: Table | None = None,
|
||||||
|
):
|
||||||
|
matrix = [headers, *rows] if headers else rows
|
||||||
|
if template_table is not None:
|
||||||
|
cloned_tbl = deepcopy(template_table._tbl)
|
||||||
|
paragraph._element.addnext(cloned_tbl)
|
||||||
|
cloned_table = Table(cloned_tbl, paragraph._parent)
|
||||||
|
_fill_table(cloned_table, matrix)
|
||||||
|
return cloned_table
|
||||||
|
|
||||||
|
container = paragraph._parent
|
||||||
|
table = container.add_table(rows=max(len(matrix), 1), cols=max(len(headers or []), len(rows[0]) if rows else 1))
|
||||||
|
if headers:
|
||||||
|
for row_index, row_values in enumerate(matrix):
|
||||||
|
for index, value in enumerate(row_values):
|
||||||
|
table.rows[row_index].cells[index].text = value
|
||||||
|
elif rows:
|
||||||
|
for row_index, row_values in enumerate(matrix):
|
||||||
|
for index, value in enumerate(row_values):
|
||||||
|
table.rows[row_index].cells[index].text = value
|
||||||
|
|
||||||
|
tbl = table._tbl
|
||||||
|
tbl.getparent().remove(tbl)
|
||||||
|
paragraph._element.addnext(tbl)
|
||||||
|
return Table(tbl, container)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_empty_paragraph_after_table(table: Table, style_name: str | None = None) -> Paragraph:
|
||||||
|
new_p = OxmlElement("w:p")
|
||||||
|
table._tbl.addnext(new_p)
|
||||||
|
new_para = Paragraph(new_p, table._parent)
|
||||||
|
if style_name:
|
||||||
|
try:
|
||||||
|
new_para.style = style_name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return new_para
|
||||||
|
|
||||||
|
|
||||||
|
def _find_heading_paragraph(document: DocumentObject, heading_text: str, after_element=None) -> Paragraph | None:
|
||||||
|
started = after_element is None
|
||||||
|
for block in _iter_block_items(document):
|
||||||
|
if isinstance(block, Paragraph) and _is_heading(block) and block.text.strip() == heading_text.strip():
|
||||||
|
if started:
|
||||||
|
return block
|
||||||
|
if after_element is not None and block._element == after_element:
|
||||||
|
started = True
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict, after_element=None):
|
||||||
|
heading = _find_heading_paragraph(document, heading_title, after_element)
|
||||||
|
if heading is None:
|
||||||
|
return after_element
|
||||||
|
|
||||||
|
first_body_style = None
|
||||||
|
paragraph_template = None
|
||||||
|
table_template = None
|
||||||
|
current = heading._element.getnext()
|
||||||
|
blocks_to_remove = []
|
||||||
|
while current is not None:
|
||||||
|
if isinstance(current, CT_P):
|
||||||
|
current_paragraph = Paragraph(current, heading._parent)
|
||||||
|
if _is_heading(current_paragraph):
|
||||||
|
break
|
||||||
|
if first_body_style is None and current_paragraph.style is not None:
|
||||||
|
first_body_style = current_paragraph.style.name
|
||||||
|
if paragraph_template is None:
|
||||||
|
paragraph_template = current_paragraph
|
||||||
|
blocks_to_remove.append(current_paragraph)
|
||||||
|
elif isinstance(current, CT_Tbl):
|
||||||
|
current_table = Table(current, heading._parent)
|
||||||
|
if table_template is None:
|
||||||
|
table_template = current_table
|
||||||
|
blocks_to_remove.append(current_table)
|
||||||
|
current = current.getnext()
|
||||||
|
|
||||||
|
for block in blocks_to_remove:
|
||||||
|
_delete_block(block)
|
||||||
|
|
||||||
|
insert_after = heading
|
||||||
|
content_blocks = content.get("content", [])
|
||||||
|
for block in content_blocks:
|
||||||
|
block_type = block.get("type")
|
||||||
|
if block_type == "table":
|
||||||
|
rows = [list(row) for row in block.get("rows", [])]
|
||||||
|
headers = block.get("headers") or []
|
||||||
|
table = _append_table_after(insert_after, rows, headers, table_template)
|
||||||
|
insert_after = _append_empty_paragraph_after_table(table, first_body_style)
|
||||||
|
else:
|
||||||
|
text = block.get("text", "")
|
||||||
|
text_parts = [item for item in text.split("\n") if item] or [text]
|
||||||
|
for text_part in text_parts:
|
||||||
|
insert_after = _append_paragraph_after(
|
||||||
|
insert_after,
|
||||||
|
text_part,
|
||||||
|
first_body_style,
|
||||||
|
paragraph_template,
|
||||||
|
)
|
||||||
|
return heading._element
|
||||||
|
|
||||||
|
|
||||||
|
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
||||||
|
document = Document(BytesIO(template_bytes))
|
||||||
|
last_heading_element = None
|
||||||
|
for item in logs:
|
||||||
|
last_heading_element = _replace_section_content(
|
||||||
|
document,
|
||||||
|
item["title"],
|
||||||
|
item["content"],
|
||||||
|
last_heading_element,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = BytesIO()
|
||||||
|
document.save(output)
|
||||||
|
return output.getvalue()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
from services.minio_client import download_object_bytes, split_bucket_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
PdfReader = None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_text(content: bytes) -> str:
|
||||||
|
for encoding in ("utf-8", "utf-8-sig", "gbk", "gb18030"):
|
||||||
|
try:
|
||||||
|
return content.decode(encoding)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return content.decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_docx(content: bytes) -> str:
|
||||||
|
doc = Document(io.BytesIO(content))
|
||||||
|
texts = [paragraph.text.strip() for paragraph in doc.paragraphs if paragraph.text.strip()]
|
||||||
|
return "\n".join(texts[:40])[:4000]
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_csv(content: bytes) -> str:
|
||||||
|
text = _decode_text(content)
|
||||||
|
reader = csv.reader(io.StringIO(text))
|
||||||
|
rows = list(reader[:20])
|
||||||
|
return "\n".join([" | ".join(row) for row in rows])[:4000]
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_excel(content: bytes, suffix: str) -> str:
|
||||||
|
excel_buffer = io.BytesIO(content)
|
||||||
|
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)
|
||||||
|
parts.append(f"[工作表] {sheet_name}")
|
||||||
|
parts.append(preview.to_csv(index=False).strip())
|
||||||
|
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 正文。"
|
||||||
|
reader = PdfReader(io.BytesIO(content))
|
||||||
|
texts: list[str] = []
|
||||||
|
for page in reader.pages[:10]:
|
||||||
|
texts.append((page.extract_text() or "").strip())
|
||||||
|
return "\n".join(filter(None, texts))[:4000]
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_file_bytes(file_name: str, content: bytes) -> str:
|
||||||
|
suffix = Path(file_name).suffix.lower()
|
||||||
|
if suffix in {".txt", ".md", ".json"}:
|
||||||
|
return _decode_text(content)[:4000]
|
||||||
|
if suffix == ".csv":
|
||||||
|
return _summarize_csv(content)
|
||||||
|
if suffix in {".xlsx", ".xls", ".xlsm"}:
|
||||||
|
return _summarize_excel(content, suffix)
|
||||||
|
if suffix == ".docx":
|
||||||
|
return _summarize_docx(content)
|
||||||
|
if suffix == ".doc":
|
||||||
|
return _summarize_doc(content)
|
||||||
|
if suffix == ".pdf":
|
||||||
|
return _summarize_pdf(content)
|
||||||
|
return f"暂不支持解析该文件内容:{file_name}"
|
||||||
|
|
||||||
|
|
||||||
|
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 = (file_name_mapping or {}).get(file_path) or Path(object_name).name
|
||||||
|
summaries.append(
|
||||||
|
{
|
||||||
|
"file_name": file_name,
|
||||||
|
"file_path": file_path,
|
||||||
|
"summary": summarize_file_bytes(file_name, content),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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
|
||||||
|
from services.file_summary import summarize_minio_files
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
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:
|
||||||
|
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, file_summaries)
|
||||||
|
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)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import io
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from minio import Minio
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
# MinIO 客户端
|
||||||
|
minio_client = Minio(
|
||||||
|
settings.MINIO_ENDPOINT,
|
||||||
|
access_key=settings.MINIO_ACCESS_KEY,
|
||||||
|
secret_key=settings.MINIO_SECRET_KEY,
|
||||||
|
secure=settings.MINIO_USE_SSL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_buckets():
|
||||||
|
"""初始化 MinIO 存储桶(应用启动时调用)"""
|
||||||
|
buckets = [
|
||||||
|
settings.MINIO_BUCKET_TEMPLATES, # 原始模板文件
|
||||||
|
settings.MINIO_BUCKET_UPLOADS, # 用户上传的参考文件
|
||||||
|
settings.MINIO_BUCKET_OUTPUTS, # 生成的文档
|
||||||
|
]
|
||||||
|
for bucket in buckets:
|
||||||
|
if not minio_client.bucket_exists(bucket):
|
||||||
|
minio_client.make_bucket(bucket)
|
||||||
|
print(f"[MinIO] 创建存储桶: {bucket}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_url(bucket: str, object_name: str) -> str:
|
||||||
|
"""获取文件的公开访问 URL"""
|
||||||
|
if settings.MINIO_USE_SSL:
|
||||||
|
protocol = "https"
|
||||||
|
else:
|
||||||
|
protocol = "http"
|
||||||
|
return f"{protocol}://{settings.MINIO_ENDPOINT}/{bucket}/{object_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_presigned_url(bucket: str, object_name: str, expires: int = 3600) -> str:
|
||||||
|
"""获取预签名下载 URL(带过期时间)"""
|
||||||
|
return minio_client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=expires))
|
||||||
|
|
||||||
|
|
||||||
|
def split_bucket_path(file_path: str) -> tuple[str, str]:
|
||||||
|
if "/" not in file_path:
|
||||||
|
raise ValueError("非法的 MinIO 文件路径")
|
||||||
|
return file_path.split("/", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def download_object_bytes(bucket: str, object_name: str) -> bytes:
|
||||||
|
response = minio_client.get_object(bucket, object_name)
|
||||||
|
try:
|
||||||
|
return response.read()
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
response.release_conn()
|
||||||
|
|
||||||
|
|
||||||
|
def upload_bytes(
|
||||||
|
bucket: str,
|
||||||
|
object_name: str,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
):
|
||||||
|
minio_client.put_object(
|
||||||
|
bucket,
|
||||||
|
object_name,
|
||||||
|
io.BytesIO(content),
|
||||||
|
len(content),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_object(bucket: str, object_name: str):
|
||||||
|
minio_client.remove_object(bucket, object_name)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fernet() -> Fernet:
|
||||||
|
raw_key = settings.ENCRYPTION_KEY.encode("utf-8")
|
||||||
|
digest = hashlib.sha256(raw_key).digest()
|
||||||
|
return Fernet(base64.urlsafe_b64encode(digest))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_text(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
return _build_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_text(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return _build_fernet().decrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
except InvalidToken:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def mask_secret(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if len(value) <= 7:
|
||||||
|
return "*" * len(value)
|
||||||
|
return f"{value[:3]}****{value[-4:]}"
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import json
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.document import Document as DocumentObject
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.oxml.table import CT_Tbl
|
||||||
|
from docx.oxml.text.paragraph import CT_P
|
||||||
|
from docx.table import Table
|
||||||
|
from docx.text.paragraph import Paragraph
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedParagraph:
|
||||||
|
sort_index: int
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
style_json: str
|
||||||
|
is_table: bool
|
||||||
|
table_json: str
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
|
||||||
|
body = document.element.body
|
||||||
|
for child in body.iterchildren():
|
||||||
|
if isinstance(child, CT_P):
|
||||||
|
yield Paragraph(child, document)
|
||||||
|
elif isinstance(child, CT_Tbl):
|
||||||
|
yield Table(child, document)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_pt(value: object) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(value.pt), 2)
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_indent(value: object) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(value.pt), 2)
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _alignment_name(value: WD_ALIGN_PARAGRAPH | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "LEFT"
|
||||||
|
return getattr(value, "name", "LEFT")
|
||||||
|
|
||||||
|
|
||||||
|
def _heading_level(style_name: str) -> int | None:
|
||||||
|
if not style_name:
|
||||||
|
return None
|
||||||
|
normalized = style_name.lower().replace(" ", "")
|
||||||
|
if normalized.startswith("heading"):
|
||||||
|
level = normalized.replace("heading", "")
|
||||||
|
if level.isdigit():
|
||||||
|
return int(level)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_run_font_info(paragraph: Paragraph) -> dict:
|
||||||
|
for run in paragraph.runs:
|
||||||
|
if not run.text.strip():
|
||||||
|
continue
|
||||||
|
r_fonts = getattr(run._element.rPr, "rFonts", None) if run._element.rPr is not None else None
|
||||||
|
east_asia = r_fonts.get(qn("w:eastAsia")) if r_fonts is not None else None
|
||||||
|
color = None
|
||||||
|
if run.font.color is not None and run.font.color.rgb is not None:
|
||||||
|
color = str(run.font.color.rgb)
|
||||||
|
return {
|
||||||
|
"name": run.font.name,
|
||||||
|
"eastAsia": east_asia,
|
||||||
|
"size": _safe_pt(run.font.size),
|
||||||
|
"bold": bool(run.bold) if run.bold is not None else False,
|
||||||
|
"italic": bool(run.italic) if run.italic is not None else False,
|
||||||
|
"color": color or "000000",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": None,
|
||||||
|
"eastAsia": None,
|
||||||
|
"size": None,
|
||||||
|
"bold": False,
|
||||||
|
"italic": False,
|
||||||
|
"color": "000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_paragraph_style(paragraph: Paragraph, level: int) -> dict:
|
||||||
|
fmt = paragraph.paragraph_format
|
||||||
|
return {
|
||||||
|
"font": _get_run_font_info(paragraph),
|
||||||
|
"paragraph": {
|
||||||
|
"alignment": _alignment_name(paragraph.alignment),
|
||||||
|
"spaceBefore": _safe_pt(fmt.space_before),
|
||||||
|
"spaceAfter": _safe_pt(fmt.space_after),
|
||||||
|
"lineSpacing": fmt.line_spacing,
|
||||||
|
"firstLineIndent": _safe_indent(fmt.first_line_indent),
|
||||||
|
},
|
||||||
|
"headingLevel": level,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cell_style(cell) -> dict:
|
||||||
|
paragraph = cell.paragraphs[0] if cell.paragraphs else None
|
||||||
|
font_info = _get_run_font_info(paragraph) if paragraph is not None else {
|
||||||
|
"name": None,
|
||||||
|
"eastAsia": None,
|
||||||
|
"size": None,
|
||||||
|
"bold": False,
|
||||||
|
"italic": False,
|
||||||
|
"color": "000000",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"font": font_info,
|
||||||
|
"shading": None,
|
||||||
|
"alignment": _alignment_name(paragraph.alignment) if paragraph is not None else "LEFT",
|
||||||
|
"borders": {"top": None, "bottom": None, "left": None, "right": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_table_data(table: Table) -> dict:
|
||||||
|
rows = len(table.rows)
|
||||||
|
cols = max((len(row.cells) for row in table.rows), default=0)
|
||||||
|
grid_span: dict[str, int] = {}
|
||||||
|
cell_styles: list[dict] = []
|
||||||
|
matrix: list[list[str]] = []
|
||||||
|
|
||||||
|
for row_index, row in enumerate(table.rows):
|
||||||
|
row_values: list[str] = []
|
||||||
|
for col_index, cell in enumerate(row.cells):
|
||||||
|
text = "\n".join(paragraph.text.strip() for paragraph in cell.paragraphs if paragraph.text.strip())
|
||||||
|
row_values.append(text)
|
||||||
|
tc_pr = cell._tc.tcPr
|
||||||
|
grid_span_value = None
|
||||||
|
if tc_pr is not None and tc_pr.gridSpan is not None:
|
||||||
|
grid_span_value = tc_pr.gridSpan.val
|
||||||
|
if grid_span_value:
|
||||||
|
grid_span[f"{row_index}-{col_index}"] = int(grid_span_value)
|
||||||
|
cell_styles.append(_get_cell_style(cell))
|
||||||
|
matrix.append(row_values)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"cols": cols,
|
||||||
|
"gridSpan": grid_span,
|
||||||
|
"cellStyles": cell_styles,
|
||||||
|
"tableWidth": None,
|
||||||
|
"data": matrix,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_template(file_path: str) -> list[ParsedParagraph]:
|
||||||
|
document = Document(file_path)
|
||||||
|
parsed: list[ParsedParagraph] = []
|
||||||
|
current_item: ParsedParagraph | None = None
|
||||||
|
loose_table_count = 0
|
||||||
|
|
||||||
|
for block in _iter_block_items(document):
|
||||||
|
if isinstance(block, Paragraph):
|
||||||
|
text = block.text.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
level = _heading_level(block.style.name if block.style is not None else "")
|
||||||
|
if level is not None:
|
||||||
|
current_item = ParsedParagraph(
|
||||||
|
sort_index=len(parsed) + 1,
|
||||||
|
title=text,
|
||||||
|
content="",
|
||||||
|
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
|
||||||
|
is_table=False,
|
||||||
|
table_json="{}",
|
||||||
|
)
|
||||||
|
parsed.append(current_item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_item is None:
|
||||||
|
current_item = ParsedParagraph(
|
||||||
|
sort_index=len(parsed) + 1,
|
||||||
|
title="未命名段落",
|
||||||
|
content=text,
|
||||||
|
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
|
||||||
|
is_table=False,
|
||||||
|
table_json="{}",
|
||||||
|
)
|
||||||
|
parsed.append(current_item)
|
||||||
|
else:
|
||||||
|
current_item.content = "\n".join(filter(None, [current_item.content, text]))
|
||||||
|
else:
|
||||||
|
table_data = _extract_table_data(block)
|
||||||
|
table_text = f"[表格] {table_data['rows']} 行 {table_data['cols']} 列"
|
||||||
|
if current_item is None:
|
||||||
|
loose_table_count += 1
|
||||||
|
current_item = ParsedParagraph(
|
||||||
|
sort_index=len(parsed) + 1,
|
||||||
|
title=f"表格_{loose_table_count}",
|
||||||
|
content=table_text,
|
||||||
|
style_json="{}",
|
||||||
|
is_table=True,
|
||||||
|
table_json=json.dumps(table_data, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
parsed.append(current_item)
|
||||||
|
else:
|
||||||
|
current_item.is_table = True
|
||||||
|
current_item.table_json = json.dumps(table_data, ensure_ascii=False)
|
||||||
|
current_item.content = "\n".join(filter(None, [current_item.content, table_text]))
|
||||||
|
|
||||||
|
return parsed
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
# === MySQL ===
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: doc-forge-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root123
|
||||||
|
MYSQL_DATABASE: doc_forge
|
||||||
|
MYSQL_USER: docforge
|
||||||
|
MYSQL_PASSWORD: docforge123
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
|
|
||||||
|
# === MinIO(对象存储)===
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: doc-forge-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: docforge
|
||||||
|
MINIO_ROOT_PASSWORD: docforge123
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # API
|
||||||
|
- "9001:9001" # Console
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# === 后端 ===
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: doc-forge-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_started
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_HOST: mysql
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_USER: docforge
|
||||||
|
DB_PASSWORD: docforge123
|
||||||
|
DB_NAME: doc_forge
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
MINIO_ACCESS_KEY: docforge
|
||||||
|
MINIO_SECRET_KEY: docforge123
|
||||||
|
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- local_cache:/app/local_cache
|
||||||
|
|
||||||
|
# === 前端 ===
|
||||||
|
web:
|
||||||
|
build: ./web
|
||||||
|
container_name: doc-forge-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "5173:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
minio_data:
|
||||||
|
local_cache:
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# 任务执行摘要
|
||||||
|
|
||||||
|
## 会话 ID: local-20260702145410
|
||||||
|
- [2026-07-02 14:54:10]
|
||||||
|
- **执行原因**: 按任务拆解清单逐项推进,优先打通模板管理与模板解析的第一条可用链路,并同步回填已完成状态。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 核对任务拆解清单、开发规范和模板格式规范,确认项目约束与当前仓库状态。
|
||||||
|
2. 检查前后端现状,识别出前端脚手架、数据库模型和多页面壳子已存在,但后端核心路由仍为空。
|
||||||
|
3. 新增模板解析服务,基于 `python-docx` 实现标题识别、正文归并、样式提取、表格基础结构提取和结构化输出。
|
||||||
|
4. 实现模板 CRUD 路由,补齐模板列表、详情、上传解析、段落配置保存和删除能力,并接入 MinIO 模板存储。
|
||||||
|
5. 为后端补充统一错误响应格式与空路由占位,避免应用启动时报错。
|
||||||
|
6. 修复前端若干现存类型/图标问题,确保 `vue-tsc --noEmit` 可通过。
|
||||||
|
7. 更新任务拆解清单,标记本轮已确认完成的阶段项与子任务。
|
||||||
|
- **执行结果**: 已完成模板 CRUD 路由和模板解析器基础能力,项目当前可通过后端语法检查与前端类型检查,任务清单已同步标注已完成项。
|
||||||
|
|
||||||
|
## 会话 ID: local-20260702145958
|
||||||
|
- [2026-07-02 14:59:58]
|
||||||
|
- **执行原因**: 用户要求先提交当前代码,并继续完善到可以初步使用的程度。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 将首批模板解析与模板管理相关改动整理后提交,提交信息使用中文。
|
||||||
|
2. 新增模型管理后端接口,支持模型列表、创建、更新、删除、状态切换与本地模拟测试。
|
||||||
|
3. 新增加密工具,按项目要求对 API Key 做 Fernet 形式加密存储并提供脱敏展示。
|
||||||
|
4. 新增生成与记录接口,支持单段测试、整份文档模拟生成、历史列表、详情预览、取消与删除。
|
||||||
|
5. 将预览页接入真实生成记录数据,将模板编辑页补齐“保存配置”和“立即测试”能力,并让历史页显示动态统计。
|
||||||
|
6. 更新任务拆解清单,补记“模型 CRUD 路由”已完成。
|
||||||
|
7. 再次执行后端语法检查与前端类型检查,确保本轮改动可用。
|
||||||
|
- **执行结果**: 当前系统已可初步走通“上传模板 → 配置段落 → 管理模型 → 触发生成 → 查看记录/预览”的联调链路,导出仍为占位提示实现。
|
||||||
|
|
||||||
|
## 会话 ID: local-20260702150745
|
||||||
|
- [2026-07-02 15:07:45]
|
||||||
|
- **执行原因**: 用户反馈项目已运行成功,要求将运行方式补充进 README,并继续完成后续任务。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 重写 README,补充本地开发启动方式、Docker 启动方式、初步使用流程、常见问题与 `.idea` 误提交处理方案。
|
||||||
|
2. 扩展 MinIO 工具方法,补充对象路径拆分、字节下载、字节上传与预签名 URL 生成能力。
|
||||||
|
3. 新增 Word 导出服务,基于原始模板按标题定位段落并替换生成结果,支持基础文本与表格内容导出。
|
||||||
|
4. 更新导出路由,使“导出 Word”能够生成文件、上传到 MinIO 输出桶并返回可下载链接。
|
||||||
|
5. 再次执行后端语法检查与前端类型检查。
|
||||||
|
6. 同步更新任务拆解清单,标记当前前端页面中已实际可用的子项。
|
||||||
|
- **执行结果**: README 已补全运行说明,系统新增基础 Word 导出能力,当前可以从预览页直接导出可下载的 Word 文件。
|
||||||
|
|
||||||
|
## 会话 ID: local-20260702151621
|
||||||
|
- [2026-07-02 15:16:21]
|
||||||
|
- **执行原因**: 用户要求继续完善系统能力,并提交当前进展。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 新增统一 AI 调用服务,支持 OpenAI 兼容接口与 Anthropic 接口两类模型调用。
|
||||||
|
2. 为 AI 调用补充 JSON 解析兜底、超时控制、重试机制与异常回退逻辑。
|
||||||
|
3. 将模型管理中的“连接测试”接入真实后端调用,并在前端增加测试与删除操作入口。
|
||||||
|
4. 将整份文档生成流程接入真实模型调用;当模型不可用或调用失败时,自动回退为模拟结果并记录失败状态。
|
||||||
|
5. 新增参考文件上传接口,将执行生成页的附件上传接入 MinIO,并把文件路径带入生成请求。
|
||||||
|
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. 执行前端类型检查,确认本轮页面重构稳定。
|
||||||
|
- **执行结果**: 现有三大主页面已开始按原型稿收口,整体信息层级和布局结构明显向原型靠齐,同时保留了当前已完成的真实业务能力。
|
||||||
|
|
||||||
|
## 会话 ID: local-20260702161641
|
||||||
|
- [2026-07-02 16:16:41]
|
||||||
|
- **执行原因**: 用户指出模板编辑页尚未对齐原型编辑态,且“立即测试”未弹出文件选择并完成多文件测试链路。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 重新核对模板管理原型中的三栏编辑态和段落测试三步弹窗交互。
|
||||||
|
2. 新增文件摘要服务,支持从 MinIO 下载并解析多种参考文件内容,包括 txt、md、csv、xlsx、xls、docx、pdf。
|
||||||
|
3. 调整 AI 调用服务,在请求模型时拼接“模板上下文 + 段落提示词 + 文件摘要”作为输入。
|
||||||
|
4. 更新段落测试接口,支持接收多文件路径、解析文件内容并把解析摘要返回前端展示。
|
||||||
|
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. 同步更新模板测试弹窗中的上传提示文案,并执行后端语法检查与前端类型检查。
|
||||||
|
- **执行结果**: 当前参考文件上传支持范围更完整,遇到不支持的文件类型时也会直接显示具体后缀和支持列表,便于快速判断问题。
|
||||||
|
|
||||||
|
## 会话 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 序列化失败。
|
||||||
|
|
||||||
|
## 会话 ID: local-20260702182751
|
||||||
|
- [2026-07-02 18:27:51]
|
||||||
|
- **执行原因**: 用户反馈导出的模板与原模板几乎一致,AI 结果没有正确写入,同时模板样式在导出后发生明显偏移。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 检查导出链路,确认原实现采用“删除原内容后新建普通段落/表格”的方式回写,容易导致标题命中不稳和样式丢失。
|
||||||
|
2. 重构导出服务的段落替换逻辑,改为按标题顺序匹配模板中的章节,减少重复标题导致的误替换风险。
|
||||||
|
3. 调整导出时的文本回写方式,优先复用模板原有段落的段落属性与首个 run 的字体样式,再写入 AI 返回文本。
|
||||||
|
4. 调整导出时的表格回写方式,优先克隆模板原有表格结构并填充新数据,尽量保留表格外观和基础样式。
|
||||||
|
5. 执行后端语法检查,确认导出服务改动稳定。
|
||||||
|
- **执行结果**: 当前导出链路已改为“尽量复用模板原始段落/表格样式后写入 AI 内容”,比此前的新建空白内容块方式更接近原模板样式,也更容易把 AI 结果正确写回文档。
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# AI 提示词库
|
||||||
|
|
||||||
|
本文档包含所有 AI 调用时使用的提示词模板。提示词分为系统级和段落级两层。
|
||||||
|
|
||||||
|
## 一、系统提示词
|
||||||
|
|
||||||
|
### 1.1 默认系统提示词(通用)
|
||||||
|
|
||||||
|
```
|
||||||
|
你是一个专业的企业文档撰写助手。你的任务是按照给定的段落标题和参考内容,
|
||||||
|
生成符合中文正式报告风格的段落内容。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 语言正式、客观、严谨,使用第三人称
|
||||||
|
2. 逻辑清晰,层次分明
|
||||||
|
3. 数据准确,引用上传文件中的实际数据
|
||||||
|
4. 字数控制在 300-800 字之间
|
||||||
|
5. 不要输出标题本身,只输出段落正文内容
|
||||||
|
6. 如果正文需要分点描述,使用 1. 2. 3. 编号,不要使用无序列表
|
||||||
|
|
||||||
|
输出格式必须为 JSON:
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "正文内容..."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
如果需要输出表格,使用:
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "表格说明文字"},
|
||||||
|
{"type": "table", "headers": ["列1","列2","列3"], "rows": [["数据1","数据2","数据3"]]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 表格生成专用提示词
|
||||||
|
|
||||||
|
```
|
||||||
|
请根据上传的数据文件,生成以下表格内容:
|
||||||
|
段落标题:{title}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 表格列名清晰,数据准确
|
||||||
|
2. 只输出表格内容,不要文字说明
|
||||||
|
3. 如果有多组数据,优先合并到一张表中
|
||||||
|
|
||||||
|
输出格式:
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{"type": "table", "headers": ["列名1","列名2","..."], "rows": [["值1","值2","..."]]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 报告摘要专用提示词
|
||||||
|
|
||||||
|
```
|
||||||
|
请根据以下参考内容,生成一段简洁的摘要。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 概括核心要点,不超过 200 字
|
||||||
|
2. 突出关键数据和结论
|
||||||
|
3. 使用总分结构
|
||||||
|
|
||||||
|
输出格式:
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "摘要内容..."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、段落预设提示词
|
||||||
|
|
||||||
|
### 2.1 经营指标分析
|
||||||
|
```
|
||||||
|
请根据上传的财务数据,生成"{title}"章节内容。
|
||||||
|
要求包括以下方面:
|
||||||
|
1. 各核心指标的完成值
|
||||||
|
2. 与上期/同期的同比变化
|
||||||
|
3. 变化原因分析
|
||||||
|
4. 存在的主要风险点
|
||||||
|
|
||||||
|
参考上下文:
|
||||||
|
{context}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 成本费用分析
|
||||||
|
```
|
||||||
|
请根据上传的数据,生成成本费用分析内容。
|
||||||
|
要求包括:
|
||||||
|
1. 各项成本的构成及占比
|
||||||
|
2. 同比变化情况及原因
|
||||||
|
3. 成本管控措施及成效
|
||||||
|
|
||||||
|
参考数据:
|
||||||
|
{context}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 问题总结
|
||||||
|
```
|
||||||
|
请基于以下数据和背景,分析当前存在的主要问题和风险。
|
||||||
|
要求:
|
||||||
|
1. 问题描述要具体,有数据支撑
|
||||||
|
2. 分析问题产生的原因
|
||||||
|
3. 指出风险等级和影响范围
|
||||||
|
|
||||||
|
参考内容:
|
||||||
|
{context}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 工作措施
|
||||||
|
```
|
||||||
|
请针对上述问题,生成下一步工作措施。
|
||||||
|
要求:
|
||||||
|
1. 措施具体可执行
|
||||||
|
2. 明确责任主体
|
||||||
|
3. 设定完成时限或目标值
|
||||||
|
4. 措施之间逻辑递进
|
||||||
|
|
||||||
|
参考内容:
|
||||||
|
{context}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、提示词拼接规则
|
||||||
|
|
||||||
|
### 3.1 最终 prompt 构成
|
||||||
|
```
|
||||||
|
[系统提示词]
|
||||||
|
---
|
||||||
|
段落标题:{paragraph.title}
|
||||||
|
编辑方式:{paragraph.edit_mode}
|
||||||
|
输出格式:{paragraph.output_format}
|
||||||
|
---
|
||||||
|
{paragraph.prompt_text}
|
||||||
|
---
|
||||||
|
参考文件摘要:
|
||||||
|
{file_summary}
|
||||||
|
---
|
||||||
|
参考上下文:
|
||||||
|
{paragraph.content}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 参考文件摘要生成规则
|
||||||
|
```
|
||||||
|
读取上传的 Excel 文件:
|
||||||
|
1. 提取列名 + 前 10 行数据作为样本
|
||||||
|
2. 统计数值列的和/均值/最大最小值
|
||||||
|
3. 生成文本摘要
|
||||||
|
|
||||||
|
Excel 摘要示例:
|
||||||
|
"文件:财务数据报表.xlsx
|
||||||
|
包含 3 个工作表:
|
||||||
|
- Sheet1(使用中):列 [月份, 营业收入, 利润总额, 净利润],共 12 行数据
|
||||||
|
营业收入合计:148.2 亿元,月均 12.35 亿元
|
||||||
|
利润总额合计:15.0 亿元,月均 1.25 亿元
|
||||||
|
```
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# AI 文档模板生成系统 · 开发规范
|
||||||
|
|
||||||
|
## 一、代码规范
|
||||||
|
|
||||||
|
### 1.1 Python 后端
|
||||||
|
- Python 3.11+,使用类型注解
|
||||||
|
- 文件命名:snake_case.py
|
||||||
|
- 类命名:PascalCase
|
||||||
|
- 函数/变量:snake_case
|
||||||
|
- 数据库表:小写复数(templates, paragraphs)
|
||||||
|
- 异步优先:async/await 贯穿全栈
|
||||||
|
|
||||||
|
### 1.2 TypeScript 前端
|
||||||
|
- TypeScript 5.x,strict 模式
|
||||||
|
- 文件命名:PascalCase.vue(组件),camelCase.ts(工具/API)
|
||||||
|
- 组件命名:多单词 PascalCase
|
||||||
|
- 变量/函数:camelCase
|
||||||
|
- 接口命名:I 开头或 PascalCase
|
||||||
|
- 使用 `<script setup lang="ts">` 组合式 API
|
||||||
|
- 禁止使用 `any`
|
||||||
|
|
||||||
|
### 1.3 API 规范
|
||||||
|
- 基础路径:`/api/v1`
|
||||||
|
- RESTful 风格
|
||||||
|
- 请求体:JSON
|
||||||
|
- 响应格式:`{ code: 0, data: {...}, message: "ok" }`
|
||||||
|
- 分页:`{ code: 0, data: { items: [], total: N, page: 1, page_size: 20 } }`
|
||||||
|
|
||||||
|
## 二、数据库规范
|
||||||
|
|
||||||
|
### 2.1 命名
|
||||||
|
- 表名:小写复数
|
||||||
|
- 主键:id (INTEGER PRIMARY KEY AUTOINCREMENT)
|
||||||
|
- 时间戳:created_at, updated_at (DATETIME)
|
||||||
|
- 外键:{table}_id (INTEGER, REFERENCES {table}(id))
|
||||||
|
|
||||||
|
### 2.2 约束
|
||||||
|
- 所有表必须有 created_at 和 updated_at
|
||||||
|
- 软删除不实现,用 DELETE 物理删除
|
||||||
|
- 开发环境用 SQLite,生产环境可切换 PostgreSQL
|
||||||
|
|
||||||
|
## 三、安全规范
|
||||||
|
|
||||||
|
### 3.1 API Key 加密
|
||||||
|
- 使用 cryptography.fernet.Fernet 加密
|
||||||
|
- 加密密钥从环境变量 ENCRYPTION_KEY 读取
|
||||||
|
- 数据库仅存储密文
|
||||||
|
- 前端展示脱敏:前 3 位 + **** + 后 4 位
|
||||||
|
|
||||||
|
### 3.2 文件上传
|
||||||
|
- 允许类型:.docx, .xlsx, .xls, .csv, .pdf, .txt, .md
|
||||||
|
- 大小限制:单文件 ≤ 50MB
|
||||||
|
- 存储路径:uploads/{YYYYMMDD}/{uuid}.{ext}
|
||||||
|
- MIME type + 扩展名双重校验
|
||||||
|
|
||||||
|
## 四、AI 模型调用规范
|
||||||
|
|
||||||
|
### 4.1 超时
|
||||||
|
- 单次 AI 请求超时:60 秒
|
||||||
|
- 重试策略:最多 3 次,指数退避(2s → 4s → 8s)
|
||||||
|
|
||||||
|
### 4.2 并发
|
||||||
|
- 单文档最大并发:5 个段落同时请求
|
||||||
|
- 全局最大并发:10 个段落同时请求(跨文档)
|
||||||
|
- 使用 asyncio.Semaphore 控制
|
||||||
|
|
||||||
|
### 4.3 错误处理
|
||||||
|
- 400 错误:重试
|
||||||
|
- 401/403 错误:标记模型不可用,停止生成
|
||||||
|
- 429 错误:等待 30 秒后重试
|
||||||
|
- 500 错误:重试,3 次后标记段落失败
|
||||||
|
- 超时错误:重试,3 次后标记段落失败
|
||||||
|
|
||||||
|
## 五、Word 导出规范
|
||||||
|
|
||||||
|
### 5.1 导出流程
|
||||||
|
1. 复制原始模板文件(作为样式骨架)
|
||||||
|
2. 获取 template 的 file_path
|
||||||
|
3. 用 python-docx 打开副本
|
||||||
|
4. 遍历段落 → 找到对应位置 → 替换内容
|
||||||
|
5. 保存为新文件
|
||||||
|
|
||||||
|
### 5.2 段落定位策略
|
||||||
|
按优先级:
|
||||||
|
1. 段落 ID 精确匹配(解析时记录的 paragraph_id)
|
||||||
|
2. 段落标题文本完全匹配
|
||||||
|
3. 段落序号匹配(一、二、三 / 1.1 / 1.2)
|
||||||
|
4. 段落索引匹配(第 N 个位置)
|
||||||
|
|
||||||
|
### 5.3 表格处理
|
||||||
|
- 导出时保留原模板中的表格占位(空的带样式表格或占位标记)
|
||||||
|
- 用 python-docx 找到表格节点,逐格填充数据
|
||||||
|
- 如需新增表格,在段落最后插入
|
||||||
|
|
||||||
|
## 六、前端组件规范
|
||||||
|
|
||||||
|
### 6.1 Ant Design Vue 使用规范
|
||||||
|
- 使用 composition API + `<script setup>`
|
||||||
|
- 组件样式使用 `<style scoped>`
|
||||||
|
- 全局覆盖 Ant Design 主题色在 App.vue 中通过 ConfigProvider 设置
|
||||||
|
|
||||||
|
### 6.2 状态管理
|
||||||
|
- 使用 Pinia,每个模块独立 store
|
||||||
|
- API 请求在 store 的 action 中调用,组件只 dispatch action
|
||||||
|
- 加载状态由 store 内部的 loading 字段管理
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# AI 文档模板生成系统 · 项目概述
|
||||||
|
|
||||||
|
## 一句话
|
||||||
|
上传 Word 模板 → AI 自动解析段落结构 → 配置各段落生成规则 → 上传参考文件 → AI 并行生成 → 在线预览编辑 → 导出保留原始样式的 Word 文档。
|
||||||
|
|
||||||
|
## 核心架构
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 前端 Vue 3 + Ant Design Vue │
|
||||||
|
│ 模板管理 模型管理 执行生成 生成记录 预览编辑 │
|
||||||
|
└────────────┬───────────────────────────┬────────────┘
|
||||||
|
│ REST API + SSE 进度 │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 后端 FastAPI + SQLAlchemy │
|
||||||
|
│ 模板解析 → 段落管理 → AI 调度 → 文档生成 → 导出 │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心流程(7步闭环)
|
||||||
|
1. **上传模板** — 上传 .docx → 解析段落结构 + 完整样式 → 存库
|
||||||
|
2. **段落标注** — 三栏编辑器:左栏点段落→中栏定位→右栏配置(编辑方式/模型/提示词/参考文件)
|
||||||
|
3. **保存模板** — 段落配置持久化
|
||||||
|
4. **执行生成** — 选模板 → 按需上传参考文件 → 启动生成
|
||||||
|
5. **并行生成** — 无依赖段落同时请求 AI,SSE 实时推进度
|
||||||
|
6. **预览编辑** — 富文本编辑器查看结果,手动精修
|
||||||
|
7. **导出 Word** — 基于原模板替换内容,样式零损失
|
||||||
|
|
||||||
|
## 关键设计决策
|
||||||
|
| 决策 | 选择 | 原因 |
|
||||||
|
|------|------|------|
|
||||||
|
| 段落边界 | Word 内置标题样式 (Heading 1~6) | 最稳定,用户学习成本低 |
|
||||||
|
| 输出格式 | AI 返回结构化 JSON | 支持文字+表格混合,后端可控解析 |
|
||||||
|
| 导出策略 | 基于原模板替换内容 | 样式零损失,不限模板格式 |
|
||||||
|
| 生成方式 | 多段落并行请求 AI | 大幅缩短等待时间 |
|
||||||
|
| 样式保留 | 导出时读取原模板段落样式映射 | 精度最高 |
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
- **前端**: Vue 3.4 + Vite 5 + TypeScript + Ant Design Vue 4.x + Pinia + Axios
|
||||||
|
- **后端**: Python 3.11+ + FastAPI + SQLAlchemy 2.0 (async) + SQLite + python-docx + openpyxl + httpx
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# AI 文档模板生成系统 · 需求规格说明书
|
||||||
|
|
||||||
|
## 一、功能需求
|
||||||
|
|
||||||
|
### 1.1 模板管理
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 模板列表 | 卡片式展示所有模板,含名称、段落数、状态、最后编辑时间 | P0 |
|
||||||
|
| 新建模板 | 上传 .docx 文件,自动解析段落结构 | P0 |
|
||||||
|
| 模板编辑器(三栏) | 左栏段落列表、中栏文档预览、右栏段落配置 | P0 |
|
||||||
|
| 段落标注 | 配置每个段落的编辑方式、模型、提示词、参考文件 | P0 |
|
||||||
|
| 保存模板 | 将段落配置持久化到数据库 | P0 |
|
||||||
|
|
||||||
|
### 1.2 模型管理
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 模型列表 | 卡片式展示所有 AI 模型,含启用/禁用开关 | P0 |
|
||||||
|
| 添加/编辑模型 | 弹窗表单:名称、厂商、API 格式、地址、Key(加密存储) | P0 |
|
||||||
|
| 默认模型分配 | 设置文本/表格/图表各自的默认生成模型 | P0 |
|
||||||
|
|
||||||
|
### 1.3 执行生成
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 选择模板 | 下拉选择已保存的模板 | P0 |
|
||||||
|
| 上传参考文件 | 按段落需上传的文件类型提示并上传 | P0 |
|
||||||
|
| 开始生成 | 启动多段落并行 AI 生成 | P0 |
|
||||||
|
| 实时进度 | SSE 推送每个段落的生成状态 | P0 |
|
||||||
|
| 取消生成 | 中断正在进行的生成任务 | P1 |
|
||||||
|
|
||||||
|
### 1.4 生成记录
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 统计卡片 | 总次数、成功、失败、中断 | P0 |
|
||||||
|
| 历史列表 | 卡片式展示已生成文档 | P0 |
|
||||||
|
| 筛选 | 按模板、按状态筛选 | P1 |
|
||||||
|
| 预览 | 跳转到预览编辑页 | P0 |
|
||||||
|
| 下载 | 下载已生成的 Word 文档 | P0 |
|
||||||
|
|
||||||
|
### 1.5 预览编辑
|
||||||
|
| 功能 | 描述 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 富文本编辑 | 在线编辑文档内容 | P0 |
|
||||||
|
| AI 内容标注 | AI 生成的段落标紫色边框+模型来源 | P0 |
|
||||||
|
| 重新生成单段落 | 对某一段落单独重新请求 AI | P0 |
|
||||||
|
| 导出 Word | 保留原始模板样式 | P0 |
|
||||||
|
| 导出 PDF | 通过 LibreOffice 转换 | P1 |
|
||||||
|
|
||||||
|
## 二、段落解析规则
|
||||||
|
|
||||||
|
### 2.1 段落边界定义
|
||||||
|
段落以 Word 内置标题样式为边界:
|
||||||
|
|
||||||
|
```
|
||||||
|
Heading 1 → 一级段落(如 "一、经营指标")
|
||||||
|
Heading 2 → 二级段落(如 "1.1 营收分析")
|
||||||
|
Heading 3 → 三级段落
|
||||||
|
无标题样式 → 合并到上一个标题下的正文内容
|
||||||
|
表格 → 独立段落,归属于前一个标题
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 标题下的正文内容
|
||||||
|
标题与下一个标题之间的所有正文、表格、图片:
|
||||||
|
- 作为该段落的 `content` 字段
|
||||||
|
- 供 AI 生成时作为上下文参考
|
||||||
|
- 导出时保留原样式
|
||||||
|
|
||||||
|
### 2.3 AI 返回格式约定
|
||||||
|
AI 输出必须是结构化 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "正文内容..."},
|
||||||
|
{"type": "table", "headers": ["列1","列2"], "rows": [["a","b"],["c","d"]]},
|
||||||
|
{"type": "text", "text": "更多正文..."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后端解析逻辑:
|
||||||
|
- type=text → 替换文档中对应段落的文本
|
||||||
|
- type=table → 在对应位置插入 Word 表格,表格样式参照该段落附近已有表格
|
||||||
|
|
||||||
|
### 2.4 正文内编号处理
|
||||||
|
AI 生成的 1、2、3 编号属于该段落的内部子结构,不拆分为新段落。导出时作为该段落的正文内容,应用该段落的样式。
|
||||||
|
|
||||||
|
## 三、导出策略
|
||||||
|
|
||||||
|
采用 **基于原模板替换内容** 策略:
|
||||||
|
|
||||||
|
1. 解析时记录每个段落在原始 docx 中的段落索引 + xpath
|
||||||
|
2. 导出时复制原始模板文件
|
||||||
|
3. 遍历每个段落,找到对应位置替换内容:
|
||||||
|
- 纯文本:替换 `<w:t>` 节点文本
|
||||||
|
- 表格:删除原有表格占位,插入新表格的 XML 节点
|
||||||
|
4. 样式完全不修改(字体、字号、颜色、行距、段间距、页边距、页眉页脚、页码全部保留)
|
||||||
|
|
||||||
|
## 四、AI 并行生成设计
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点击"开始生成"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
解析模板段落依赖关系(当前无依赖,全部并行)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
创建 asyncio.Task 池,Semaphore 控制并发数(默认 5)
|
||||||
|
│
|
||||||
|
├── 段落1 → AI 请求 → SSE 推送完成
|
||||||
|
├── 段落2 → AI 请求 → SSE 推送完成
|
||||||
|
├── 段落3 → AI 请求 → SSE 推送完成
|
||||||
|
├── 段落4 → AI 请求 → SSE 推送完成
|
||||||
|
└── 段落5 → AI 请求 → SSE 推送完成
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
所有 Task 完成后,统一更新文档状态为 completed
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SSE 推送 "全部完成",前端跳转到预览编辑
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、数据结构
|
||||||
|
|
||||||
|
### 5.1 模板 (template)
|
||||||
|
```
|
||||||
|
id: int (PK)
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
file_path: str # 原始模板文件路径
|
||||||
|
paragraph_count: int
|
||||||
|
status: str # draft / ready
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 段落 (paragraph)
|
||||||
|
```
|
||||||
|
id: int (PK)
|
||||||
|
template_id: int (FK)
|
||||||
|
sort_index: int
|
||||||
|
title: str # 段落标题
|
||||||
|
content: str # 正文内容(供 AI 参考)
|
||||||
|
style_json: json # 完整样式定义
|
||||||
|
is_table: bool
|
||||||
|
table_json: json # 表格结构
|
||||||
|
edit_mode: str # manual / ai
|
||||||
|
model_id: int (FK, nullable)
|
||||||
|
need_prompt: bool
|
||||||
|
prompt_text: str
|
||||||
|
need_file: bool
|
||||||
|
file_note: str
|
||||||
|
output_format: str # text / table / mixed / chart
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 AI 模型 (ai_model)
|
||||||
|
```
|
||||||
|
id: int (PK)
|
||||||
|
name: str
|
||||||
|
provider: str
|
||||||
|
api_format: str # anthropic / openai
|
||||||
|
api_endpoint: str
|
||||||
|
api_key_encrypted: str
|
||||||
|
status: str # enabled / disabled
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 生成文档 (document)
|
||||||
|
```
|
||||||
|
id: int (PK)
|
||||||
|
template_id: int (FK)
|
||||||
|
name: str
|
||||||
|
para_count_done: int
|
||||||
|
para_count_total: int
|
||||||
|
status: str # pending / generating / completed / failed / cancelled
|
||||||
|
file_path: str # 生成的文档路径
|
||||||
|
error: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 模板格式规范
|
||||||
|
|
||||||
|
## 一、对用户(模板提供方)的要求
|
||||||
|
|
||||||
|
### 必须遵守
|
||||||
|
| 要求 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 标题样式 | 需要 AI 生成的段落,其标题必须应用 Word 内置标题样式(Heading 1~3) |
|
||||||
|
| 段落独立性 | 每个标题段落的内容应当主题独立,方便 AI 分别生成 |
|
||||||
|
|
||||||
|
### 强烈建议
|
||||||
|
| 建议 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 正文提供参考内容 | 标题下的现有文本可作为 AI 生成的上下文,建议保留 |
|
||||||
|
| 表格上方有说明文字 | 表格前最好有一段文字说明,方便定位表格归属 |
|
||||||
|
| 文件名中文 | 模板文件名建议用中文,方便识别 |
|
||||||
|
|
||||||
|
### 不约束
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 字体/配色/布局 | 不限,导出时完全保留 |
|
||||||
|
| 页眉页脚 | 不限,导出时完全保留 |
|
||||||
|
| 图片 | 不限,解析时保留占位,导出时保持不动 |
|
||||||
|
| 封面/附录 | 不限,不作为段落处理,导出时保留 |
|
||||||
|
|
||||||
|
## 二、解析规则(面向开发)
|
||||||
|
|
||||||
|
### 2.1 段落检测算法
|
||||||
|
```
|
||||||
|
for each paragraph in document.paragraphs:
|
||||||
|
style = paragraph.style.name
|
||||||
|
|
||||||
|
if style starts with "Heading":
|
||||||
|
→ 新段落开始
|
||||||
|
→ style 等级 = heading level (1~6)
|
||||||
|
→ 该段落为「标题段落」
|
||||||
|
elif style is "Normal" or None:
|
||||||
|
→ 属于上一个标题段落的「正文内容」
|
||||||
|
→ 追加到 paragraph.content
|
||||||
|
elif paragraph is inside a table cell:
|
||||||
|
→ 属于表格内容,跳过段落检测
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 表格归属
|
||||||
|
```
|
||||||
|
当前检测到的表格 → 归属于最近的标题段落
|
||||||
|
if 无标题段落:
|
||||||
|
→ 独立成段,段名 = "表格_{序号}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 样式捕获字段
|
||||||
|
对每个标题段落,捕获以下样式信息:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"font": {
|
||||||
|
"name": "等线",
|
||||||
|
"eastAsia": "等线",
|
||||||
|
"size": 16,
|
||||||
|
"bold": true,
|
||||||
|
"italic": false,
|
||||||
|
"color": "000000"
|
||||||
|
},
|
||||||
|
"paragraph": {
|
||||||
|
"alignment": "CENTER",
|
||||||
|
"spaceBefore": 12,
|
||||||
|
"spaceAfter": 6,
|
||||||
|
"lineSpacing": 1.5,
|
||||||
|
"firstLineIndent": 0
|
||||||
|
},
|
||||||
|
"headingLevel": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 表格样式捕获
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rows": 5,
|
||||||
|
"cols": 6,
|
||||||
|
"gridSpan": {},
|
||||||
|
"cellStyles": [
|
||||||
|
{
|
||||||
|
"font": {"name": "宋体", "size": 10.5, "bold": false},
|
||||||
|
"shading": "D9E2F3",
|
||||||
|
"alignment": "CENTER",
|
||||||
|
"borders": {"top": "single", "bottom": "single", "left": "single", "right": "single"}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tableWidth": 5000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、AI 输出解析规则
|
||||||
|
|
||||||
|
### 3.1 强制输出 JSON
|
||||||
|
```
|
||||||
|
在 prompt 末尾附加:
|
||||||
|
请以 JSON 格式返回,不要包含任何其他说明文字。
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{"type": "text|table", ...}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 JSON 解析
|
||||||
|
```
|
||||||
|
收到 AI 响应后:
|
||||||
|
1. 尝试解析为 JSON
|
||||||
|
2. 若解析失败,尝试从响应的 ```json ``` 代码块中提取
|
||||||
|
3. 若仍然失败,将整个响应作为纯文本处理(type=text)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 表格插入
|
||||||
|
```
|
||||||
|
当 type=table:
|
||||||
|
1. 在原始 docx 中找到该段落后面的第一个表格占位
|
||||||
|
2. 删除占位表格的 XML 节点
|
||||||
|
3. 创建新表格(python-docx add_table)
|
||||||
|
4. 逐格填充数据
|
||||||
|
5. 应用模板中该位置的表格样式(边框、底纹、对齐)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、段落编辑方式
|
||||||
|
|
||||||
|
### 人工编辑
|
||||||
|
- 段落内容完全由用户手动输入
|
||||||
|
- 不参与 AI 生成流程
|
||||||
|
- 导出时保留用户输入的内容
|
||||||
|
|
||||||
|
### AI 生成
|
||||||
|
- 参与 AI 生成流程
|
||||||
|
- 可配置:生成模型、提示词、参考文件、输出格式
|
||||||
|
- 生成后可在预览编辑页手动修改
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# AI 文档模板生成系统 · 任务拆解清单
|
||||||
|
|
||||||
|
总工期估算:5-6 周(两人并行:前端 + 后端)
|
||||||
|
|
||||||
|
## 第一阶段:架构与规范(第 1 周)
|
||||||
|
|
||||||
|
- [x] 搭建前端脚手架(Vue 3 + Vite + TS + Ant Design Vue + Pinia + Router)
|
||||||
|
- [x] 搭建后端脚手架(FastAPI + SQLAlchemy async + SQLite)
|
||||||
|
- [x] 数据库表设计与建表
|
||||||
|
- [x] 模板格式规范定稿(段落边界规则、标题样式要求、表格归属)
|
||||||
|
- [x] AI 输出格式规范定稿(JSON 结构、表格标记、错误兜底)
|
||||||
|
- [x] 导出策略定稿(基于原模板替换内容)
|
||||||
|
- [x] 前后端 API 接口约定
|
||||||
|
|
||||||
|
## 第二阶段:后端核心开发(第 2-3 周)
|
||||||
|
|
||||||
|
### Word 解析器(5-7 天)
|
||||||
|
- [x] python-docx 打开模板,逐段落遍历
|
||||||
|
- [x] 标题样式识别(Heading 1~6)→ 段落边界
|
||||||
|
- [x] 正文内容捕获 → 合并到上一标题
|
||||||
|
- [ ] 表格结构提取(行列数、合并单元格、边框、底纹)
|
||||||
|
- [x] 样式捕获(字体名、字号、加粗、颜色、对齐、缩进、间距、行距)
|
||||||
|
- [x] 段落索引记录(在文档中的位置,用于导出定位)
|
||||||
|
- [x] 输出结构化 JSON
|
||||||
|
|
||||||
|
### AI 服务层(5-7 天)
|
||||||
|
- [x] OpenAI 格式适配(GPT-4o、DeepSeek-V3、通义千问)
|
||||||
|
- [x] Anthropic 格式适配(Claude 3.5 Sonnet)
|
||||||
|
- [x] 统一接口:call_ai(paragraph, files, callback) → content
|
||||||
|
- [ ] 提示词拼接:系统提示词 + 段落预设提示词 + 文件摘要
|
||||||
|
- [x] 超时/重试/错误处理
|
||||||
|
- [ ] 并发控制(asyncio.Semaphore)
|
||||||
|
- [ ] 文件摘要生成(Excel 解析 + 数据统计)
|
||||||
|
|
||||||
|
### 文档生成器(3 天)
|
||||||
|
- [x] 单段落生成流程
|
||||||
|
- [ ] 多段落并行生成编排(asyncio.gather)
|
||||||
|
- [x] SSE 进度推送
|
||||||
|
- [x] 取消生成支持
|
||||||
|
- [x] 生成结果入库
|
||||||
|
|
||||||
|
### Word 导出引擎(5-7 天)
|
||||||
|
- [ ] 复制原模板文件作为骨架
|
||||||
|
- [ ] 段落定位(按索引/xpath/标题文本三级匹配)
|
||||||
|
- [ ] 纯文本替换(保留原样式)
|
||||||
|
- [ ] 表格填充(删除占位表格 → 创建新表格 → 填充数据 → 应用样式)
|
||||||
|
- [ ] 混合内容处理(文字 + 表格交替)
|
||||||
|
- [ ] PDF 导出(调用 LibreOffice 命令)
|
||||||
|
|
||||||
|
### 路由与 API(3 天)
|
||||||
|
- [x] 模板 CRUD 路由
|
||||||
|
- [x] 模型 CRUD 路由
|
||||||
|
- [x] 生成相关路由(测试/全量/进度SSE/取消)
|
||||||
|
- [ ] 导出路由(Word/PDF)
|
||||||
|
- [x] 文件上传/管理
|
||||||
|
|
||||||
|
## 第三阶段:前端核心开发(第 2-4 周)
|
||||||
|
|
||||||
|
### 通用组件(3 天)
|
||||||
|
- [ ] FileUploader 组件(拖拽 + 点击上传)
|
||||||
|
- [ ] ModelModal 组件(添加/编辑模型弹窗)
|
||||||
|
- [ ] TestModal 组件(段落测试三步弹窗)
|
||||||
|
- [ ] 状态标签、加载状态、空状态组件
|
||||||
|
|
||||||
|
### 模板管理页(5-7 天)
|
||||||
|
- [ ] 模板列表:卡片布局 + 搜索 + 分页
|
||||||
|
- [x] 三栏编辑器布局
|
||||||
|
- [x] 左栏:段落列表(点击高亮 + 滚动联动)
|
||||||
|
- [x] 中栏:文档预览(A4 纸样式,段落可点击选择)
|
||||||
|
- [x] 右栏:段落配置面板(编辑方式/模型/提示词/文件/格式)
|
||||||
|
- [x] 保存模板
|
||||||
|
|
||||||
|
### 模型管理页(2 天)
|
||||||
|
- [x] 模型卡片列表 + 启用/禁用
|
||||||
|
- [x] 添加/编辑弹窗表单
|
||||||
|
|
||||||
|
### 执行生成页(3 天)
|
||||||
|
- [x] 双栏布局:左模板选择 + 右段落列表
|
||||||
|
- [x] 文件上传区(按段落分列)
|
||||||
|
- [x] 生成按钮 + 进度展示
|
||||||
|
- [x] 完成跳转
|
||||||
|
|
||||||
|
### 生成记录页(2 天)
|
||||||
|
- [x] 统计卡片
|
||||||
|
- [ ] 卡片式历史列表 + 筛选
|
||||||
|
- [x] 预览/下载按钮
|
||||||
|
|
||||||
|
### 预览编辑页(3-4 天)
|
||||||
|
- [ ] 富文本编辑器(contenteditable + 自定义工具栏)
|
||||||
|
- [ ] AI 内容紫色高亮标注
|
||||||
|
- [ ] 重新生成单段落
|
||||||
|
- [x] 保存/导出按钮
|
||||||
|
|
||||||
|
## 第四阶段:联调与修边(第 5-6 周)
|
||||||
|
|
||||||
|
- [ ] 文件上传 → 解析 → 保存 → 生成 → 导出全流程联调
|
||||||
|
- [ ] 模板不同格式兼容性测试(不同字体、不同布局、含表格/图片/页眉页脚)
|
||||||
|
- [ ] AI 不同模型的输出格式兼容性
|
||||||
|
- [ ] 错误处理完善(网络中断、模型不可用、文件格式错误)
|
||||||
|
- [ ] 边界情况处理(空模板、超大文件、特殊字符)
|
||||||
|
- [ ] 响应式适配
|
||||||
|
- [ ] 加载状态/骨架屏
|
||||||
|
- [ ] 操作提示/Toast
|
||||||
|
|
||||||
|
## 阶段交付物
|
||||||
|
|
||||||
|
| 阶段 | 交付物 |
|
||||||
|
|------|--------|
|
||||||
|
| 第 1 周 | 项目脚手架、数据库表、API 接口文档 |
|
||||||
|
| 第 2-3 周 | 后端全部功能可用(可通过 API 测试) |
|
||||||
|
| 第 4-5 周 | 前端全部页面可用,可联调 |
|
||||||
|
| 第 6 周 | 全流程跑通,交付验收 |
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS doc_forge CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
USE doc_forge;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL COMMENT '模板名称',
|
||||||
|
description TEXT DEFAULT '' COMMENT '描述',
|
||||||
|
file_path VARCHAR(500) NOT NULL COMMENT 'MinIO 对象路径',
|
||||||
|
paragraph_count INT DEFAULT 0 COMMENT '段落数',
|
||||||
|
status VARCHAR(20) DEFAULT 'draft' COMMENT 'draft/ready',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_models (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL COMMENT '模型名称',
|
||||||
|
provider VARCHAR(100) DEFAULT '' COMMENT '供应厂商',
|
||||||
|
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
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS paragraphs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
sort_index INT DEFAULT 0 COMMENT '排序',
|
||||||
|
title VARCHAR(500) DEFAULT '' COMMENT '段落标题',
|
||||||
|
content TEXT DEFAULT '' COMMENT '正文内容',
|
||||||
|
style_json TEXT DEFAULT '{}' COMMENT '样式 JSON',
|
||||||
|
is_table TINYINT(1) DEFAULT 0 COMMENT '是否为表格',
|
||||||
|
table_json TEXT DEFAULT '{}' COMMENT '表格结构 JSON',
|
||||||
|
edit_mode VARCHAR(20) DEFAULT 'ai' COMMENT 'manual/ai',
|
||||||
|
model_id INT DEFAULT NULL COMMENT '指定模型',
|
||||||
|
need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词',
|
||||||
|
prompt_text TEXT DEFAULT '' COMMENT '预设提示词',
|
||||||
|
need_file TINYINT(1) DEFAULT 0 COMMENT '是否需要参考文件',
|
||||||
|
file_note TEXT DEFAULT '' COMMENT '备注说明',
|
||||||
|
output_format VARCHAR(20) DEFAULT 'text' COMMENT 'text/table/mixed/chart',
|
||||||
|
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,
|
||||||
|
FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) DEFAULT '' COMMENT '文档名称',
|
||||||
|
para_count_done INT DEFAULT 0 COMMENT '已完成段落数',
|
||||||
|
para_count_total INT DEFAULT 0 COMMENT '总段落数',
|
||||||
|
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
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
document_id INT NOT NULL,
|
||||||
|
paragraph_id INT NOT NULL,
|
||||||
|
model_id INT DEFAULT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/generating/success/failed',
|
||||||
|
content TEXT DEFAULT '' COMMENT '生成的内容',
|
||||||
|
duration FLOAT DEFAULT 0 COMMENT '耗时秒数',
|
||||||
|
error_msg TEXT DEFAULT '' COMMENT '错误信息',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
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;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!DOCTYPE html><html lang="zh-CN"><head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>AI 文档模板生成系统</title></head><body>
|
||||||
|
<div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-doc-template-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"ant-design-vue": "^4.1.0",
|
||||||
|
"@ant-design/icons-vue": "^7.0.0",
|
||||||
|
"dayjs": "^1.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.1.0",
|
||||||
|
"vue-tsc": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1384
File diff suppressed because it is too large
Load Diff
+176
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<a-config-provider :theme="{ token: { colorPrimary: '#5b5bd6', borderRadius: 8 } }">
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-logo">
|
||||||
|
<div class="logo-icon">A</div>
|
||||||
|
<span>AI 文档模板</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="topbar-nav">
|
||||||
|
<button :class="['topbar-tab', { active: isActive('/templates') }]" @click="router.push('/templates')">
|
||||||
|
<folder-outlined />
|
||||||
|
模板管理
|
||||||
|
</button>
|
||||||
|
<button :class="['topbar-tab', { active: isActive('/models') }]" @click="router.push('/models')">
|
||||||
|
<api-outlined />
|
||||||
|
模型管理
|
||||||
|
</button>
|
||||||
|
<button :class="['topbar-tab', { active: isActive('/generate') }]" @click="router.push('/generate')">
|
||||||
|
<thunderbolt-outlined />
|
||||||
|
执行生成
|
||||||
|
</button>
|
||||||
|
<button :class="['topbar-tab', { active: isActive('/attachments') }]" @click="router.push('/attachments')">
|
||||||
|
<paper-clip-outlined />
|
||||||
|
附件历史
|
||||||
|
</button>
|
||||||
|
<button :class="['topbar-tab', { active: isActive('/history') }]" @click="router.push('/history')">
|
||||||
|
<clock-circle-outlined />
|
||||||
|
生成记录
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="topbar-right">
|
||||||
|
<span class="version-text">v3.0</span>
|
||||||
|
<div class="user-badge">
|
||||||
|
<span class="user-name">周文涛</span>
|
||||||
|
<div class="user-avatar-sm">周</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="page-main">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined, PaperClipOutlined } from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
function isActive(path: string) {
|
||||||
|
return route.path.startsWith(path)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f8;
|
||||||
|
color: #1a1d24;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 52px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e0e2e6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-right: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: #5b5bd6;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-tab {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b626e;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-tab:hover {
|
||||||
|
background: #f0f1f3;
|
||||||
|
color: #1a1d24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-tab.active {
|
||||||
|
background: #eeeefb;
|
||||||
|
color: #5b5bd6;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px 4px 12px;
|
||||||
|
background: #f0f1f3;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-sm {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eeeefb;
|
||||||
|
color: #5b5bd6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-main {
|
||||||
|
height: calc(100vh - 52px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 }),
|
||||||
|
updateReferenceFile: (id: number, data: any) => http.put(`/generate/reference-files/${id}`, data),
|
||||||
|
deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`),
|
||||||
|
downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`),
|
||||||
|
full: (data: any) => http.post('/generate/full', data),
|
||||||
|
progress: (id: number) => `/api/v1/generate/progress/${id}`,
|
||||||
|
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
|
||||||
|
documents: (params?: any) => http.get('/generate/documents', { params }),
|
||||||
|
getDocument: (id: number) => http.get(`/generate/documents/${id}`),
|
||||||
|
deleteDocument: (id: number) => http.delete(`/generate/documents/${id}`),
|
||||||
|
exportDocx: (id: number) => `/api/v1/export/${id}/docx`,
|
||||||
|
exportPdf: (id: number) => `/api/v1/export/${id}/pdf`,
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const http = axios.create({ baseURL: '/api/v1', timeout: 30000 })
|
||||||
|
|
||||||
|
http.interceptors.response.use(
|
||||||
|
res => res.data,
|
||||||
|
err => {
|
||||||
|
const msg = err.response?.data?.message || err.message || '网络错误'
|
||||||
|
console.error('[API Error]', msg)
|
||||||
|
return Promise.reject({ code: -1, message: msg })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default http
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import http from './index'
|
||||||
|
|
||||||
|
export const modelApi = {
|
||||||
|
list: () => http.get('/models'),
|
||||||
|
create: (data: any) => http.post('/models', data),
|
||||||
|
update: (id: number, data: any) => http.put(`/models/${id}`, data),
|
||||||
|
delete: (id: number) => http.delete(`/models/${id}`),
|
||||||
|
test: (id: number) => http.post(`/models/${id}/test`),
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import http from './index'
|
||||||
|
|
||||||
|
export const templateApi = {
|
||||||
|
list: (params?: any) => http.get('/templates', { params }),
|
||||||
|
get: (id: number) => http.get(`/templates/${id}`),
|
||||||
|
upload: (formData: FormData) => http.post('/templates/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
||||||
|
saveParagraphs: (id: number, data: any) => http.put(`/templates/${id}/paragraphs`, data),
|
||||||
|
delete: (id: number) => http.delete(`/templates/${id}`),
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<template><div ref="containerRef" style="overflow-y:auto;padding:24px;display:flex;justify-content:center;background:#f0f1f3;flex:1"><div style="width:794px;background:#fff;padding:80px 72px;min-height:500px;box-shadow:0 2px 8px rgba(0,0,0,.08);font-size:14px;line-height:1.8"><slot /></div></div></template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
const containerRef = ref<HTMLElement|null>(null);
|
||||||
|
defineExpose({containerRef})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<template><a-upload-dragger :multiple="multiple" :beforeUpload="(f: File)=>{$emit('upload',f);return false}" :showUploadList="showList"><p class="ant-upload-drag-icon"><upload-outlined /></p><p class="ant-upload-text">{{text}}</p><p class="ant-upload-hint">{{hint}}</p></a-upload-dragger></template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { UploadOutlined } from "@ant-design/icons-vue";
|
||||||
|
defineProps<{text?:string;hint?:string;multiple?:boolean;showList?:boolean}>()
|
||||||
|
defineEmits<{upload:[file:File]}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<template><a-modal v-model:open="visible" :title="isEdit?'编辑模型':'添加模型'" @ok="$emit('save',form)"><a-form layout="vertical"><a-form-item label="模型名称"><a-input v-model:value="form.name" /></a-form-item><a-form-item label="供应厂商"><a-input v-model:value="form.provider" /></a-form-item><a-form-item label="API 格式"><a-select v-model:value="form.api_format"><a-select-option value="openai">OpenAI</a-select-option><a-select-option value="anthropic">Anthropic</a-select-option></a-select></a-form-item><a-form-item label="API 地址"><a-input v-model:value="form.api_endpoint" /></a-form-item><a-form-item label="API Key"><a-input-password v-model:value="form.api_key" /></a-form-item></a-form></a-modal></template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
const props = defineProps<{open:boolean;isEdit:boolean;initialData?:any}>()
|
||||||
|
const emit = defineEmits<{save:[data:any];close:[]}>()
|
||||||
|
const visible = ref(false)
|
||||||
|
const form = ref({name:'',provider:'',api_format:'openai',api_endpoint:'',api_key:''})
|
||||||
|
watch(()=>props.open,v=>{visible.value=v;if(v&&props.isEdit&&props.initialData)form.value={...form.value,...props.initialData}})
|
||||||
|
watch(visible,v=>{if(!v)emit('close')})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template><div><a-form layout="vertical"><a-form-item label="编辑方式"><a-select v-model:value="data.edit_mode"><a-select-option value="ai">AI 生成</a-select-option><a-select-option value="manual">人工编辑</a-select-option></a-select></a-form-item><a-form-item v-if="data.edit_mode==='ai'" label="生成模型"><a-select v-model:value="data.model_id" allowClear placeholder="使用默认模型"><a-select-option v-for="m in models" :key="m.id" :value="m.id">{{m.name}}</a-select-option></a-select></a-form-item><a-form-item label="输出格式"><a-select v-model:value="data.output_format"><a-select-option value="text">正式报告段落</a-select-option><a-select-option value="table">表格形式</a-select-option><a-select-option value="mixed">混合内容</a-select-option></a-select></a-form-item><a-form-item label="需要提示词"><a-switch v-model:checked="data.need_prompt" /></a-form-item><a-form-item v-if="data.need_prompt" label="预设提示词"><a-textarea v-model:value="data.prompt_text" :rows="4" /></a-form-item><a-form-item label="需要参考文件"><a-switch v-model:checked="data.need_file" /></a-form-item><a-form-item v-if="data.need_file" label="备注"><a-input v-model:value="data.file_note" placeholder="提示上传什么文件" /></a-form-item></a-form><a-button v-if="data.edit_mode==='ai'" type="primary" block @click="$emit('test')">立即测试</a-button></div></template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{data:any;models:any[]}>()
|
||||||
|
defineEmits<{test:[]}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<template><div class="para-list"><div v-for="(p,i) in paragraphs" :key="p.id" :class="['para-item',{active:activeId===p.id}]" @click="$emit('select',p.id)"><span class="idx">{{i+1}}</span><span class="title">{{p.title||"段落"+p.id}}</span><a-tag :color="p.edit_mode==='ai'?'blue':'orange'" size="small">{{p.edit_mode==='ai'?'AI':'手动'}}</a-tag></div></div></template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{paragraphs:any[];activeId:number}>()
|
||||||
|
defineEmits<{select:[id:number]}>()
|
||||||
|
</script>
|
||||||
|
<style scoped>.para-list{padding:8px}.para-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;margin-bottom:2px;font-size:13px}.para-item:hover{background:#f5f5f5}.para-item.active{background:#f0f0ff;color:#5b5bd6;font-weight:500}.para-item .idx{width:20px;height:20px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.para-item.active .idx{background:#5b5bd6;color:#fff}.para-item .title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<template><a-modal v-model:open="visible" title="段落测试" :footer="null" width="680px"><a-steps :current="step" style="margin-bottom:24px"><a-step title="上传附件" /><a-step title="处理中" /><a-step title="查看结果" /></a-steps><div v-if="step===0"><a-upload-dragger :beforeUpload="(f: File)=>{uploadFile=f;step=1;return false}"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击上传参考文件</p></a-upload-dragger></div><div v-if="step===1" style="text-align:center;padding:40px"><a-spin size="large" /><p style="margin-top:16px;color:#666">正在解析文件 → 请求 AI 模型...</p></div><div v-if="step===2"><div style="background:#f0f0ff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px"><p>AI 生成结果预览:</p><p>{{result}}</p></div></div></a-modal></template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { FileAddOutlined } from "@ant-design/icons-vue";
|
||||||
|
const props = defineProps<{open:boolean}>()
|
||||||
|
const emit = defineEmits<{close:[]}>()
|
||||||
|
const visible = ref(false)
|
||||||
|
const step = ref(0)
|
||||||
|
const uploadFile = ref<File|null>(null)
|
||||||
|
const result = ref("这是 AI 生成的示例内容...")
|
||||||
|
watch(()=>props.open,v=>{visible.value=v;if(v){step.value=0;uploadFile.value=null}})
|
||||||
|
watch(visible,v=>{if(!v)emit('close')})
|
||||||
|
</script>
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare module "*.vue" { import type { DefineComponent } from "vue"; const comp: DefineComponent<{}, {}, any>; export default comp; }
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import Antd from 'ant-design-vue'
|
||||||
|
import 'ant-design-vue/dist/reset.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(Antd)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', redirect: '/templates' },
|
||||||
|
{ path: '/templates', name: 'TemplateList', component: () => import('@/views/TemplateList.vue') },
|
||||||
|
{ path: '/templates/:id/edit', name: 'TemplateEditor', component: () => import('@/views/TemplateEditor.vue') },
|
||||||
|
{ path: '/models', name: 'ModelManage', component: () => import('@/views/ModelManage.vue') },
|
||||||
|
{ path: '/generate', name: 'GeneratePage', component: () => import('@/views/GeneratePage.vue') },
|
||||||
|
{ path: '/attachments', name: 'AttachmentHistoryPage', component: () => import('@/views/AttachmentHistoryPage.vue') },
|
||||||
|
{ path: '/history', name: 'HistoryPage', component: () => import('@/views/HistoryPage.vue') },
|
||||||
|
{ path: '/preview/:id', name: 'PreviewEdit', component: () => import('@/views/PreviewEdit.vue') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({ history: createWebHistory(), routes })
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { generateApi } from '@/api/generate'
|
||||||
|
import type { Document } from '@/types'
|
||||||
|
|
||||||
|
export const useDocumentStore = defineStore('document', () => {
|
||||||
|
const documents = ref<Document[]>([])
|
||||||
|
const currentDoc = ref<Document | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchList(params?: any) { loading.value = true; try { const r: any = await generateApi.documents(params); documents.value = r.data?.items || r.data || [] } finally { loading.value = false } }
|
||||||
|
async function generateFull(data: any) { const r: any = await generateApi.full(data); return r.data }
|
||||||
|
async function cancel(id: number) { await generateApi.cancel(id) }
|
||||||
|
async function removeDoc(id: number) { await generateApi.deleteDocument(id); await fetchList() }
|
||||||
|
|
||||||
|
return { documents, currentDoc, loading, fetchList, generateFull, cancel, removeDoc }
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { modelApi } from '@/api/model'
|
||||||
|
import type { AiModel } from '@/types'
|
||||||
|
|
||||||
|
export const useModelStore = defineStore('model', () => {
|
||||||
|
const models = ref<AiModel[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchList() { loading.value = true; try { const r: any = await modelApi.list(); models.value = r.data || [] } finally { loading.value = false } }
|
||||||
|
async function create(data: any) { await modelApi.create(data); await fetchList() }
|
||||||
|
async function update(id: number, data: any) { await modelApi.update(id, data); await fetchList() }
|
||||||
|
async function remove(id: number) { await modelApi.delete(id); await fetchList() }
|
||||||
|
async function test(id: number) { return await modelApi.test(id) }
|
||||||
|
|
||||||
|
return { models, loading, fetchList, create, update, remove, test }
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { templateApi } from '@/api/template'
|
||||||
|
import type { Template, Paragraph } from '@/types'
|
||||||
|
|
||||||
|
export const useTemplateStore = defineStore('template', () => {
|
||||||
|
const templates = ref<Template[]>([])
|
||||||
|
const currentTemplate = ref<Template | null>(null)
|
||||||
|
const paragraphs = ref<Paragraph[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchList() { loading.value = true; try { const r: any = await templateApi.list(); templates.value = r.data?.items || r.data || [] } finally { loading.value = false } }
|
||||||
|
async function fetchOne(id: number) { const r: any = await templateApi.get(id); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data }
|
||||||
|
async function upload(file: File) { const fd = new FormData(); fd.append('file', file); const r: any = await templateApi.upload(fd); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data }
|
||||||
|
async function save(id: number) { const data = paragraphs.value.map(p => ({ id: p.id, sort_index: p.sort_index, title: p.title, edit_mode: p.edit_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format })); await templateApi.saveParagraphs(id, { paragraphs: data }) }
|
||||||
|
async function remove(id: number) { await templateApi.delete(id); await fetchList() }
|
||||||
|
|
||||||
|
return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove }
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface Template {
|
||||||
|
id: number; name: string; description: string; file_path: string
|
||||||
|
paragraph_count: number; status: string; created_at: string; updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paragraph {
|
||||||
|
id: number; template_id: number; sort_index: number; title: string; content: string
|
||||||
|
style_json: string; is_table: boolean; table_json: string
|
||||||
|
edit_mode: 'manual' | 'ai'; model_id: number | null
|
||||||
|
need_prompt: boolean; prompt_text: string; need_file: boolean; file_note: string
|
||||||
|
output_format: 'text' | 'table' | 'mixed' | 'chart'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiModel {
|
||||||
|
id: number; name: string; provider: string; api_format: 'anthropic' | 'openai'
|
||||||
|
api_endpoint: string; api_key_preview: string; supports_streaming: boolean; enable_reasoning: boolean; status: 'enabled' | 'disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Document {
|
||||||
|
id: number; template_id: number; name: string
|
||||||
|
para_count_done: number; para_count_total: number
|
||||||
|
status: 'pending' | 'generating' | 'completed' | 'failed' | 'cancelled'
|
||||||
|
file_path: string; error: string
|
||||||
|
request_payload?: {
|
||||||
|
template_name?: string
|
||||||
|
file_map?: Record<string, string[]>
|
||||||
|
paragraphs?: Array<{
|
||||||
|
paragraph_id: number
|
||||||
|
title: string
|
||||||
|
sort_index: number
|
||||||
|
need_file: boolean
|
||||||
|
file_note: string
|
||||||
|
selected_files: ReferenceFile[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferenceFile {
|
||||||
|
id: number; file_name: string; file_path: string; file_size: number; content_type: string; created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> { code: number; data: T; message: string }
|
||||||
|
export interface PageData<T = any> { items: T[]; total: number; page: number; page_size: number }
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<div class="attachment-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">附件历史</div>
|
||||||
|
<div class="page-desc">查看系统内已上传的参考文件,便于后续模板测试时直接复用。</div>
|
||||||
|
</div>
|
||||||
|
<a-button @click="fetchList">刷新列表</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">历史附件总数</div>
|
||||||
|
<div class="stat-value">{{ total }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">当前页文件数</div>
|
||||||
|
<div class="stat-value">{{ files.length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="keyword"
|
||||||
|
placeholder="按文件名搜索附件"
|
||||||
|
allow-clear
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-card :bordered="false" class="table-card">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="files"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'file_name'">
|
||||||
|
<div class="file-name">{{ record.file_name }}</div>
|
||||||
|
<div class="file-path">{{ record.file_path }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'file_size'">
|
||||||
|
{{ formatFileSize(record.file_size) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'created_at'">
|
||||||
|
{{ formatDateTime(record.created_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" @click="downloadFile(record)">下载</a-button>
|
||||||
|
<a-button size="small" @click="renameFile(record)">改名</a-button>
|
||||||
|
<a-button size="small" danger @click="removeFile(record)">删除</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { generateApi } from '@/api/generate'
|
||||||
|
import type { ReferenceFile } from '@/types'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const files = ref<ReferenceFile[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '文件名', key: 'file_name', dataIndex: 'file_name' },
|
||||||
|
{ title: '文件类型', key: 'content_type', dataIndex: 'content_type', width: 180 },
|
||||||
|
{ title: '文件大小', key: 'file_size', dataIndex: 'file_size', width: 120 },
|
||||||
|
{ title: '上传时间', key: 'created_at', dataIndex: 'created_at', width: 180 },
|
||||||
|
{ title: '操作', key: 'actions', width: 220 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
current: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
total: total.value,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (count: number) => `共 ${count} 个附件`,
|
||||||
|
})
|
||||||
|
|
||||||
|
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 fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response: any = await generateApi.referenceFiles({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
keyword: keyword.value.trim(),
|
||||||
|
})
|
||||||
|
files.value = response.data?.items || []
|
||||||
|
total.value = response.data?.total || 0
|
||||||
|
pagination.value = {
|
||||||
|
...pagination.value,
|
||||||
|
current: response.data?.page || page.value,
|
||||||
|
pageSize: response.data?.page_size || pageSize.value,
|
||||||
|
total: response.data?.total || 0,
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '加载附件历史失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(record: ReferenceFile) {
|
||||||
|
try {
|
||||||
|
const response: any = await generateApi.downloadReferenceFile(record.id)
|
||||||
|
window.open(response.data?.url, '_blank')
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '下载附件失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameFile(record: ReferenceFile) {
|
||||||
|
const nextName = window.prompt('请输入新的附件名称', record.file_name)
|
||||||
|
if (nextName === null) return
|
||||||
|
if (!nextName.trim()) {
|
||||||
|
message.warning('文件名不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
generateApi.updateReferenceFile(record.id, { file_name: nextName.trim() }).then(async () => {
|
||||||
|
message.success('附件名称已更新')
|
||||||
|
await fetchList()
|
||||||
|
}).catch((error: any) => {
|
||||||
|
message.error(error.message || '附件改名失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(record: ReferenceFile) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '删除附件',
|
||||||
|
content: `确认删除附件“${record.file_name}”吗?`,
|
||||||
|
okText: '删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
await generateApi.deleteReferenceFile(record.id)
|
||||||
|
message.success('附件已删除')
|
||||||
|
await fetchList()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTableChange(nextPagination: any) {
|
||||||
|
page.value = nextPagination.current || 1
|
||||||
|
pageSize.value = nextPagination.pageSize || 10
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.attachment-page {
|
||||||
|
padding: 24px;
|
||||||
|
background: #f5f6f8;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f5f7fb 100%);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-path {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<span>执行生成</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span>选择模板 → 上传文件 → AI 自动生成</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gen-layout">
|
||||||
|
<div class="gen-left">
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">选择模板</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<a-select style="width:100%" v-model:value="selectedTplId" placeholder="— 请选择已编辑好的模板 —" @change="onTplChange">
|
||||||
|
<a-select-option v-for="item in templates" :key="item.id" :value="item.id">{{ item.name }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<div class="template-summary" v-if="selectedTplId">
|
||||||
|
<div class="summary-name">{{ currentTemplateName }}</div>
|
||||||
|
<div class="summary-desc">{{ tplInfo.paragraph_count || 0 }} 个段落 · {{ needFileCount }} 个需上传文件</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-stats">
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-num">{{ tplInfo.paragraph_count || 0 }}</div>
|
||||||
|
<div class="stat-label">总段落</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box success">
|
||||||
|
<div class="stat-num">{{ autoCount }}</div>
|
||||||
|
<div class="stat-label">自动生成</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box warning">
|
||||||
|
<div class="stat-num">{{ needFileCount }}</div>
|
||||||
|
<div class="stat-label">需要文件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-block">
|
||||||
|
<div class="gen-progress">
|
||||||
|
<div class="gen-text">
|
||||||
|
这里仅负责提交生成任务。任务创建后会在后台继续执行,你可以离开当前页面,稍后在“生成记录”或任务详情中查看进度。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gen-right">
|
||||||
|
<div class="panel-card fill-card">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">段落文件配置</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div v-for="paragraph in paragraphs" :key="paragraph.id" :class="['para-row', { needFile: paragraph.need_file, noFile: !paragraph.need_file }]">
|
||||||
|
<div class="para-info">
|
||||||
|
<span class="idx">{{ paragraph.sort_index }}</span>
|
||||||
|
<div class="para-main">
|
||||||
|
<span>{{ paragraph.title }}</span>
|
||||||
|
<span v-if="paragraph.need_file && paragraph.file_note" class="file-note">{{ paragraph.file_note }}</span>
|
||||||
|
</div>
|
||||||
|
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="paragraph.need_file" class="file-info">
|
||||||
|
<a-upload :multiple="true" :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
|
||||||
|
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id]?.length ? '继续上传' : '上传文件' }}</a-button>
|
||||||
|
</a-upload>
|
||||||
|
<div v-if="uploadedFiles[paragraph.id]?.length" class="uploaded-list">
|
||||||
|
<span v-for="item in uploadedFiles[paragraph.id]" :key="item.file_path" class="uploaded-name">
|
||||||
|
{{ item.file_name }}
|
||||||
|
<button class="remove-file-btn" @click="removeUploadedFile(paragraph.id, item.file_path)">x</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="no-file-tag">无需上传</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
<span>{{ fileCount }}/{{ needFileCount }} 个文件已上传</span>
|
||||||
|
<a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { useTemplateStore } from '@/stores/template'
|
||||||
|
import { useDocumentStore } from '@/stores/document'
|
||||||
|
import { generateApi } from '@/api/generate'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const tplStore = useTemplateStore()
|
||||||
|
const docStore = useDocumentStore()
|
||||||
|
|
||||||
|
const templates = ref<any[]>([])
|
||||||
|
const paragraphs = ref<any[]>([])
|
||||||
|
const selectedTplId = ref<number | undefined>(undefined)
|
||||||
|
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({})
|
||||||
|
const uploadedFilePaths = ref<Record<number, string[]>>({})
|
||||||
|
const uploadingMap = ref<Record<number, boolean>>({})
|
||||||
|
const generating = ref(false)
|
||||||
|
const tplInfo = ref<any>({})
|
||||||
|
|
||||||
|
const needFileCount = computed(() => paragraphs.value.filter((item) => item.need_file).length)
|
||||||
|
const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mode === 'ai').length)
|
||||||
|
const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length)
|
||||||
|
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
|
||||||
|
|
||||||
|
async function refreshTemplates() {
|
||||||
|
await tplStore.fetchList()
|
||||||
|
templates.value = tplStore.templates as any
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshTemplates()
|
||||||
|
const queryId = Number(route.query.templateId)
|
||||||
|
if (queryId) {
|
||||||
|
selectedTplId.value = queryId
|
||||||
|
await onTplChange(queryId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onTplChange(id: number) {
|
||||||
|
const template = await tplStore.fetchOne(id)
|
||||||
|
paragraphs.value = tplStore.paragraphs as any
|
||||||
|
tplInfo.value = {
|
||||||
|
paragraph_count: template.paragraph_count,
|
||||||
|
fileCount: paragraphs.value.filter((item: any) => item.need_file).length,
|
||||||
|
}
|
||||||
|
uploadedFiles.value = {}
|
||||||
|
uploadedFilePaths.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(paragraphId: number, file: File) {
|
||||||
|
try {
|
||||||
|
uploadingMap.value[paragraphId] = true
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
const res: any = await generateApi.upload(fd)
|
||||||
|
const nextFile = { file_name: res.data.file_name, file_path: res.data.file_path }
|
||||||
|
uploadedFiles.value[paragraphId] = [...(uploadedFiles.value[paragraphId] || []), nextFile]
|
||||||
|
uploadedFilePaths.value[paragraphId] = [...(uploadedFilePaths.value[paragraphId] || []), res.data.file_path]
|
||||||
|
message.success('文件上传成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '文件上传失败')
|
||||||
|
} finally {
|
||||||
|
uploadingMap.value[paragraphId] = false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUploadedFile(paragraphId: number, filePath: string) {
|
||||||
|
uploadedFiles.value[paragraphId] = (uploadedFiles.value[paragraphId] || []).filter((item) => item.file_path !== filePath)
|
||||||
|
uploadedFilePaths.value[paragraphId] = (uploadedFilePaths.value[paragraphId] || []).filter((item) => item !== filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGen() {
|
||||||
|
if (!selectedTplId.value) {
|
||||||
|
message.warning('请先选择模板')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const missing = paragraphs.value.filter((item: any) => item.need_file && !uploadedFilePaths.value[item.id])
|
||||||
|
if (missing.length) {
|
||||||
|
message.warning('还有必传文件未上传')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
generating.value = true
|
||||||
|
const fileMap = Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([key, value]) => [String(key), value || []]))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document: any = await docStore.generateFull({ template_id: selectedTplId.value, file_map: fileMap })
|
||||||
|
message.success('生成任务已提交,已转到任务详情页继续查看进度')
|
||||||
|
router.push(`/preview/${document.id}`)
|
||||||
|
} catch (error: any) {
|
||||||
|
generating.value = false
|
||||||
|
message.error(error.message || '生成失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrap {
|
||||||
|
padding: 24px 32px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: calc(100vh - 140px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-left {
|
||||||
|
width: 340px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-right {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e0e2e6;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-card {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #eaecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-summary {
|
||||||
|
background: #f0f1f3;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f0f1f3;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box.success {
|
||||||
|
background: #e8f5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box.warning {
|
||||||
|
background: #fff3e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #5b5bd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-block {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-progress {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #e0e2e6;
|
||||||
|
border-top-color: #5b5bd6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b626e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.para-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.para-row.needFile {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.para-row.noFile {
|
||||||
|
background: #fafafa;
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.para-info {
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idx {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f0f0ff;
|
||||||
|
color: #5b5bd6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-name {
|
||||||
|
color: #1a8c4a;
|
||||||
|
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 {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div style="padding:24px">
|
||||||
|
<a-row :gutter="16" style="margin-bottom:24px">
|
||||||
|
<a-col :span="6"><a-card><a-statistic title="总生成" :value="totalCount" /></a-card></a-col>
|
||||||
|
<a-col :span="6"><a-card><a-statistic title="成功" :value="completedCount" value-style="color:#52c41a" /></a-card></a-col>
|
||||||
|
<a-col :span="6"><a-card><a-statistic title="中断" :value="cancelledCount" value-style="color:#faad14" /></a-card></a-col>
|
||||||
|
<a-col :span="6"><a-card><a-statistic title="失败" :value="failedCount" value-style="color:#ff4d4f" /></a-card></a-col>
|
||||||
|
</a-row>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h3>生成记录</h3>
|
||||||
|
</div>
|
||||||
|
<a-list :dataSource="documents" :grid="{gutter:16,xs:1,sm:1,md:2,lg:2,xl:3,xxl:3}">
|
||||||
|
<template #renderItem="{item}">
|
||||||
|
<a-list-item>
|
||||||
|
<a-card hoverable>
|
||||||
|
<a-card-meta :title="item.name">
|
||||||
|
<template #description>
|
||||||
|
<p>模板:{{ item.template_id }}</p>
|
||||||
|
<p>段落:{{ item.para_count_done }}/{{ item.para_count_total }}</p>
|
||||||
|
<p>状态:<a-badge :status="statusBadge(item.status)" :text="statusText(item.status)" /></p>
|
||||||
|
</template>
|
||||||
|
</a-card-meta>
|
||||||
|
<template #actions>
|
||||||
|
<a-button type="link" @click="preview(item.id)">预览</a-button>
|
||||||
|
<a-button type="link" @click="download(item.id)">下载</a-button>
|
||||||
|
</template>
|
||||||
|
</a-card>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
|
</a-list>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useDocumentStore } from '@/stores/document'
|
||||||
|
import { generateApi } from '@/api/generate'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useDocumentStore()
|
||||||
|
const documents = ref<any[]>([])
|
||||||
|
|
||||||
|
const totalCount = computed(() => documents.value.length)
|
||||||
|
const completedCount = computed(() => documents.value.filter((item) => item.status === 'completed').length)
|
||||||
|
const cancelledCount = computed(() => documents.value.filter((item) => item.status === 'cancelled').length)
|
||||||
|
const failedCount = computed(() => documents.value.filter((item) => item.status === 'failed').length)
|
||||||
|
|
||||||
|
function statusText(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
completed: '已生成',
|
||||||
|
failed: '失败',
|
||||||
|
cancelled: '中断',
|
||||||
|
generating: '生成中',
|
||||||
|
pending: '等待中',
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: string) {
|
||||||
|
if (status === 'completed') return 'success'
|
||||||
|
if (status === 'failed') return 'error'
|
||||||
|
if (status === 'cancelled') return 'warning'
|
||||||
|
return 'processing'
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(id: number) {
|
||||||
|
router.push(`/preview/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(id: number) {
|
||||||
|
window.open(generateApi.exportDocx(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchList()
|
||||||
|
documents.value = store.documents as any
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<span>模型管理</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span>AI 模型配置</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">可用模型列表</div>
|
||||||
|
<a-button @click="openAdd">添加模型</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="model-card" v-for="item in models" :key="item.id">
|
||||||
|
<div class="mc-icon">{{ item.name?.slice(0, 1)?.toUpperCase() || 'M' }}</div>
|
||||||
|
<div class="mc-info">
|
||||||
|
<div class="mc-name">{{ item.name }}</div>
|
||||||
|
<div class="mc-provider">{{ item.provider }} · {{ item.api_endpoint }}</div>
|
||||||
|
<div class="mc-provider">密钥:{{ item.api_key_preview || '未设置' }}</div>
|
||||||
|
<div class="mc-provider">流式传输:{{ item.supports_streaming ? '支持' : '关闭' }}</div>
|
||||||
|
<div class="mc-provider">思考模式:{{ item.enable_reasoning ? '开启' : '关闭' }}</div>
|
||||||
|
</div>
|
||||||
|
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
|
||||||
|
{{ item.status === 'enabled' ? '已启用' : '已禁用' }}
|
||||||
|
</div>
|
||||||
|
<div class="mc-actions">
|
||||||
|
<a-button size="small" @click="runTest(item)">测试</a-button>
|
||||||
|
<a-button size="small" @click="openEdit(item)">编辑</a-button>
|
||||||
|
<a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-modal v-model:open="modalOpen" :title="isEdit ? '编辑模型' : '添加模型'" @ok="saveModel">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="模型名称">
|
||||||
|
<a-input v-model:value="form.name" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="供应厂商">
|
||||||
|
<a-input v-model:value="form.provider" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="API 格式">
|
||||||
|
<a-select v-model:value="form.api_format">
|
||||||
|
<a-select-option value="openai">OpenAI 格式</a-select-option>
|
||||||
|
<a-select-option value="anthropic">Anthropic 格式</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="API 地址">
|
||||||
|
<a-input v-model:value="form.api_endpoint" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="支持流式传输">
|
||||||
|
<a-switch v-model:checked="form.supports_streaming" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="开启思考模式">
|
||||||
|
<a-switch v-model:checked="form.enable_reasoning" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="API Key">
|
||||||
|
<a-input-password v-model:value="form.api_key" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Modal, message } from 'ant-design-vue'
|
||||||
|
import { useModelStore } from '@/stores/model'
|
||||||
|
|
||||||
|
const store = useModelStore()
|
||||||
|
const models = ref<any[]>([])
|
||||||
|
const modalOpen = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const editId = ref(0)
|
||||||
|
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false })
|
||||||
|
|
||||||
|
async function refreshList() {
|
||||||
|
await store.fetchList()
|
||||||
|
models.value = store.models as any
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshList()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
isEdit.value = false
|
||||||
|
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: any) {
|
||||||
|
isEdit.value = true
|
||||||
|
editId.value = item.id
|
||||||
|
form.value = {
|
||||||
|
name: item.name,
|
||||||
|
provider: item.provider,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModel() {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await store.update(editId.value, form.value)
|
||||||
|
} else {
|
||||||
|
await store.create(form.value)
|
||||||
|
}
|
||||||
|
modalOpen.value = false
|
||||||
|
await refreshList()
|
||||||
|
message.success('保存成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(item: any) {
|
||||||
|
await store.update(item.id, { status: item.status === 'enabled' ? 'disabled' : 'enabled' })
|
||||||
|
await refreshList()
|
||||||
|
message.success('状态已更新')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest(item: any) {
|
||||||
|
try {
|
||||||
|
const result: any = await store.test(item.id)
|
||||||
|
Modal.info({
|
||||||
|
title: '连接测试结果',
|
||||||
|
width: 680,
|
||||||
|
content: JSON.stringify(result.data, null, 2),
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '连接测试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrap {
|
||||||
|
padding: 24px 32px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e0e2e6;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #eaecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card {
|
||||||
|
border: 1px solid #eaecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eeeefb;
|
||||||
|
color: #5b5bd6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-provider {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-status {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-status.on {
|
||||||
|
color: #1a8c4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-status.off {
|
||||||
|
color: #9aa1ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-page">
|
||||||
|
<div class="detail-head">
|
||||||
|
<div>
|
||||||
|
<div class="detail-title">任务详情</div>
|
||||||
|
<div class="detail-desc">查看当前生成状态、已选附件,以及已完成段落的输出结果。</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<a-button v-if="isRunning" danger @click="cancelTask">取消任务</a-button>
|
||||||
|
<a-button :disabled="!isCompleted" @click="exportDocx">导出 Word</a-button>
|
||||||
|
<a-button :disabled="!isCompleted" @click="exportPdf">导出 PDF</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">任务状态</div>
|
||||||
|
<div class="stat-value">{{ statusText(documentInfo.status) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">完成进度</div>
|
||||||
|
<div class="stat-value">{{ documentInfo.para_count_done || 0 }}/{{ documentInfo.para_count_total || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">关联模板</div>
|
||||||
|
<div class="stat-value">{{ documentInfo.request_payload?.template_name || documentInfo.name || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-card class="status-card">
|
||||||
|
<a-badge :status="statusBadge(documentInfo.status)" :text="statusText(documentInfo.status)" />
|
||||||
|
<a-progress style="margin-top: 14px" :percent="progressPercent" />
|
||||||
|
<div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="mapping-card" title="段落与附件映射">
|
||||||
|
<div v-if="paragraphMappings.length" class="mapping-list">
|
||||||
|
<div v-for="item in paragraphMappings" :key="item.paragraph_id" class="mapping-item">
|
||||||
|
<div class="mapping-top">
|
||||||
|
<span class="mapping-index">{{ item.sort_index }}</span>
|
||||||
|
<div class="mapping-main">
|
||||||
|
<div class="mapping-title">{{ item.title }}</div>
|
||||||
|
<div v-if="item.file_note" class="mapping-note">{{ item.file_note }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.selected_files?.length" class="mapping-files">
|
||||||
|
<span v-for="file in item.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mapping-empty">未选择附件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-empty v-else description="当前任务未记录附件映射" />
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="preview-card" title="生成结果预览">
|
||||||
|
<div v-if="logs.length" class="preview-wrap">
|
||||||
|
<section v-for="item in logs" :key="item.paragraph_id" :id="`section-${item.paragraph_id}`" class="preview-section">
|
||||||
|
<div class="preview-section-head">
|
||||||
|
<h3>{{ item.title }}</h3>
|
||||||
|
<a-badge :status="statusBadge(item.status)" :text="statusText(item.status)" />
|
||||||
|
</div>
|
||||||
|
<div class="preview-block" v-html="renderBlocks(item.content?.content || [])" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<a-empty v-else :description="isRunning ? '任务进行中,已完成段落会逐步出现在这里' : '暂无生成内容'" />
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { useDocumentStore } from '@/stores/document'
|
||||||
|
import { generateApi } from '@/api/generate'
|
||||||
|
|
||||||
|
interface ContentBlock {
|
||||||
|
type: string
|
||||||
|
text?: string
|
||||||
|
title?: string
|
||||||
|
headers?: string[]
|
||||||
|
rows?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogItem {
|
||||||
|
paragraph_id: number
|
||||||
|
title: string
|
||||||
|
sort_index: number
|
||||||
|
status: string
|
||||||
|
content: { content: ContentBlock[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const docStore = useDocumentStore()
|
||||||
|
const documentInfo = ref<any>({})
|
||||||
|
const logs = ref<LogItem[]>([])
|
||||||
|
const progressPercent = ref(0)
|
||||||
|
const progressMessage = ref('')
|
||||||
|
let progressSource: EventSource | null = null
|
||||||
|
|
||||||
|
const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status))
|
||||||
|
const isCompleted = computed(() => documentInfo.value.status === 'completed')
|
||||||
|
const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || [])
|
||||||
|
|
||||||
|
function statusText(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
cancelled: '已取消',
|
||||||
|
generating: '生成中',
|
||||||
|
pending: '等待中',
|
||||||
|
success: '成功',
|
||||||
|
}
|
||||||
|
return map[status] || status || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: string) {
|
||||||
|
if (status === 'completed' || status === 'success') return 'success'
|
||||||
|
if (status === 'failed') return 'error'
|
||||||
|
if (status === 'cancelled') return 'warning'
|
||||||
|
return 'processing'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(block: ContentBlock) {
|
||||||
|
const headers = block.headers || []
|
||||||
|
const rows = block.rows || []
|
||||||
|
const thead = headers.length
|
||||||
|
? `<thead><tr>${headers.map((header) => `<th>${header}</th>`).join('')}</tr></thead>`
|
||||||
|
: ''
|
||||||
|
const tbody = `<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody>`
|
||||||
|
return `<table class="result-table">${thead}${tbody}</table>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlocks(blocks: ContentBlock[]) {
|
||||||
|
return blocks
|
||||||
|
.map((block) => {
|
||||||
|
if (block.type === 'table') return renderTable(block)
|
||||||
|
return `<p class="result-text">${block.text || ''}</p>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocument() {
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
const response: any = await generateApi.getDocument(id)
|
||||||
|
documentInfo.value = response.data || {}
|
||||||
|
logs.value = response.data?.logs || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindProgress() {
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
if (progressSource) progressSource.close()
|
||||||
|
progressSource = new EventSource(generateApi.progress(id))
|
||||||
|
progressSource.addEventListener('progress', async (event: MessageEvent) => {
|
||||||
|
const payload = JSON.parse(event.data)
|
||||||
|
progressPercent.value = payload.percent || 0
|
||||||
|
progressMessage.value = payload.message || ''
|
||||||
|
await loadDocument()
|
||||||
|
if (['completed', 'failed', 'cancelled'].includes(payload.status)) {
|
||||||
|
progressSource?.close()
|
||||||
|
progressSource = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
progressSource.onerror = () => {
|
||||||
|
progressSource?.close()
|
||||||
|
progressSource = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelTask() {
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
await docStore.cancel(id)
|
||||||
|
message.info('已发起取消请求')
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportDocx() {
|
||||||
|
window.open(generateApi.exportDocx(Number(route.params.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPdf() {
|
||||||
|
window.open(generateApi.exportPdf(Number(route.params.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadDocument()
|
||||||
|
progressPercent.value = Math.floor(((documentInfo.value.para_count_done || 0) / Math.max(documentInfo.value.para_count_total || 1, 1)) * 100)
|
||||||
|
if (isRunning.value) bindProgress()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '加载任务详情失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
progressSource?.close()
|
||||||
|
progressSource = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-page {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-desc {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f7f8fc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card,
|
||||||
|
.mapping-card,
|
||||||
|
.preview-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-item {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-top {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-index {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4f46e5;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-file {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eefbf2;
|
||||||
|
border: 1px solid #cdebd8;
|
||||||
|
color: #1a8c4a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-empty {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.result-text) {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.result-table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.result-table th),
|
||||||
|
:deep(.result-table td) {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrap">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<span>模板管理</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span>所有模板</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<span class="page-subtitle">共 {{ templates.length }} 个模板</span>
|
||||||
|
<a-button type="primary" @click="handleUpload">
|
||||||
|
<template #icon><file-add-outlined /></template>
|
||||||
|
新建模板
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-grid">
|
||||||
|
<div
|
||||||
|
v-for="item in templates"
|
||||||
|
:key="item.id"
|
||||||
|
class="tpl-card"
|
||||||
|
@click="goEdit(item.id)"
|
||||||
|
>
|
||||||
|
<div class="tpl-name">
|
||||||
|
<file-text-outlined class="tpl-icon" />
|
||||||
|
{{ item.name }}
|
||||||
|
<span :class="['tpl-status', item.status === 'ready' ? 'ready' : 'editing']">
|
||||||
|
{{ item.status === 'ready' ? '已编辑' : '编辑中' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-desc">
|
||||||
|
{{ item.description || `含 ${item.paragraph_count || 0} 个段落,可继续编辑配置。` }}
|
||||||
|
</div>
|
||||||
|
<div class="tpl-meta">
|
||||||
|
<span>段落:{{ item.paragraph_count || 0 }}</span>
|
||||||
|
<span>状态:{{ item.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-actions" @click.stop>
|
||||||
|
<a-button size="small" @click="goEdit(item.id)">编辑模板</a-button>
|
||||||
|
<a-button size="small" type="primary" @click="goGenerate(item.id)">前去生成</a-button>
|
||||||
|
<a-popconfirm title="确认删除这个模板吗?" @confirm="removeTemplate(item.id)">
|
||||||
|
<a-button size="small" danger>删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tpl-card tpl-card-create" @click="handleUpload">
|
||||||
|
<div class="tpl-name">
|
||||||
|
<plus-square-outlined class="tpl-icon muted" />
|
||||||
|
新建模板
|
||||||
|
<span class="tpl-status empty">未开始</span>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-desc">点击创建新模板,上传 Word 文件,配置段落。</div>
|
||||||
|
<div class="tpl-meta">
|
||||||
|
<span>段落:0</span>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-actions">
|
||||||
|
<a-button size="small" type="primary">新建模板</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-modal v-model:open="uploadOpen" title="上传模板" @ok="doUpload">
|
||||||
|
<a-upload-dragger :beforeUpload="beforeUpload">
|
||||||
|
<p class="ant-upload-drag-icon"><file-add-outlined /></p>
|
||||||
|
<p class="ant-upload-text">点击或拖拽 Word 模板文件到此区域</p>
|
||||||
|
</a-upload-dragger>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { FileAddOutlined, FileTextOutlined, PlusSquareOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { useTemplateStore } from '@/stores/template'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useTemplateStore()
|
||||||
|
const templates = ref<any[]>([])
|
||||||
|
const uploadOpen = ref(false)
|
||||||
|
const uploadFile = ref<File | null>(null)
|
||||||
|
|
||||||
|
async function refreshList() {
|
||||||
|
await store.fetchList()
|
||||||
|
templates.value = store.templates as any
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshList()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleUpload() {
|
||||||
|
uploadOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeUpload(file: File) {
|
||||||
|
uploadFile.value = file
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function goEdit(id: number) {
|
||||||
|
router.push(`/templates/${id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goGenerate(id: number) {
|
||||||
|
router.push(`/generate?templateId=${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTemplate(id: number) {
|
||||||
|
await store.remove(id)
|
||||||
|
await refreshList()
|
||||||
|
message.success('模板已删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUpload() {
|
||||||
|
if (!uploadFile.value) return
|
||||||
|
await store.upload(uploadFile.value)
|
||||||
|
uploadOpen.value = false
|
||||||
|
router.push(`/templates/${store.currentTemplate?.id}/edit`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrap {
|
||||||
|
padding: 24px 32px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b626e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card {
|
||||||
|
border: 1px solid #eaecef;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: #7c7cdb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-icon {
|
||||||
|
color: #5b5bd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-icon.muted {
|
||||||
|
color: #9aa1ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-status {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-status.ready {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #1a8c4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-status.editing {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #d48a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-status.empty {
|
||||||
|
background: #f0f1f3;
|
||||||
|
color: #9aa1ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-card-create {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020", "module": "ESNext", "moduleResolution": "bundler",
|
||||||
|
"strict": true, "jsx": "preserve", "resolveJsonModule": true,
|
||||||
|
"isolatedModules": true, "esModuleInterop": true, "lib": ["ES2020", "DOM"],
|
||||||
|
"skipLibCheck": true, "noEmit": true,
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": { "composite": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true },
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': resolve(__dirname, 'src') }
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
'/uploads': { target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
'/outputs': { target: 'http://localhost:8000', changeOrigin: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user