init: 初始化项目

This commit is contained in:
zwt13703
2026-07-08 20:02:29 +08:00
parent 22590ae7b8
commit 1bb84df4ca
98 changed files with 8535 additions and 2 deletions
+4
View File
@@ -6,9 +6,13 @@
# TODO: where does this rule come from?
docs/_book
.idea
# TODO: where does this rule come from?
test/
**/venv/**
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
+77 -2
View File
@@ -1,3 +1,78 @@
# doc-forge-reborn
# AI 智能文档生成系统 (Doc Forge Reborn)
## 项目简介
Doc Forge Reborn 是一款外部 Web 工具,支持上传 Word 模板、在线编辑与预览,通过可视化方式标注文档中需要 AI 生成内容的区域,并可配置不同的 AI 模型进行内容生成。系统提供模型管理、模板管理、生成点管理、异步任务执行等完整功能,最终导出的文档保持原样式。
## 核心功能
- 📤 上传 Word 模板,在线编辑并预览
- 🎯 框选标注生成点,配置提示词、参考文件和指定 AI 模型
- 🤖 AI 模型管理(多供应商:OpenAI、Azure、自定义)
- ⚙️ 异步任务生成,状态追踪,结果下载
## 技术栈
- **前端**React 18 + TypeScript + Ant Design + TinyMCE
- **后端**Python 3.12 + FastAPI + SQLAlchemy
- **异步任务**Celery + Redis
- **数据库**PostgreSQL 16
- **文档处理**Mammoth + python-docx + reportlab
- **部署**Docker + Docker Compose + Nginx
## 快速开始
### 环境要求
- Docker & Docker Compose
- Python 3.12+ (本地开发)
- Node.js 22+ (本地开发)
### 生产部署(一键启动)
```bash
cp .env.example .env # 编辑 SECRET_KEY
./deploy.sh # 构建镜像 + 启动 + 数据库迁移
```
访问 `http://localhost:3000`
### 本地开发
```bash
# 1. 启动依赖服务
docker compose up -d postgres redis
对比 doc-forge 富文本编辑组件,文本标注ai块
# 2. 后端
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
alembic upgrade head
uvicorn app.main:app --reload
# 3. Celery Worker(新终端)
cd backend && source venv/bin/activate
celery -A app.tasks.celery_app worker --loglevel=info
# 4. 前端(新终端)
cd web && npm install && npm run dev
```
## 项目目录结构
```
.
├── backend/ # Python 后端
│ ├── app/
│ │ ├── api/ # API 路由
│ │ ├── core/ # 配置、安全、数据库
│ │ ├── models/ # ORM 模型
│ │ ├── schemas/ # Pydantic 模式
│ │ ├── services/ # 业务逻辑
│ │ ├── tasks/ # Celery 任务
│ │ └── main.py
│ ├── migrations/ # Alembic 迁移
│ ├── requirements.txt
│ └── Dockerfile
├── web/ # React 前端
│ ├── src/
│ │ ├── api/ # API 调用封装
│ │ ├── components/ # 可复用 UI
│ │ ├── pages/ # 页面
│ │ ├── hooks/ # 自定义 Hooks
│ │ └── store/ # 状态管理
│ ├── package.json
│ └── Dockerfile
├── docker-compose.yml
└── docs/ # 项目文档
```
## 文档索引
- [技术方案概述](docs/技术方案概述.md)
- [数据库设计](docs/数据库设计.md)
- [API 设计](docs/API设计.md)
- [任务拆解清单](docs/任务拆解清单.md)
- [部署指南](docs/部署指南.md)
+9
View File
@@ -0,0 +1,9 @@
venv/
__pycache__/
*.pyc
.env
.git
storage/
alembic/versions/
*.egg-info/
.pytest_cache/
+8
View File
@@ -0,0 +1,8 @@
{
"hash": "1e9afdbb",
"configHash": "ac7453e4",
"lockfileHash": "e3b0c442",
"browserHash": "29840d3b",
"optimized": {},
"chunks": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/storage/templates /app/storage/ref_files /app/storage/results
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/storage/templates /app/storage/ref_files /app/storage/results
CMD ["celery", "-A", "app.tasks.celery_app", "worker", "--loglevel=info", "--concurrency=4"]
+37
View File
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql://docforge:docforge@localhost:5432/docforge
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
View File
+51
View File
@@ -0,0 +1,51 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.core.config import get_settings
from app.models import Base
config = context.config
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+23
View File
@@ -0,0 +1,23 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,27 @@
"""Add selected_text to generation_points
Revision ID: 011bde04d871
Revises: 857f5a3874dd
Create Date: 2026-07-06 18:19:15.771438
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '011bde04d871'
down_revision: Union[str, None] = '857f5a3874dd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('generation_points', sa.Column('selected_text', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('generation_points', 'selected_text')
# ### end Alembic commands ###
@@ -0,0 +1,88 @@
"""Initial schema
Revision ID: 857f5a3874dd
Revises:
Create Date: 2026-07-06 16:42:37.845634
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = '857f5a3874dd'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('models',
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=False),
sa.Column('endpoint', sa.String(length=255), nullable=False),
sa.Column('api_key', sa.Text(), nullable=False),
sa.Column('extra_params', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('is_enabled', sa.Boolean(), nullable=False),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('system_config',
sa.Column('key', sa.String(length=50), nullable=False),
sa.Column('value', sa.Text(), nullable=True),
sa.Column('description', sa.String(length=200), nullable=True),
sa.PrimaryKeyConstraint('key')
)
op.create_table('templates',
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('file_path', sa.String(length=500), nullable=False),
sa.Column('html_content', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('generation_points',
sa.Column('template_id', sa.UUID(), nullable=False),
sa.Column('position', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('prompt', sa.Text(), nullable=False),
sa.Column('model_id', sa.UUID(), nullable=True),
sa.Column('ref_file_path', sa.String(length=500), nullable=True),
sa.Column('order', sa.Integer(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['model_id'], ['models.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['template_id'], ['templates.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_generation_points_template_id'), 'generation_points', ['template_id'], unique=False)
op.create_table('generation_tasks',
sa.Column('template_id', sa.UUID(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('result_file_path', sa.String(length=500), nullable=True),
sa.Column('error_msg', sa.Text(), nullable=True),
sa.Column('celery_task_id', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['template_id'], ['templates.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_generation_tasks_template_id'), 'generation_tasks', ['template_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_generation_tasks_template_id'), table_name='generation_tasks')
op.drop_table('generation_tasks')
op.drop_index(op.f('ix_generation_points_template_id'), table_name='generation_points')
op.drop_table('generation_points')
op.drop_table('templates')
op.drop_table('system_config')
op.drop_table('models')
# ### end Alembic commands ###
@@ -0,0 +1,33 @@
"""Change ref_file_path to Text for multi-file support
Revision ID: 9066ecf60786
Revises: b4b91d4466e4
Create Date: 2026-07-06 21:01:41.552375
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '9066ecf60786'
down_revision: Union[str, None] = 'b4b91d4466e4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('generation_points', 'ref_file_path',
existing_type=sa.VARCHAR(length=500),
type_=sa.Text(),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('generation_points', 'ref_file_path',
existing_type=sa.Text(),
type_=sa.VARCHAR(length=500),
existing_nullable=True)
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""Add need_ref_file and remark to generation_points
Revision ID: b4b91d4466e4
Revises: 011bde04d871
Create Date: 2026-07-06 18:27:03.054325
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'b4b91d4466e4'
down_revision: Union[str, None] = '011bde04d871'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('generation_points', sa.Column('need_ref_file', sa.Boolean(), nullable=False))
op.add_column('generation_points', sa.Column('remark', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('generation_points', 'remark')
op.drop_column('generation_points', 'need_ref_file')
# ### end Alembic commands ###
View File
View File
+128
View File
@@ -0,0 +1,128 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security_middleware import validate_file_extension
from app.models.generation_point import GenerationPoint
from app.models.template import Template
from app.models.ai_model import AIModel
from app.schemas.generation_point import (
GenerationPointCreate,
GenerationPointUpdate,
GenerationPointResponse,
BatchOrderUpdate,
)
from app.services.file_storage import save_upload, REF_FILES_DIR
router = APIRouter(prefix="/generation-points", tags=["生成点管理"])
@router.post("", response_model=GenerationPointResponse)
async def create_generation_point(
template_id: uuid.UUID = Form(...),
position: str = Form(...),
prompt: str = Form(...),
model_id: uuid.UUID | None = Form(None),
order: int = Form(0),
selected_text: str | None = Form(None),
need_ref_file: bool = Form(False),
remark: str | None = Form(None),
ref_file: UploadFile | None = File(None),
db: AsyncSession = Depends(get_db),
):
import json
template_result = await db.execute(select(Template).where(Template.id == template_id))
if not template_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="模板不存在")
if model_id:
model_result = await db.execute(select(AIModel).where(AIModel.id == model_id))
if not model_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="模型不存在")
ref_file_path = None
if ref_file and ref_file.filename:
validate_file_extension(ref_file.filename)
ref_file_path = await save_upload(ref_file, REF_FILES_DIR)
point = GenerationPoint(
template_id=template_id,
position=json.loads(position),
prompt=prompt,
model_id=model_id,
order=order,
ref_file_path=ref_file_path,
selected_text=selected_text,
need_ref_file=need_ref_file,
remark=remark,
)
db.add(point)
await db.flush()
await db.refresh(point)
return point
@router.get("", response_model=list[GenerationPointResponse])
async def list_generation_points(
template_id: uuid.UUID = Query(...),
db: AsyncSession = Depends(get_db),
):
query = (
select(GenerationPoint)
.where(GenerationPoint.template_id == template_id)
.order_by(GenerationPoint.order.asc(), GenerationPoint.created_at.asc())
)
result = await db.execute(query)
return result.scalars().all()
@router.get("/{point_id}", response_model=GenerationPointResponse)
async def get_generation_point(point_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
point = result.scalar_one_or_none()
if not point:
raise HTTPException(status_code=404, detail="生成点不存在")
return point
@router.put("/{point_id}", response_model=GenerationPointResponse)
async def update_generation_point(
point_id: str,
data: GenerationPointUpdate,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
point = result.scalar_one_or_none()
if not point:
raise HTTPException(status_code=404, detail="生成点不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(point, key, value)
await db.flush()
await db.refresh(point)
return point
@router.delete("/{point_id}")
async def delete_generation_point(point_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
point = result.scalar_one_or_none()
if not point:
raise HTTPException(status_code=404, detail="生成点不存在")
await db.delete(point)
return {"detail": "删除成功"}
@router.post("/batch-order")
async def batch_update_order(data: BatchOrderUpdate, db: AsyncSession = Depends(get_db)):
for item in data.points:
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == item["id"]))
point = result.scalar_one_or_none()
if point:
point.order = item["order"]
await db.flush()
return {"detail": "排序更新成功"}
+115
View File
@@ -0,0 +1,115 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import encrypt_api_key
from app.models.ai_model import AIModel
from app.schemas.ai_model import AIModelCreate, AIModelUpdate, AIModelToggle, AIModelResponse
from app.services.ai_adapter import call_ai_model
router = APIRouter(prefix="/models", tags=["AI模型管理"])
@router.post("", response_model=AIModelResponse)
async def create_model(data: AIModelCreate, db: AsyncSession = Depends(get_db)):
encrypted_key = encrypt_api_key(data.api_key)
model = AIModel(
name=data.name,
provider=data.provider,
endpoint=data.endpoint,
api_key=encrypted_key,
extra_params=data.extra_params,
is_enabled=data.is_enabled,
remark=data.remark,
)
db.add(model)
await db.flush()
await db.refresh(model)
return model
@router.get("", response_model=list[AIModelResponse])
async def list_models(
enabled: bool | None = Query(None, description="过滤启用/禁用"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
query = select(AIModel)
if enabled is not None:
query = query.where(AIModel.is_enabled == enabled)
query = query.offset(skip).limit(limit).order_by(AIModel.created_at.desc())
result = await db.execute(query)
models = result.scalars().all()
return models
@router.get("/{model_id}", response_model=AIModelResponse)
async def get_model(model_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
return model
@router.put("/{model_id}", response_model=AIModelResponse)
async def update_model(model_id: str, data: AIModelUpdate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
update_data = data.model_dump(exclude_unset=True)
if "api_key" in update_data and update_data["api_key"] is not None:
update_data["api_key"] = encrypt_api_key(update_data["api_key"])
for key, value in update_data.items():
setattr(model, key, value)
await db.flush()
await db.refresh(model)
return model
@router.delete("/{model_id}")
async def delete_model(model_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
await db.delete(model)
return {"detail": "删除成功"}
@router.patch("/{model_id}/toggle", response_model=AIModelResponse)
async def toggle_model(model_id: str, data: AIModelToggle, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
model.is_enabled = data.is_enabled
await db.flush()
await db.refresh(model)
return model
@router.post("/{model_id}/test")
async def test_model(model_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
model_config = {
"provider": model.provider,
"endpoint": model.endpoint,
"api_key": model.api_key,
"extra_params": model.extra_params,
}
try:
response = await call_ai_model(model_config, "请用一句话介绍你自己。")
return {"success": True, "result": response}
except Exception as e:
return {"success": False, "error": str(e)}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from app.api.models import router as models_router
from app.api.templates import router as templates_router
from app.api.generation_points import router as generation_points_router
from app.api.tasks import router as tasks_router
from app.api.settings import router as settings_router
from app.core.config import get_settings
settings = get_settings()
api_router = APIRouter(prefix=settings.API_V1_PREFIX)
api_router.include_router(models_router)
api_router.include_router(templates_router)
api_router.include_router(generation_points_router)
api_router.include_router(tasks_router, prefix="")
api_router.include_router(settings_router)
+34
View File
@@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.models.system_config import SystemConfig
router = APIRouter(prefix="/settings", tags=["系统设置"])
@router.get("")
async def get_all_settings(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(SystemConfig))
configs = result.scalars().all()
return {c.key: c.value for c in configs}
@router.put("/{key}")
async def update_setting(key: str, body: dict, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key))
config = result.scalar_one_or_none()
value = body.get("value", "")
description = body.get("description", "")
if config:
config.value = value
if description:
config.description = description
else:
config = SystemConfig(key=key, value=value, description=description)
db.add(config)
await db.flush()
return {"key": key, "value": value}
+233
View File
@@ -0,0 +1,233 @@
import json
import os
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import Response
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security_middleware import validate_file_extension
from app.models.generation_task import GenerationTask
from app.models.generation_point import GenerationPoint
from app.models.template import Template
from app.models.ai_model import AIModel
from app.schemas.generation_task import TaskResponse, GenerateResponse, SingleTestRequest, SingleTestResponse, TaskDetailResponse
from app.services.document_processor import docx_to_pdf_bytes
from app.services.file_storage import get_file_content, save_upload, REF_FILES_DIR
from app.services.ai_adapter import call_ai_model
from app.services.ref_parser import parse_reference_file, parse_reference_files
from app.tasks.generate import generate_document
router = APIRouter()
@router.post("/templates/{template_id}/generate", response_model=GenerateResponse)
async def trigger_generation(template_id: str, db: AsyncSession = Depends(get_db)):
template_result = await db.execute(select(Template).where(Template.id == template_id))
if not template_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="模板不存在")
points_result = await db.execute(
select(GenerationPoint).where(GenerationPoint.template_id == template_id)
)
points = points_result.scalars().all()
if not points:
raise HTTPException(status_code=400, detail="模板没有生成点")
task = GenerationTask(
template_id=uuid.UUID(template_id),
status="pending",
created_at=datetime.now(timezone.utc),
)
db.add(task)
await db.flush()
celery_task = generate_document.delay(str(task.id))
task.celery_task_id = celery_task.id
await db.commit()
return GenerateResponse(task_id=task.id, status="pending")
@router.get("/tasks/{task_id}")
async def get_task_status(task_id: str, detail: bool = Query(False), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if detail:
tpl_result = await db.execute(select(Template).where(Template.id == task.template_id))
tpl = tpl_result.scalar_one_or_none()
points_result = await db.execute(
select(GenerationPoint).where(GenerationPoint.template_id == task.template_id).order_by(GenerationPoint.order.asc())
)
points = points_result.scalars().all()
return TaskDetailResponse(
id=task.id,
template_id=task.template_id,
template_name=tpl.name if tpl else "",
status=task.status,
result_file_path=task.result_file_path,
error_msg=task.error_msg,
created_at=task.created_at,
finished_at=task.finished_at,
points=[{
"id": str(p.id),
"prompt": p.prompt,
"position": p.position,
"model_id": str(p.model_id) if p.model_id else None,
"ref_file_path": p.ref_file_path,
"selected_text": p.selected_text,
"need_ref_file": p.need_ref_file,
"remark": p.remark,
"order": p.order,
} for p in points],
)
return task
@router.get("/tasks/{task_id}/download")
async def download_task_result(
task_id: str,
format: str = Query("docx", pattern="^(docx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status != "done" or not task.result_file_path:
raise HTTPException(status_code=400, detail="任务未完成或无结果文件")
file_content = await get_file_content(task.result_file_path)
if format == "pdf":
file_content = docx_to_pdf_bytes(file_content)
media_type = "application/pdf"
filename = f"result_{task_id}.pdf"
else:
media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
filename = f"result_{task_id}.docx"
return Response(
content=file_content,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/tasks")
async def list_tasks(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
query = select(GenerationTask).offset(skip).limit(limit).order_by(GenerationTask.created_at.desc())
result = await db.execute(query)
return result.scalars().all()
@router.post("/tasks/{task_id}/cancel")
async def cancel_task(task_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status not in ("pending", "processing"):
raise HTTPException(status_code=400, detail="任务无法取消")
if task.celery_task_id:
from app.tasks.celery_app import celery_app
celery_app.control.revoke(task.celery_task_id, terminate=True)
task.status = "failed"
task.error_msg = "用户取消"
task.finished_at = datetime.now(timezone.utc)
await db.commit()
return {"detail": "任务已取消"}
@router.post("/generation-points/{point_id}/test", response_model=SingleTestResponse)
async def test_single_point(
point_id: str,
ref_files: List[UploadFile] = File(default=[]),
db: AsyncSession = Depends(get_db),
):
point_result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
point = point_result.scalar_one_or_none()
if not point:
raise HTTPException(status_code=404, detail="生成点不存在")
tmp_paths = []
if point.need_ref_file:
if not ref_files and not point.ref_file_path:
raise HTTPException(status_code=400, detail="此生成点需要上传参考文件")
for f in ref_files:
if f.filename:
validate_file_extension(f.filename)
path = await save_upload(f, REF_FILES_DIR)
tmp_paths.append(path)
model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}}
if point.model_id:
model_result = await db.execute(select(AIModel).where(AIModel.id == point.model_id))
model = model_result.scalar_one_or_none()
if model:
model_config = {
"provider": model.provider,
"endpoint": model.endpoint,
"api_key": model.api_key,
"extra_params": model.extra_params,
}
ref_content = ""
if tmp_paths:
ref_content = await parse_reference_files(json.dumps(tmp_paths))
elif point.ref_file_path:
ref_content = await parse_reference_files(point.ref_file_path)
try:
ai_result = await call_ai_model(model_config, point.prompt, ref_content)
return SingleTestResponse(result=ai_result)
except Exception as e:
raise HTTPException(status_code=500, detail=f"AI 调用失败: {str(e)}")
@router.post("/generation-points/{point_id}/upload-ref")
async def upload_ref_files(
point_id: str,
ref_files: List[UploadFile] = File(...),
db: AsyncSession = Depends(get_db),
):
point_result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
point = point_result.scalar_one_or_none()
if not point:
raise HTTPException(status_code=404, detail="生成点不存在")
paths = []
for f in ref_files:
if f.filename:
validate_file_extension(f.filename)
path = await save_upload(f, REF_FILES_DIR)
paths.append(path)
existing = []
if point.ref_file_path:
try:
existing = json.loads(point.ref_file_path)
if not isinstance(existing, list):
existing = [point.ref_file_path]
except json.JSONDecodeError:
existing = [point.ref_file_path] if point.ref_file_path else []
all_paths = existing + paths
point.ref_file_path = json.dumps(all_paths)
await db.commit()
return {"success": True, "count": len(all_paths)}
+150
View File
@@ -0,0 +1,150 @@
import uuid
import bleach
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security_middleware import validate_file_extension
from app.models.template import Template
from app.schemas.template import TemplateResponse, TemplateListItem, HTMLContentResponse, HTMLUpdateRequest
from app.services.file_storage import save_upload, get_file_content, delete_file, TEMPLATES_DIR
from app.services.document_processor import docx_to_html, html_to_docx_bytes, docx_to_pdf_bytes
ALLOWED_TAGS = [
"p", "div", "span", "br", "hr",
"h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li",
"a", "img", "table", "thead", "tbody", "tr", "td", "th",
"b", "i", "u", "strong", "em", "del", "sub", "sup",
"pre", "code", "blockquote",
]
ALLOWED_ATTRS = {
"a": ["href", "title", "target"],
"img": ["src", "alt", "width", "height"],
"td": ["colspan", "rowspan"],
"th": ["colspan", "rowspan"],
"p": ["style"],
"span": ["style"],
"div": ["style"],
"table": ["style"],
}
router = APIRouter(prefix="/templates", tags=["模板管理"])
@router.post("", response_model=TemplateResponse)
async def upload_template(
file: UploadFile = File(...),
name: str | None = Form(None),
db: AsyncSession = Depends(get_db),
):
if not file.filename or not file.filename.endswith(".docx"):
raise HTTPException(status_code=400, detail="仅支持 .docx 文件")
validate_file_extension(file.filename)
file_path = await save_upload(file, TEMPLATES_DIR)
file_content = await get_file_content(file_path)
html_content = await docx_to_html(file_content)
template = Template(
name=name or file.filename.replace(".docx", ""),
file_path=file_path,
html_content=html_content,
)
db.add(template)
await db.flush()
await db.refresh(template)
return template
@router.get("", response_model=list[TemplateListItem])
async def list_templates(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
query = select(Template).offset(skip).limit(limit).order_by(Template.created_at.desc())
result = await db.execute(query)
return result.scalars().all()
@router.get("/{template_id}", response_model=TemplateResponse)
async def get_template(template_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Template).where(Template.id == template_id))
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.get("/{template_id}/html", response_model=HTMLContentResponse)
async def get_template_html(template_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Template).where(Template.id == template_id))
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return HTMLContentResponse(html_content=template.html_content or "")
@router.put("/{template_id}/html")
async def update_template_html(
template_id: str,
data: HTMLUpdateRequest,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Template).where(Template.id == template_id))
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template.html_content = bleach.clean(
data.html_content, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True
)
docx_bytes = html_to_docx_bytes(template.html_content)
with open(template.file_path, "wb") as f:
f.write(docx_bytes)
await db.flush()
return {"detail": "保存成功"}
@router.get("/{template_id}/download")
async def download_template(
template_id: str,
format: str = Query("docx", pattern="^(docx|pdf)$"),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Template).where(Template.id == template_id))
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
file_content = await get_file_content(template.file_path)
if format == "pdf":
file_content = docx_to_pdf_bytes(file_content)
media_type = "application/pdf"
filename = f"{template.name}.pdf"
else:
media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
filename = f"{template.name}.docx"
return Response(
content=file_content,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/{template_id}")
async def delete_template(template_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Template).where(Template.id == template_id))
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
delete_file(template.file_path)
await db.delete(template)
return {"detail": "删除成功"}
View File
+47
View File
@@ -0,0 +1,47 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
PROJECT_NAME: str = "Doc Forge Reborn"
API_V1_PREFIX: str = "/api/v1"
DEBUG: bool = False
POSTGRES_USER: str = "docforge"
POSTGRES_PASSWORD: str = "docforge"
POSTGRES_DB: str = "docforge"
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int = 5432
REDIS_URL: str = "redis://localhost:6379/0"
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
SECRET_KEY: str = "change-me-in-production"
DEFAULT_MODEL_ID: str = ""
STORAGE_ROOT: str = "./storage"
MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
@property
def DATABASE_URL(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@property
def DATABASE_URL_SYNC(self) -> str:
return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "case_sensitive": True}
@lru_cache
def get_settings() -> Settings:
return Settings()
+35
View File
@@ -0,0 +1,35 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+25
View File
@@ -0,0 +1,25 @@
import base64
import hashlib
from cryptography.fernet import Fernet
from app.core.config import get_settings
def _derive_fernet_key(secret: str) -> bytes:
digest = hashlib.sha256(secret.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
def _get_fernet() -> Fernet:
settings = get_settings()
key = _derive_fernet_key(settings.SECRET_KEY)
return Fernet(key)
def encrypt_api_key(api_key: str) -> str:
f = _get_fernet()
return f.encrypt(api_key.encode("utf-8")).decode("utf-8")
def decrypt_api_key(encrypted_key: str) -> str:
f = _get_fernet()
return f.decrypt(encrypted_key.encode("utf-8")).decode("utf-8")
+32
View File
@@ -0,0 +1,32 @@
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import get_settings
settings = get_settings()
class SecurityMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.method in ("POST", "PUT", "PATCH"):
content_length = request.headers.get("content-length")
if content_length and int(content_length) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=413,
detail=f"文件大小不能超过 {settings.MAX_UPLOAD_SIZE // 1024 // 1024}MB",
)
response = await call_next(request)
return response
ALLOWED_EXTENSIONS = {".docx", ".txt", ".pdf"}
def validate_file_extension(filename: str) -> str:
import os
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: {ext},允许的类型: {', '.join(ALLOWED_EXTENSIONS)}",
)
return ext
+29
View File
@@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import get_settings
from app.core.security_middleware import SecurityMiddleware
from app.api.router import api_router
settings = get_settings()
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(SecurityMiddleware)
app.include_router(api_router)
@app.get("/health")
async def health_check():
return {"status": "ok", "version": "0.1.0"}
+17
View File
@@ -0,0 +1,17 @@
from app.models.base import Base, TimestampMixin, UUIDMixin
from app.models.ai_model import AIModel
from app.models.template import Template
from app.models.generation_point import GenerationPoint
from app.models.generation_task import GenerationTask
from app.models.system_config import SystemConfig
__all__ = [
"Base",
"TimestampMixin",
"UUIDMixin",
"AIModel",
"Template",
"GenerationPoint",
"GenerationTask",
"SystemConfig",
]
+18
View File
@@ -0,0 +1,18 @@
import uuid
from sqlalchemy import Boolean, String, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
class AIModel(Base, UUIDMixin, TimestampMixin):
__tablename__ = "models"
name: Mapped[str] = mapped_column(String(100), nullable=False)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
endpoint: Mapped[str] = mapped_column(String(255), nullable=False)
api_key: Mapped[str] = mapped_column(Text, nullable=False)
extra_params: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
+17
View File
@@ -0,0 +1,17 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class UUIDMixin:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+27
View File
@@ -0,0 +1,27 @@
import uuid
from sqlalchemy import Integer, String, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
class GenerationPoint(Base, UUIDMixin, TimestampMixin):
__tablename__ = "generation_points"
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("templates.id", ondelete="CASCADE"), nullable=False, index=True
)
position: Mapped[dict] = mapped_column(JSONB, nullable=False)
prompt: Mapped[str] = mapped_column(Text, nullable=False)
model_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("models.id", ondelete="SET NULL"), nullable=True
)
ref_file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
need_ref_file: Mapped[bool] = mapped_column(default=False, nullable=False)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
selected_text: Mapped[str | None] = mapped_column(Text, nullable=True)
template = relationship("Template")
model = relationship("AIModel")
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import UUIDMixin
class GenerationTask(Base, UUIDMixin):
__tablename__ = "generation_tasks"
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("templates.id", ondelete="CASCADE"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
result_file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
celery_task_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
template = relationship("Template")
+11
View File
@@ -0,0 +1,11 @@
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class SystemConfig(Base):
__tablename__ = "system_config"
key: Mapped[str] = mapped_column(String(50), primary_key=True)
value: Mapped[str] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(String(200), nullable=True)
+14
View File
@@ -0,0 +1,14 @@
import uuid
from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
class Template(Base, UUIDMixin, TimestampMixin):
__tablename__ = "templates"
name: Mapped[str] = mapped_column(String(200), nullable=False)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
html_content: Mapped[str | None] = mapped_column(Text, nullable=True)
View File
+46
View File
@@ -0,0 +1,46 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field, field_serializer
class AIModelCreate(BaseModel):
name: str = Field(..., max_length=100)
provider: str = Field(..., max_length=50)
endpoint: str = Field(..., max_length=255)
api_key: str
extra_params: dict = Field(default_factory=dict)
is_enabled: bool = True
remark: str | None = None
class AIModelUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
provider: str | None = Field(None, max_length=50)
endpoint: str | None = Field(None, max_length=255)
api_key: str | None = None
extra_params: dict | None = None
is_enabled: bool | None = None
remark: str | None = None
class AIModelToggle(BaseModel):
is_enabled: bool
class AIModelResponse(BaseModel):
id: uuid.UUID
name: str
provider: str
endpoint: str
api_key: str
extra_params: dict
is_enabled: bool
remark: str | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
@field_serializer("api_key")
def mask_api_key(self, value: str) -> str:
return "***"
+45
View File
@@ -0,0 +1,45 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class GenerationPointCreate(BaseModel):
template_id: uuid.UUID
position: dict = Field(..., description="选区位置信息")
prompt: str
model_id: uuid.UUID | None = None
order: int = 0
selected_text: str | None = None
need_ref_file: bool = False
remark: str | None = None
class GenerationPointUpdate(BaseModel):
position: dict | None = None
prompt: str | None = None
model_id: uuid.UUID | None = None
order: int | None = None
selected_text: str | None = None
need_ref_file: bool | None = None
remark: str | None = None
class GenerationPointResponse(BaseModel):
id: uuid.UUID
template_id: uuid.UUID
position: dict
prompt: str
model_id: uuid.UUID | None = None
ref_file_path: str | None = None
need_ref_file: bool = False
remark: str | None = None
order: int
selected_text: str | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class BatchOrderUpdate(BaseModel):
points: list[dict] = Field(..., description="[{id: uuid, order: int}, ...]")
+44
View File
@@ -0,0 +1,44 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class TaskResponse(BaseModel):
id: uuid.UUID
template_id: uuid.UUID
status: str
result_file_path: str | None = None
error_msg: str | None = None
created_at: datetime
finished_at: datetime | None = None
model_config = {"from_attributes": True}
class TaskDetailResponse(BaseModel):
id: uuid.UUID
template_id: uuid.UUID
template_name: str = ""
status: str
result_file_path: str | None = None
error_msg: str | None = None
created_at: datetime
finished_at: datetime | None = None
points: list[dict] = []
model_config = {"from_attributes": True}
class GenerateResponse(BaseModel):
task_id: uuid.UUID
status: str
class SingleTestRequest(BaseModel):
prompt: str
model_id: uuid.UUID | None = None
ref_file_path: str | None = None
class SingleTestResponse(BaseModel):
result: str
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class TemplateResponse(BaseModel):
id: uuid.UUID
name: str
file_path: str
html_content: str | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TemplateListItem(BaseModel):
id: uuid.UUID
name: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class HTMLContentResponse(BaseModel):
html_content: str
class HTMLUpdateRequest(BaseModel):
html_content: str
View File
+181
View File
@@ -0,0 +1,181 @@
import json
from abc import ABC, abstractmethod
import httpx
from app.core.security import decrypt_api_key
PROVIDER_OPENAI = "openai"
PROVIDER_AZURE = "azure"
PROVIDER_CUSTOM = "custom"
class AIAdapter(ABC):
@abstractmethod
def build_request(self, model_config: dict, prompt: str, ref_content: str | None = None) -> dict:
pass
@abstractmethod
def parse_response(self, response_data: dict) -> str:
pass
@property
@abstractmethod
def provider(self) -> str:
pass
class OpenAIAdapter(AIAdapter):
@property
def provider(self) -> str:
return PROVIDER_OPENAI
def build_request(self, model_config: dict, prompt: str, ref_content: str | None = None) -> dict:
extra = model_config.get("extra_params", {})
temperature = extra.get("temperature", 0.7)
max_tokens = extra.get("max_tokens", 2000)
messages = [{"role": "system", "content": "你是一个专业的文档内容生成助手。"}]
user_content = prompt
if ref_content:
user_content = f"参考以下内容:\n{ref_content}\n\n任务:{prompt}"
messages.append({"role": "user", "content": user_content})
return {
"url": model_config["endpoint"],
"headers": {
"Authorization": f"Bearer {decrypt_api_key(model_config['api_key'])}",
"Content-Type": "application/json",
},
"json": {
"model": extra.get("model", "gpt-4"),
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
},
}
def parse_response(self, response_data: dict) -> str:
return response_data["choices"][0]["message"]["content"]
class AzureAdapter(AIAdapter):
@property
def provider(self) -> str:
return PROVIDER_AZURE
def build_request(self, model_config: dict, prompt: str, ref_content: str | None = None) -> dict:
extra = model_config.get("extra_params", {})
temperature = extra.get("temperature", 0.7)
max_tokens = extra.get("max_tokens", 2000)
messages = [{"role": "system", "content": "你是一个专业的文档内容生成助手。"}]
user_content = prompt
if ref_content:
user_content = f"参考以下内容:\n{ref_content}\n\n任务:{prompt}"
messages.append({"role": "user", "content": user_content})
api_version = extra.get("api_version", "2024-02-15-preview")
endpoint = model_config["endpoint"]
url = f"{endpoint}?api-version={api_version}"
return {
"url": url,
"headers": {
"api-key": decrypt_api_key(model_config["api_key"]),
"Content-Type": "application/json",
},
"json": {
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
},
}
def parse_response(self, response_data: dict) -> str:
return response_data["choices"][0]["message"]["content"]
class CustomAdapter(AIAdapter):
@property
def provider(self) -> str:
return PROVIDER_CUSTOM
def build_request(self, model_config: dict, prompt: str, ref_content: str | None = None) -> dict:
extra = model_config.get("extra_params", {})
user_content = prompt
if ref_content:
user_content = f"参考以下内容:\n{ref_content}\n\n任务:{prompt}"
messages = [{"role": "system", "content": "你是一个专业的文档内容生成助手。"}]
messages.append({"role": "user", "content": user_content})
body = {
"model": extra.get("model", "gpt-3.5-turbo"),
"messages": messages,
"max_tokens": extra.get("max_tokens", 2000),
"temperature": extra.get("temperature", 0.7),
}
body.update({k: v for k, v in extra.items() if k not in ("model", "messages", "max_tokens", "temperature")})
return {
"url": model_config["endpoint"],
"headers": {
"Authorization": f"Bearer {decrypt_api_key(model_config['api_key'])}",
"Content-Type": "application/json",
},
"json": body,
}
def parse_response(self, response_data: dict) -> str:
if "choices" in response_data:
return response_data["choices"][0]["message"]["content"]
if "response" in response_data:
return response_data["response"]
if "content" in response_data:
return response_data["content"]
if "text" in response_data:
return response_data["text"]
return json.dumps(response_data)
_adapters: dict[str, AIAdapter] = {
PROVIDER_OPENAI: OpenAIAdapter(),
PROVIDER_AZURE: AzureAdapter(),
PROVIDER_CUSTOM: CustomAdapter(),
}
def get_adapter(provider: str) -> AIAdapter:
adapter = _adapters.get(provider)
if not adapter:
raise ValueError(f"不支持的供应商: {provider}")
return adapter
async def call_ai_model(model_config: dict, prompt: str, ref_content: str | None = None) -> str:
adapter = get_adapter(model_config["provider"])
request = adapter.build_request(model_config, prompt, ref_content)
timeout = model_config.get("extra_params", {}).get("timeout", 120)
try:
async with httpx.AsyncClient(
timeout=timeout,
proxy=None,
trust_env=False,
) as client:
response = await client.post(
request["url"],
headers=request["headers"],
json=request["json"],
)
response.raise_for_status()
return adapter.parse_response(response.json())
except httpx.HTTPStatusError as e:
detail = e.response.text[:500] if e.response else str(e)
raise RuntimeError(f"AI 服务返回错误 ({e.response.status_code}): {detail}")
except httpx.TimeoutException:
raise RuntimeError("AI 调用超时")
except httpx.ConnectError as e:
raise RuntimeError(f"无法连接 AI 服务: {e}")
except Exception as e:
raise RuntimeError(f"AI 调用异常: {str(e)}")
+168
View File
@@ -0,0 +1,168 @@
import mammoth
from io import BytesIO
from bs4 import BeautifulSoup
from docx import Document
from docx.shared import Pt, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from app.services.file_storage import get_file_content
ALIGN_MAP = {
WD_ALIGN_PARAGRAPH.CENTER: "center",
WD_ALIGN_PARAGRAPH.RIGHT: "right",
WD_ALIGN_PARAGRAPH.JUSTIFY: "justify",
}
async def docx_to_html(file_content: bytes) -> str:
result = mammoth.convert_to_html(BytesIO(file_content))
html = result.value
try:
doc = Document(BytesIO(file_content))
soup = BeautifulSoup(html, "html.parser")
paragraphs = doc.paragraphs
html_blocks = soup.find_all(["p", "h1", "h2", "h3", "h4", "h5", "h6", "li"])
for i, para in enumerate(paragraphs):
if i >= len(html_blocks):
break
if para.alignment and para.alignment in ALIGN_MAP:
css = ALIGN_MAP[para.alignment]
existing = html_blocks[i].get("style", "")
styles = f"text-align:{css}"
if existing:
styles = existing.rstrip(";") + ";" + styles
html_blocks[i]["style"] = styles
html = str(soup)
except Exception:
pass
return html
def html_to_docx_bytes(html_content: str) -> bytes:
from html.parser import HTMLParser
doc = Document()
style = doc.styles["Normal"]
font = style.font
font.name = "Arial"
font.size = Pt(11)
class RichHTMLParser(HTMLParser):
def __init__(self):
super().__init__()
self.paragraphs: list[dict] = []
self.current = {"runs": [], "align": None}
self.in_paragraph = False
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
self.tag_stack: list[str] = []
self.heading_level = 0
def handle_starttag(self, tag, attrs):
tag_lower = tag.lower()
attrs_dict = dict(attrs)
if tag_lower in ("p", "div", "li"):
self.in_paragraph = True
self.current = {"runs": [], "align": None}
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
style = attrs_dict.get("style", "")
if "text-align:center" in style:
self.current["align"] = "center"
elif "text-align:right" in style:
self.current["align"] = "right"
elif "text-align:justify" in style:
self.current["align"] = "justify"
elif tag_lower in ("h1", "h2", "h3", "h4", "h5", "h6"):
self.in_paragraph = True
self.heading_level = int(tag_lower[1])
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
elif tag_lower in ("strong", "b"):
self.current_run["bold"] = True
elif tag_lower in ("em", "i"):
self.current_run["italic"] = True
elif tag_lower == "u":
self.current_run["underline"] = True
elif tag_lower in ("br",):
if self.in_paragraph:
self.current["runs"].append(dict(self.current_run))
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
self.tag_stack.append(tag_lower)
def handle_endtag(self, tag):
tag_lower = tag.lower()
if tag_lower in ("p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6"):
if self.current_run["text"].strip():
self.current["runs"].append(dict(self.current_run))
if self.current["runs"]:
p = dict(self.current)
p["heading"] = self.heading_level
self.paragraphs.append(p)
self.current = {"runs": []}
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
self.in_paragraph = False
self.heading_level = 0
if self.tag_stack:
self.tag_stack.pop()
def handle_data(self, data):
if self.in_paragraph:
self.current_run["text"] += data
parser = RichHTMLParser()
parser.feed(html_content)
for para_data in parser.paragraphs:
heading = para_data.get("heading", 0)
if heading > 0:
p = doc.add_heading(level=min(heading, 9))
else:
p = doc.add_paragraph()
align_val = para_data.get("align")
if align_val == "center":
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
elif align_val == "right":
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
elif align_val == "justify":
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
for run_data in para_data.get("runs", []):
run = p.add_run(run_data["text"])
if run_data.get("bold"):
run.bold = True
if run_data.get("italic"):
run.italic = True
if run_data.get("underline"):
run.underline = True
output = BytesIO()
doc.save(output)
return output.getvalue()
def docx_to_pdf_bytes(file_content: bytes) -> bytes:
doc = Document(BytesIO(file_content))
from io import BytesIO as Bio
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.lib.enums import TA_LEFT
buffer = Bio()
pdf_doc = SimpleDocTemplate(buffer, pagesize=A4)
styles = getSampleStyleSheet()
story = []
for para in doc.paragraphs:
if para.text.strip():
p = Paragraph(para.text, styles["Normal"])
story.append(p)
story.append(Spacer(1, 6))
pdf_doc.build(story)
return buffer.getvalue()
+43
View File
@@ -0,0 +1,43 @@
import os
import aiofiles
from pathlib import Path
from fastapi import UploadFile
from app.core.config import get_settings
settings = get_settings()
TEMPLATES_DIR = "templates"
REF_FILES_DIR = "ref_files"
RESULTS_DIR = "results"
def _ensure_dir(path: str) -> str:
os.makedirs(path, exist_ok=True)
return path
def get_storage_dir(subdir: str) -> str:
return _ensure_dir(os.path.join(settings.STORAGE_ROOT, subdir))
async def save_upload(upload_file: UploadFile, subdir: str) -> str:
dir_path = get_storage_dir(subdir)
file_path = os.path.join(dir_path, upload_file.filename or "unnamed")
async with aiofiles.open(file_path, "wb") as f:
content = await upload_file.read()
await f.write(content)
return file_path
async def get_file_content(file_path: str) -> bytes:
async with aiofiles.open(file_path, "rb") as f:
return await f.read()
def delete_file(file_path: str) -> None:
if os.path.exists(file_path):
os.remove(file_path)
def get_absolute_path(rel_path: str) -> str:
return os.path.abspath(rel_path)
+46
View File
@@ -0,0 +1,46 @@
import os
import json
from docx import Document
from PyPDF2 import PdfReader
from app.services.file_storage import get_file_content
async def parse_reference_file(file_path: str) -> str:
ext = os.path.splitext(file_path)[1].lower()
content = await get_file_content(file_path)
if ext == ".txt":
return content.decode("utf-8", errors="ignore")
if ext == ".docx":
from io import BytesIO
doc = Document(BytesIO(content))
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
if ext == ".pdf":
from io import BytesIO
reader = PdfReader(BytesIO(content))
return "\n".join(page.extract_text() or "" for page in reader.pages)
raise ValueError(f"不支持的文件格式: {ext}")
async def parse_reference_files(ref_file_path: str | None) -> str:
if not ref_file_path:
return ""
try:
paths = json.loads(ref_file_path)
if isinstance(paths, list):
texts = []
for path in paths:
try:
texts.append(await parse_reference_file(path))
except Exception:
texts.append(f"[无法解析文件: {path}]")
return "\n\n".join(texts)
except json.JSONDecodeError:
pass
try:
return await parse_reference_file(ref_file_path)
except Exception:
return f"[无法解析文件: {ref_file_path}]"
View File
+25
View File
@@ -0,0 +1,25 @@
from celery import Celery
from app.core.config import get_settings
settings = get_settings()
celery_app = Celery(
"doc_forge_reds",
broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND,
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="Asia/Shanghai",
enable_utc=True,
task_track_started=True,
task_acks_late=True,
worker_prefetch_multiplier=1,
task_soft_time_limit=600,
task_time_limit=900,
)
celery_app.autodiscover_tasks(["app.tasks.generate"])
+225
View File
@@ -0,0 +1,225 @@
import asyncio
import os
from io import BytesIO
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from docx import Document
from app.tasks.celery_app import celery_app
from app.core.config import get_settings
from app.models.generation_task import GenerationTask
from app.models.generation_point import GenerationPoint
from app.models.template import Template
from app.services.ai_adapter import call_ai_model
from app.services.ref_parser import parse_reference_files
from app.services.file_storage import get_storage_dir, get_file_content, RESULTS_DIR
def _create_db_session() -> async_sessionmaker[AsyncSession]:
settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
pool_size=5,
max_overflow=5,
pool_pre_ping=True,
)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
def _strip_html(html: str) -> str:
from html.parser import HTMLParser
class Stripper(HTMLParser):
def __init__(self):
super().__init__()
self.text = ""
def handle_data(self, data):
self.text += data
s = Stripper()
s.feed(html)
return s.text
def _get_selected_text(html_content: str, position: dict) -> str:
start = position.get("start", 0)
end = position.get("end", 0)
plain_text = _strip_html(html_content)
if 0 <= start < end <= len(plain_text):
return plain_text[start:end].strip()
return ""
def _replace_text_in_docx(doc: Document, old_text: str, new_text: str) -> bool:
if not old_text:
return False
for paragraph in doc.paragraphs:
if old_text in paragraph.text:
inline = paragraph.runs
for run in inline:
if old_text in run.text:
run.text = run.text.replace(old_text, new_text)
return True
full_text = "".join(r.text for r in inline)
if old_text in full_text:
remaining = old_text
for run in inline:
if not remaining:
break
if remaining.startswith(run.text):
remaining = remaining[len(run.text):]
elif run.text in remaining:
idx = remaining.find(run.text)
if idx >= 0:
remaining = remaining[:idx] + remaining[idx + len(run.text):]
if remaining.startswith(run.text):
remaining = remaining[len(run.text):]
if not remaining:
chunk_parts = new_text
for run in inline:
if chunk_parts:
chunk_parts = chunk_parts[len(run.text):]
first_run = inline[0]
first_run.text = new_text
for run in inline[1:]:
run.text = ""
return True
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
if old_text in paragraph.text:
for run in paragraph.runs:
if old_text in run.text:
run.text = run.text.replace(old_text, new_text)
return True
return False
async def _generate_document(task_id: str) -> None:
session_factory = _create_db_session()
async with session_factory() as db:
task_result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
task = task_result.scalar_one_or_none()
if not task:
return
task.status = "processing"
await db.commit()
template_result = await db.execute(select(Template).where(Template.id == task.template_id))
template = template_result.scalar_one_or_none()
if not template:
task.status = "failed"
task.error_msg = "模板不存在"
task.finished_at = datetime.now(timezone.utc)
await db.commit()
return
if not os.path.exists(template.file_path):
task.status = "failed"
task.error_msg = f"原始文件不存在: {template.file_path}"
task.finished_at = datetime.now(timezone.utc)
await db.commit()
return
docx_content = await get_file_content(template.file_path)
doc = Document(BytesIO(docx_content))
html_content = template.html_content or ""
points_result = await db.execute(
select(GenerationPoint)
.where(GenerationPoint.template_id == task.template_id)
.order_by(GenerationPoint.order.asc())
)
points = points_result.scalars().all()
try:
# 先收集所有 AI 调用参数
point_data = []
for point in points:
selected_text = point.selected_text or _get_selected_text(html_content, point.position)
if not selected_text:
continue
ref_content = ""
if point.ref_file_path:
ref_content = await parse_reference_files(point.ref_file_path)
model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}}
if point.model_id:
from app.models.ai_model import AIModel
model_result = await db.execute(select(AIModel).where(AIModel.id == point.model_id))
model = model_result.scalar_one_or_none()
if model:
model_config = {
"provider": model.provider,
"endpoint": model.endpoint,
"api_key": model.api_key,
"extra_params": model.extra_params,
}
point_data.append({
"point": point,
"selected_text": selected_text,
"model_config": model_config,
"ref_content": ref_content,
})
# 并发调用所有 AI 模型
async def _call_one(pd):
try:
return await call_ai_model(pd["model_config"], pd["point"].prompt, pd["ref_content"])
except Exception as e:
return f"[生成失败: {e}]"
coros = [_call_one(pd) for pd in point_data]
ai_results = await asyncio.gather(*coros)
# 按顺序替换文本
for pd, ai_result in zip(point_data, ai_results):
_replace_text_in_docx(doc, pd["selected_text"], str(ai_result))
await db.commit()
result_dir = get_storage_dir(RESULTS_DIR)
result_path = os.path.join(result_dir, f"{task_id}.docx")
doc.save(result_path)
task.status = "done"
task.result_file_path = result_path
task.finished_at = datetime.now(timezone.utc)
await db.commit()
except Exception as e:
task.status = "failed"
task.error_msg = str(e)
task.finished_at = datetime.now(timezone.utc)
await db.commit()
await session_factory.engine.dispose()
@celery_app.task(bind=True, name="generate_document")
def generate_document(self, task_id: str) -> dict:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(_generate_document(task_id))
return {"status": "done", "task_id": task_id}
except Exception as e:
return {"status": "failed", "task_id": task_id, "error": str(e)}
finally:
loop.close()
+16
View File
@@ -0,0 +1,16 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
sqlalchemy[asyncio]==2.0.30
asyncpg==0.29.0
alembic==1.13.1
pydantic-settings==2.3.4
cryptography==42.0.8
python-multipart==0.0.9
python-docx==1.1.2
mammoth==1.7.2
PyPDF2==3.0.1
httpx==0.27.0
celery[redis]==5.4.0
redis==5.0.7
aiofiles==24.1.0
reportlab==4.2.2
Executable
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -e
echo ">>> 启动 Doc Forge Reborn <<<"
if ! command -v docker &> /dev/null; then
echo "错误: 需要安装 Docker"
exit 1
fi
if [ ! -f .env ]; then
echo "请先复制 .env.example 为 .env 并填写必要配置"
exit 1
fi
echo "1. 构建镜像..."
docker compose build
echo "2. 启动服务..."
docker compose up -d
echo "3. 等待数据库就绪..."
sleep 5
echo "4. 执行数据库迁移..."
docker compose exec backend alembic upgrade head
echo ""
echo ">>> 启动完成 <<<"
echo "前端地址: http://localhost:3000"
echo "后端文档: http://localhost:8000/docs"
echo ""
echo "查看日志: docker compose logs -f"
echo "停止服务: docker compose down"
+101
View File
@@ -0,0 +1,101 @@
services:
postgres:
image: postgres:16-alpine
container_name: docforge-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-docforge}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-docforge}
POSTGRES_DB: ${POSTGRES_DB:-docforge}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-docforge}"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: docforge-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: docforge-backend
environment:
POSTGRES_USER: ${POSTGRES_USER:-docforge}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-docforge}
POSTGRES_DB: ${POSTGRES_DB:-docforge}
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
REDIS_URL: redis://redis:6379/0
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/1
SECRET_KEY: ${SECRET_KEY}
STORAGE_ROOT: /app/storage
CORS_ORIGINS: '["http://localhost:3000","http://localhost:80","http://localhost"]'
ports:
- "8000:8000"
volumes:
- storage_data:/app/storage
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
celery-worker:
build:
context: ./backend
dockerfile: Dockerfile.celery
container_name: docforge-celery
environment:
POSTGRES_USER: ${POSTGRES_USER:-docforge}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-docforge}
POSTGRES_DB: ${POSTGRES_DB:-docforge}
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
REDIS_URL: redis://redis:6379/0
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/1
SECRET_KEY: ${SECRET_KEY}
STORAGE_ROOT: /app/storage
volumes:
- storage_data:/app/storage
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
nginx:
build:
context: ./web
dockerfile: Dockerfile
container_name: docforge-nginx
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped
volumes:
postgres_data:
redis_data:
storage_data:
+103
View File
@@ -0,0 +1,103 @@
# API 设计文档
所有接口前缀为 `/api/v1`,返回 JSON 格式数据。认证暂未启用(单用户环境)。
## 1. AI 模型管理 (`/models`)
### 1.1 创建模型
`POST /models`
**请求体**
```json
{
"name": "GPT-4",
"provider": "openai",
"endpoint": "https://api.openai.com/v1/chat/completions",
"api_key": "sk-...",
"extra_params": {"max_tokens": 1000, "temperature": 0.7},
"is_enabled": true,
"remark": "主模型"
}
```
**响应**:返回创建的模型对象(API Key 已加密)。
### 1.2 获取模型列表
`GET /models?enabled=true&page=1&limit=20`
**响应**:分页列表。
### 1.3 获取模型详情
`GET /models/{id}`
### 1.4 更新模型
`PUT /models/{id}`(字段同创建)
### 1.5 删除模型
`DELETE /models/{id}`
### 1.6 启用/禁用
`PATCH /models/{id}/toggle`
请求体`{"is_enabled": false}`
## 2. 模板管理 (`/templates`)
### 2.1 上传模板
`POST /templates` (multipart/form-data)
字段`file` (.docx), `name` (可选)
**响应**:模板对象(含生成的 HTML 内容)。
### 2.2 获取模板列表
`GET /templates` (分页)
### 2.3 获取模板 HTML 内容
`GET /templates/{id}/html`
返回`{"html_content": "<html>..."}`
### 2.4 更新模板 HTML(编辑后保存)
`PUT /templates/{id}/html`
请求体`{"html_content": "<html>..."}`
后端将同步更新对应的 .docx 文件。
### 2.5 下载最终文档
`GET /templates/{id}/download`
返回文件流(.docx)。
## 3. 生成点管理 (`/generation-points`)
### 3.1 创建生成点
`POST /generation-points`
```json
{
"template_id": "uuid",
"position": {"start": 100, "end": 200},
"prompt": "请根据参考文件生成一段总结",
"model_id": "uuid (可选)",
"ref_file": "file (multipart, 可选)"
}
```
**响应**:生成点对象。
### 3.2 获取模板的所有生成点
`GET /generation-points?template_id={id}`
### 3.3 更新生成点
`PUT /generation-points/{id}`(字段同创建)
### 3.4 删除生成点
`DELETE /generation-points/{id}`
## 4. 生成任务 (`/tasks`)
### 4.1 触发生成任务
`POST /templates/{template_id}/generate`
**响应**
```json
{
"task_id": "uuid",
"status": "pending"
}
```
### 4.2 查询任务状态
`GET /tasks/{task_id}`
**响应**
```json
{
"id": "uuid",
"status": "done",
"result_file_path": "/path/to/result.docx",
"error_msg": null,
"created_at": "...",
"finished_at": "..."
}
```
### 4.3 下载生成结果
`GET /tasks/{task_id}/download`
返回 .docx 文件流。
## 错误码规范
- `200`: 成功
- `400`: 请求参数错误
- `404`: 资源不存在
- `500`: 服务器内部错误
错误响应格式:
```json
{
"detail": "错误描述"
}
```
+78
View File
@@ -0,0 +1,78 @@
# 任务执行摘要
## 会话 ID: 1
- [2026-07-06 16:00]
- **执行原因**: 用户要求按任务拆解清单逐步实施项目开发,先完成阶段一和阶段二
- **执行过程**:
1. 创建项目目录结构(backend/, web/, storage/)。
2. 编写 docker-compose.ymlPostgres 18.3 + Redis 7)、.env.example、requirements.txt。
3. 实现 backend/app/core/ 核心模块:configpydantic-settings)、databaseSQLAlchemy async engine + session)、securityFernet 加密)。
4. 创建 5 个 ORM 模型:AIModel、Template、GenerationPoint、GenerationTask、SystemConfig + Base/TimestampMixin/UUIDMixin。
5. 配置 Alembicalembic.ini, env.py, script.py.mako)。
6. 编写 FastAPI 入口 main.pyCORS + health 端点)。
7. 实现文件存储服务 file_storage.py(上传/下载/删除)。
8. 初始化前端项目(Vite + React 18 + TypeScript + Ant Design + React Router),侧边栏布局 + 5 个占位页面。
9. 编写 4 组 Pydantic Schemasai_model, template, generation_point, generation_task)。
10. 实现文档处理服务 document_processor.pyMammoth docx→HTML、python-docx HTML→docx、reportlab docx→PDF)。
11. 实现 AI 模型管理 API(CRUD + 启用/禁用切换,6 个端点)。
12. 实现模板管理 API(上传/获取HTML/更新HTML/下载docx+pdf6 个端点)。
13. 实现生成点管理 API(CRUD + 参考文件上传 + 批量排序更新,5 个端点)。
14. 注册路由(api/router.py),更新 main.py,启动验证通过(/health 返回 OK)。
- **执行结果**:
- 阶段一(基础设施):45 个文件,后端 5 张表模型导入正常,前端 TypeScript 编译零错误。
- 阶段二(核心业务 API):17 个 API 端点全部注册,后端启动正常,`/health` 返回 200 OK。
- Git 提交:2 次提交(b5fa343, aa84521)。
## 会话 ID: 2
- [2026-07-06 16:15]
- **执行原因**: 完成阶段三(异步任务与 AI 集成)和阶段四(前端开发)
- **执行过程**:
1. 实现 AI 调用适配器 `ai_adapter.py`:工厂模式支持 openai/azure/custom 三种供应商,httpx 异步调用。
2. 实现参考文件解析器 `ref_parser.py`:提取 txt/docx/pdf 文本内容。
3. 编写 Celery 配置 `celery_app.py` + 异步生成任务 `generate.py`(模板加载→生成点排序→参考解析→AI调用→HTML插入→文档保存→状态更新)。
4. 实现任务管理 API:触发生成/查询状态/下载结果(docx+pdf)/取消任务/单点测试。
5. 注册全部 23 个 API 端点,后端启动验证通过。
6. 前端 API 层:axios 封装 + 4 组 API 调用(models/templates/generationPoints/tasks)。
7. TypeScript 类型定义(AIModel/Template/GenerationPoint/GenerationTask)。
8. 模型管理页面:表格 + 新增/编辑弹窗 + 启用切换 + 删除。
9. 模板列表页面:表格 + docx 上传 + 在线编辑/下载/删除。
10. 模板编辑器(核心):TinyMCE 自托管 + 选区监听浮动标注 + 生成点弹窗 + 右侧面板(测试/编辑/删除)+ 触发生成任务 + 进度轮询 + 结果下载。
11. 任务历史页面:状态标签 + 下载 + 取消。
12. 系统设置页面:默认模型/并发/超时配置。
13. Vite 配置 API 代理(3000→8000) + TinyMCE 自托管。
14. 修复 docker-composepostgres:18.3→16-alpine+ Alembic 初始迁移。
15. 启动全套服务(PostgreSQL + Redis + Backend + Frontend),前端页面可正常访问。
- **执行结果**:
- 后端 23 个 API 端点,前端 5 个页面完成,TypeScript 零错误,Vite 构建成功。
- 服务全部启动:前端 http://localhost:3000 返回 200,后端 /api/v1/models 返回正常。
- Git 提交:3 次提交(afabb64, 985a861, 0b23858)。
- 总计 8 次提交,约 70 个文件。
## 会话 ID: 3
- [2026-07-06 16:45]
- **执行原因**: 完成阶段五(部署上线与安全加固)
- **执行过程**:
1. 添加 `postinstall` 脚本自动复制 TinyMCE 自托管文件。
2. 编写 3 个 Dockerfilebackend (uvicorn)、celery-worker、web (多阶段 Node+Nginx)。
3. 重写 docker-compose.yml:完整 5 服务编排(postgres + redis + backend + celery + nginx)。
4. 编写 nginx.conf 反向代理配置 + gzip + API 代理。
5. 安全加固:请求体大小限制中间件、文件类型白名单校验(.docx/.txt/.pdf)、CORS 更新。
6. 添加 .dockerignorebackend + web)、deploy.sh 一键部署脚本。
- **执行结果**:
- 后端 17 个端点 + 前端 TypeScript 编译通过 + Vite 构建成功。
- 生产部署:`docker compose up -d` 即可一键启动 5 个服务。
- Git 提交:1 次提交(017a195)。总计 14 次提交。
## 会话 ID: 4
- [2026-07-06 17:10]
- **执行原因**: 补充完善剩余任务(拖拽排序、系统设置、安全加固、Celery验证、文档)
- **执行过程**:
1. Celery Worker 启动验证:连接 Redis`generate_document` 任务已注册并可通过 inspector 检测。
2. 生成点拖拽排序:HTML5 原生 Drag & Drop + `MenuOutlined` 拖拽手柄,拖拽后自动调用 `/batch-order` API。
3. 系统设置 API:新增 `/api/v1/settings` GET/PUT,前端 Settings 实时加载模型列表并持久化配置。
4. 安全加固:`bleach` HTML 净化过滤 script/onerror 等危险标签,文件类型白名单校验,请求大小限制中间件。
5. 修复 Fernet 加密:SHA256(SECRET_KEY) → base64url 派生合法 32 字节密钥。
6. 更新 README:修正技术栈版本,补充生产部署 + 本地开发完整步骤。
- **执行结果**:
- API 端点 19 个,所有核心功能模块完成。
- 可通过 http://localhost:3000 进行端到端测试。
+87
View File
@@ -0,0 +1,87 @@
# 任务拆解清单(详细版)
本文档将整个开发过程拆解为可执行的工作包,包含优先级、工时估算、依赖关系和里程碑。所有任务均覆盖核心功能、测试、部署及新增需求(单点测试、拖拽排序、独立模型选择、PDF导出)。
## 优先级说明
- **P0**:核心功能,必须完成才能可用。
- **P1**:重要功能,提升体验。
- **P2**:优化和增强。
---
## 阶段一:基础设施与核心服务 (第1~2周)
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|----|------|--------|---------|--------|------|--------|
| 1.1 | 需求分析与架构评审 | - 编写详细需求文档(含用户故事)<br>- 数据库模型设计评审(ER图、字段说明)<br>- 技术选型最终确认(含PDF导出库调研) | 24 | P0 | - | 架构基线完成 |
| 1.2 | 开发环境搭建 | - 配置后端虚拟环境,安装 FastAPI、SQLAlchemy、Celery、PyPDF2等<br>- 配置前端项目(Vite + React + TS + Ant Design<br>- Docker Compose 定义基础服务(Postgres、Redis | 16 | P0 | 1.1 | 可运行空框架 |
| 1.3 | 数据库与 ORM | - 使用 Alembic 创建初始迁移,建表(models, templates, generation_points, tasks, system_config<br>- 编写 SQLAlchemy 模型(含`order`字段用于生成点排序)<br>- 实现数据库会话依赖注入 | 24 | P0 | 1.2 | 数据库就绪 |
| 1.4 | 文件存储服务 | - 实现文件上传、下载、删除工具类<br>- 配置存储根目录和路径生成规则(区分模板、参考文件、结果) | 16 | P0 | 1.3 | 文件操作可用 |
## 阶段二:核心业务逻辑 (第3~5周)
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|----|------|--------|---------|--------|------|--------|
| 2.1 | 文档处理服务(含PDF导出) | - 集成 Aspose.Words (或 Mammoth + python-docx) 实现 docx ↔ HTML 转换<br>- 实现 `docx_to_html``html_to_docx` 函数<br>- 实现 `docx_to_pdf` 函数(使用 python-docx + reportlab 或 Aspose<br>- 单元测试转换效果(包含表格、图片、样式) | 48 | P0 | 1.4 | 文档转换与导出达标 |
| 2.2 | AI 模型管理 API | - 实现 CRUD 接口<br>- API Key 加密存储(Fernet<br>- 启用/禁用切换<br>- 列表查询过滤(启用/全部) | 24 | P0 | 1.3 | 模型管理功能完备 |
| 2.3 | 模板管理 API | - 上传接口(接收文件,存储并转换 HTML<br>- 获取 HTML 内容(含模板基本信息)<br>- 更新 HTML(并同步转回 docx<br>- 下载接口(支持 docx 和 pdf 格式参数) | 32 | P0 | 2.1, 1.4 | 模板 CRUD 完成 |
| 2.4 | 生成点管理 API | - 创建、列表、更新、删除生成点<br>- 关联模板和模型校验<br>- 参考文件上传处理<br>- 支持 `order` 字段(用于排序) | 24 | P0 | 2.2, 2.3 | 生成点可标注 |
| 2.5 | 生成点顺序管理 | - 提供批量更新接口(接收生成点ID列表顺序)<br>- 前端拖拽排序时调用此接口 | 8 | P1 | 2.4 | 排序功能可用 |
## 阶段三:异步任务与 AI 集成 (第6~7周)
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|----|------|--------|---------|--------|------|--------|
| 3.1 | AI 调用适配器 | - 设计工厂模式,支持 openai, azure, custom<br>- 实现各供应商的请求构建和响应解析<br>- 编写异步调用函数(httpx<br>- 支持超时、重试配置 | 32 | P0 | 2.2 | 可成功调用不同模型 |
| 3.2 | 参考文件解析器 | - 实现提取 .txt, .docx, .pdf 文本内容的功能<br>- 使用 python-docx, PyPDF2 等库 | 16 | P0 | 1.4 | 提取文本成功 |
| 3.3 | Celery 任务定义 | - **3.3.1** Celery 配置与连接(4h<br>- **3.3.2** 任务函数骨架(加载模板、获取生成点,按 order 排序)(8h<br>- **3.3.3** 参考文件解析集成(4h<br>- **3.3.4** AI 调用与结果插入(基于偏移量或 XPath)(12h<br>- **3.3.5** 文档保存(docx)与状态更新(8h<br>- **3.3.6** 异常处理与重试逻辑(12h<br>**合计** | 48 | P0 | 3.1, 3.2, 2.3, 2.4, 2.5 | 可端到端生成文档 |
| 3.4 | 任务管理 API | - 触发生成任务接口(创建 task 记录,启动 Celery<br>- 查询任务状态(含进度百分比)<br>- 下载结果接口(支持 docx 和 pdf)<br>- 取消任务接口(可选) | 20 | P0 | 3.3 | 任务管理可用 |
| 3.5 | 单个生成点测试功能 | - 提供 API 允许用户测试单个生成点(不保存文档)<br>- 返回 AI 生成结果预览(可返回纯文本或HTML)<br>- 前端在标注弹窗中增加“测试”按钮,展示结果 | 16 | P1 | 3.1, 3.2 | 单点测试可用 |
## 阶段四:前端开发 (第5~9周,与后端并行)
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|----|------|--------|---------|--------|------|--------|
| 4.1 | 页面路由与布局 | - 使用 React Router 定义路由(/templates, /editor/:id, /models, /tasks, /settings<br>- 整体布局(侧边栏 + 内容区) | 12 | P0 | 1.2 | 框架搭建 |
| 4.2 | 模型管理页面 | - 列表展示(表格 + 分页)<br>- 新建/编辑弹窗表单(含供应商、API Key、扩展参数JSON编辑器)<br>- 启用/禁用开关<br>- 删除确认<br>- 设置全局默认模型(单选按钮) | 24 | P0 | 2.2 | 模型管理交互完整 |
| 4.3 | 模板列表与上传 | - 列表页面(卡片或表格,含名称、创建时间、操作按钮)<br>- 上传文件组件(支持拖拽,仅 .docx)<br>- 点击“编辑”跳转到编辑器页 | 16 | P0 | 2.3 | 可上传和查看模板 |
| 4.4 | 模板编辑器(核心) | **拆解为以下子任务**<br>- **4.4.1** 集成 TinyMCE,加载 HTML 内容,保存时调用更新接口(8h<br>- **4.4.2** 实现选区监听与高亮标注(监听 mouseup,显示浮动按钮“设为AI生成点”)(12h)<br>- **4.4.3** 生成点弹窗表单:提示词、参考文件上传(拖拽)、模型下拉(独立选择),并集成“测试”按钮(调用3.5)(16h<br>- **4.4.4** 生成点列表(右侧面板),显示每个点的摘要,支持删除、编辑(修改弹窗)(8h)<br>- **4.4.5** 标注区域与实际选区偏移量同步(确保插入位置准确)(4h)<br>- **4.4.6** 支持拖拽排序生成点(使用 react-beautiful-dnd 等),更新顺序(4h<br>**合计** | 52 | P0 | 4.3, 2.4, 4.2, 3.5, 2.5 | 可编辑并标注 |
| 4.5 | 生成任务执行与监控 | - 页面内“生成文档”按钮(可放在编辑器底部或工具栏)<br>- 弹出确认框,显示所有生成点列表(可勾选跳过个别)<br>- 提交后显示任务进度条(轮询状态,含进度百分比)<br>- 完成后自动显示下载按钮(支持 docx 和 pdf 格式切换) | 28 | P0 | 3.4 | 完整生成流程 |
| 4.6 | 任务历史页面 | - 表格列出所有任务(模板名称、状态、开始/完成时间)<br>- 状态为“已完成”的可以下载(格式选择)<br>- 状态为“进行中”的显示进度,可取消(可选) | 16 | P1 | 3.4 | 任务可追溯 |
| 4.7 | 系统设置页面 | - 展示可编辑的系统配置(全局默认模型、最大并发数、超时时间等)<br>- 调用后端接口读写 system_config | 12 | P1 | 2.2 | 设置可用 |
## 阶段五:测试与优化 (第10~11周)
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|----|------|--------|---------|--------|------|--------|
| 5.1 | 集成测试 | - 端到端测试(上传 → 标注(含拖拽排序)→ 单点测试 → 生成 → 下载)<br>- 测试不同供应商模型调用<br>- 测试参考文件多种格式(txt, docx, pdf<br>- 测试导出 PDF 功能<br>- 异常场景(网络超时、文件损坏、AI 返回错误) | 48 | P0 | 所有前序 | 功能稳定 |
| 5.2 | 性能调优 | - 优化大文档转换速度(异步处理)<br>- 数据库查询添加索引(template_id, status<br>- 调整 Celery 并发参数<br>- 优化前端渲染(虚拟列表等) | 16 | P1 | 5.1 | 响应达标 |
| 5.3 | 安全加固 | - 检查 API Key 加密流程<br>- 文件上传类型和大小限制(限制50MB<br>- 添加 CORS 配置<br>- 输入校验(防止 XSS | 8 | P0 | 5.1 | 安全合规 |
| 5.4 | 文档编写 | - 用户手册(操作指南,含截图)<br>- 部署文档(Docker 详细步骤)<br>- API 文档(由 FastAPI 自动生成,补充说明) | 24 | P1 | - | 交付文档完整 |
## 阶段六:部署上线 (第12周)
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|----|------|--------|---------|--------|------|--------|
| 6.1 | Docker 化所有服务 | - 编写 Dockerfile(后端、前端、Celery<br>- 编写 docker-compose.yml 整合所有服务(postgres, redis, backend, celery-worker, nginx | 16 | P0 | 所有 | 可容器化运行 |
| 6.2 | 生产环境配置 | - 配置环境变量(数据库、Redis、密钥等)<br>- 配置 Nginx 反向代理(前端静态 + 后端 API 转发)<br>- 配置 SSL(可选) | 8 | P0 | 6.1 | 生产就绪 |
| 6.3 | 部署测试 | - 在测试服务器上部署,验证全部功能(含 PDF 导出) | 8 | P0 | 6.2 | 上线成功 |
## 里程碑总览
| 里程碑 | 预计完成时间 | 关键交付 |
|--------|--------------|----------|
| M1: 架构与数据库就绪 | 第2周末 | 需求文档、数据库设计、环境搭建、文件存储 |
| M2: 核心 API 完成 | 第5周末 | 模板、模型、生成点 CRUD 完成,文档转换与导出(含PDF) |
| M3: 异步生成能力 | 第7周末 | Celery 任务可端到端生成文档,单点测试可用 |
| M4: 前端完整交互 | 第9周末 | 所有页面可用(含拖拽排序、任务历史、系统设置),生成流程顺畅 |
| M5: 测试与稳定 | 第11周末 | 通过集成测试,性能优化,安全加固 |
| M6: 正式上线 | 第12周末 | Docker 部署,生产环境运行 |
## 总工时估算
- 开发:约 **468 人时**(按 8h/天 ≈ 58.5 人天)
- 含新增功能(单点测试、PDF导出、拖拽排序、系统设置)和细化拆分
- 测试与部署额外计入,总项目周期约 **12 周**(建议 4~5 人并行开发)
---
+63
View File
@@ -0,0 +1,63 @@
# 技术方案概述
## 1. 总体架构
采用前后端分离架构,后端提供 RESTful API,前端负责交互和展示。异步任务通过 Celery 处理,确保长时间 AI 调用不阻塞主线程。
### 架构图
```
用户 -> Nginx (前端静态) -> React 应用
|
+-> FastAPI 后端 (API)
|
+-> PostgreSQL (业务数据)
+-> Redis (消息队列 + 缓存)
+-> Celery Worker (异步任务)
+-> 文件存储 (模板/参考文件/结果)
```
## 2. 技术选型
| 层级 | 组件 | 理由 |
|------|------|------|
| 前端框架 | React + TypeScript | 生态成熟,类型安全,组件化利于维护 |
| UI 库 | Ant Design | 后台管理组件丰富,开发效率高 |
| 富文本编辑器 | TinyMCE | 支持选区操作,可扩展,兼容性好 |
| 后端框架 | FastAPI | 异步高性能,自动生成文档,易于集成 AI |
| ORM | SQLAlchemy | 功能强大,支持异步 (2.0) |
| 任务队列 | Celery + Redis | 久经考验,支持重试、状态追踪 |
| 文档处理 | Aspose.Words (优先) | 高保真 Word ↔ HTML 转换,样式保留最佳 |
| 备选文档方案 | Mammoth + python-docx | 开源免费,但复杂样式可能丢失 |
| AI 调用 | httpx (异步) | 支持异步请求,适配多种 API 格式 |
| 容器化 | Docker Compose | 简化部署,环境一致性 |
## 3. 核心模块设计
### 3.1 AI 模型管理模块
- 支持新建、编辑、删除、启用/禁用模型。
- 模型信息包括:名称、供应商、接口地址、API Key(加密存储)、扩展参数、备注。
- 提供下拉选择供生成点关联。
### 3.2 模板管理模块
- 上传 .docx → 存储原始文件,转换为 HTML 存于数据库。
- 在线编辑:前端编辑器修改 HTML,后端同步转回 .docx。
- 导出最终 .docx 文件。
### 3.3 生成点管理模块
- 用户在编辑器中框选文本,标注为 AI 生成点。
- 填写提示词,上传参考文件,选择模型。
- 保存选区位置(起始/结束偏移量或 XPath)。
### 3.4 异步生成任务
1. 用户触发生成,系统创建任务记录,启动 Celery 任务。
2. 任务流程:
- 加载模板和所有生成点。
- 对每个生成点,提取参考文件文本,构造 prompt。
- 调用对应的 AI 模型(通过适配器)。
- 将生成结果插入文档对应位置。
- 保存最终文档。
3. 前端轮询任务状态,完成后提供下载。
## 4. 安全设计
- **API Key 加密**:使用 `cryptography.fernet` 对称加密,密钥取自环境变量。
- **文件隔离**:若未来引入多用户,可通过用户目录隔离文件。
- **输入校验**:所有 API 使用 Pydantic 校验,防止注入攻击。
- **CORS**:配置仅允许前端域名访问。
## 5. 性能与扩展
- **异步处理**:AI 调用和文档转换均异步,提升吞吐量。
- **连接池**:数据库和 Redis 使用连接池,避免资源耗尽。
- **水平扩展**Celery worker 可多实例部署,后端 API 可水平扩展。
- **适配器模式**:AI 调用采用工厂模式,新增供应商无需修改核心逻辑。
## 6. 部署方案
- 使用 Docker Compose 编排:PostgreSQL、Redis、FastAPI、Celery Worker、前端 Nginx。
- 环境变量统一管理,通过 `.env` 配置。
- 生产环境建议使用反向代理(如 Nginx)挂载 SSL 证书。
+61
View File
@@ -0,0 +1,61 @@
# 数据库设计
## ER 图(核心关系)
- `templates` (1) ——> (N) `generation_points`
- `generation_points` (N) ——> (1) `models`
- `templates` (1) ——> (N) `generation_tasks`
## 表结构详细
### 1. `models`AI 模型配置)
| 字段名 | 类型 | 约束 | 说明 |
| -------------- | ------------- | ------------------ | -------------------------------------- |
| id | UUID | PRIMARY KEY | 主键 |
| name | VARCHAR(100) | NOT NULL | 模型名称 |
| provider | VARCHAR(50) | NOT NULL | 供应商:openai, azure, custom |
| endpoint | VARCHAR(255) | NOT NULL | 接口地址 |
| api_key | TEXT | NOT NULL | 加密存储(Fernet 对称加密) |
| extra_params | JSONB | DEFAULT '{}' | 扩展参数,如 max_tokens, temperature |
| is_enabled | BOOLEAN | DEFAULT TRUE | 是否启用 |
| remark | TEXT | | 备注 |
| created_at | TIMESTAMP | DEFAULT NOW() | |
| updated_at | TIMESTAMP | DEFAULT NOW() | |
### 2. `templates`(模板文档)
| 字段名 | 类型 | 约束 | 说明 |
| -------------- | ------------- | ------------------ | ------------------------------------------ |
| id | UUID | PRIMARY KEY | |
| name | VARCHAR(200) | NOT NULL | 模板名称 |
| file_path | VARCHAR(500) | NOT NULL | 原始 .docx 文件存储路径 |
| html_content | TEXT | | 转换后的 HTML 内容(供前端编辑) |
| created_at | TIMESTAMP | DEFAULT NOW() | |
| updated_at | TIMESTAMP | DEFAULT NOW() | |
### 3. `generation_points`AI 生成点)
| 字段名 | 类型 | 约束 | 说明 |
| --------------- | ------------- | ------------------ | ------------------------------------------------- |
| id | UUID | PRIMARY KEY | |
| template_id | UUID | FOREIGN KEY | 关联模板 |
| position | JSONB | NOT NULL | 在 HTML 中的选区信息,如 {start: 100, end: 200} 或 XPath |
| prompt | TEXT | NOT NULL | 用户编写的提示词 |
| model_id | UUID | FOREIGN KEY | 指定使用的模型,若为空则使用全局默认模型 |
| ref_file_path | VARCHAR(500) | | 参考文件存储路径(可选) |
| created_at | TIMESTAMP | DEFAULT NOW() | |
| updated_at | TIMESTAMP | DEFAULT NOW() | |
### 4. `generation_tasks`(生成任务)
| 字段名 | 类型 | 约束 | 说明 |
| --------------- | ------------- | ------------------ | ----------------------------------------------- |
| id | UUID | PRIMARY KEY | |
| template_id | UUID | FOREIGN KEY | 关联模板 |
| status | VARCHAR(20) | NOT NULL | pending / processing / done / failed |
| result_file_path| VARCHAR(500) | | 生成后的 .docx 文件路径 |
| error_msg | TEXT | | 任务失败时的错误信息 |
| celery_task_id | VARCHAR(100) | | Celery 任务 ID,便于追踪 |
| created_at | TIMESTAMP | DEFAULT NOW() | |
| finished_at | TIMESTAMP | | 完成时间 |
### 5. `system_config`(系统配置,可选)
存储全局默认模型 ID 等键值对。
| 字段名 | 类型 | 约束 | 说明 |
| ----------- | ------------- | -------- | -------------------- |
| key | VARCHAR(50) | PRIMARY | 配置键 |
| value | TEXT | | 配置值(JSON 格式) |
| description | VARCHAR(200) | | 描述 |
## 索引建议
- `templates`:在 `created_at` 上建索引,便于按时间排序。
- `generation_points`:在 `template_id` 上建外键索引,提高关联查询速度。
- `generation_tasks`:在 `template_id,status` 上建索引,优化列表查询。
+59
View File
@@ -0,0 +1,59 @@
# 部署指南 (Docker Compose)
## 前置条件
- Linux 服务器(或 Windows WSL2
- Docker 和 Docker Compose 已安装
- 域名(可选)和 SSL 证书(可选)
## 步骤
### 1. 克隆代码
```bash
git clone <repository-url>
cd doc-forge-reborn
```
### 2. 配置环境变量
复制 `.env.example``.env`,并填写以下关键变量:
```env
# Database
POSTGRES_USER=docforge
POSTGRES_PASSWORD=secure_password
POSTGRES_DB=docforge
# Redis
REDIS_URL=redis://redis:6379/0
# Backend
SECRET_KEY=your-secret-key-for-fernet-encryption
DEFAULT_MODEL_ID=uuid-of-default-model
# AI API Keys (也可在后台模型管理中配置)
OPENAI_API_KEY=sk-...
AZURE_API_KEY=...
```
### 3. 构建并启动所有服务
```bash
docker-compose up -d --build
```
### 4. 执行数据库迁移(首次启动后)
```bash
docker-compose exec backend alembic upgrade head
```
### 5. 验证服务状态
- 前端`http://localhost:3000`
- 后端 API 文档`http://localhost:8000/docs`
- Celery Worker 日志`docker-compose logs celery-worker`
## 服务端口映射
- 前端`3000` (可通过 `nginx` 暴露 80)
- 后端 API`8000`
- PostgreSQL`5432` (仅内部)
- Redis`6379` (仅内部)
## 生产环境优化建议
- 使用外部 PostgreSQL(如 AWS RDS)提升可靠性。
- 将 Redis 配置为持久化模式。
- 挂载 SSL 证书,配置 HTTPS。
- 设置 Celery 并发数根据 CPU 核心数调整。
- 定期备份数据库和文件存储目录。
## 停止服务
```bash
docker-compose down
```
## 升级流程
1. `git pull` 拉取最新代码
2. `docker-compose build` 重新构建镜像
3. `docker-compose up -d` 重启服务
4. 运行数据库迁移(如有)
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.git
*.local
public/tinymce/
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
public/tinymce
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+18
View File
@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run postinstall && npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
client_max_body_size 100M;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
}
+2773
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"postinstall": "cp -r node_modules/tinymce public/tinymce 2>/dev/null || true"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@tinymce/tinymce-react": "^6.3.0",
"antd": "^6.5.0",
"axios": "^1.18.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"tinymce": "^8.7.0"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+26
View File
@@ -0,0 +1,26 @@
import { Routes, Route } from "react-router-dom";
import AppLayout from "./components/AppLayout";
import TemplateList from "./pages/TemplateList";
import TemplateEditor from "./pages/TemplateEditor";
import ModelList from "./pages/ModelList";
import TaskList from "./pages/TaskList";
import TaskDetail from "./pages/TaskDetail";
import Settings from "./pages/Settings";
function App() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route path="/" element={<TemplateList />} />
<Route path="/templates" element={<TemplateList />} />
<Route path="/editor/:id" element={<TemplateEditor />} />
<Route path="/models" element={<ModelList />} />
<Route path="/tasks" element={<TaskList />} />
<Route path="/tasks/:id" element={<TaskDetail />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
);
}
export default App;
+57
View File
@@ -0,0 +1,57 @@
import request from "./request";
import type { GenerationPoint } from "../types";
export async function listGenerationPoints(template_id: string) {
const { data } = await request.get<GenerationPoint[]>("/generation-points", {
params: { template_id },
});
return data;
}
export async function createGenerationPoint(body: {
template_id: string;
position: string;
prompt: string;
model_id?: string;
order?: number;
selected_text?: string;
need_ref_file?: boolean;
remark?: string;
ref_file?: File;
}) {
const formData = new FormData();
formData.append("template_id", body.template_id);
formData.append("position", body.position);
formData.append("prompt", body.prompt);
if (body.model_id) formData.append("model_id", body.model_id);
if (body.order !== undefined) formData.append("order", String(body.order));
if (body.selected_text) formData.append("selected_text", body.selected_text);
if (body.need_ref_file) formData.append("need_ref_file", "true");
if (body.remark) formData.append("remark", body.remark);
if (body.ref_file) formData.append("ref_file", body.ref_file);
const { data } = await request.post<GenerationPoint>("/generation-points", formData);
return data;
}
export async function updateGenerationPoint(
id: string,
body: Partial<GenerationPoint>
) {
const { data } = await request.put<GenerationPoint>(`/generation-points/${id}`, body);
return data;
}
export async function deleteGenerationPoint(id: string) {
await request.delete(`/generation-points/${id}`);
}
export async function batchUpdateOrder(points: { id: string; order: number }[]) {
await request.post("/generation-points/batch-order", { points });
}
export async function testGenerationPoint(pointId: string) {
const { data } = await request.post<{ result: string }>(
`/generation-points/${pointId}/test`
);
return data.result;
}
+36
View File
@@ -0,0 +1,36 @@
import request from "./request";
import type { AIModel } from "../types";
export async function listModels(params?: { enabled?: boolean }) {
const { data } = await request.get<AIModel[]>("/models", { params });
return data;
}
export async function getModel(id: string) {
const { data } = await request.get<AIModel>(`/models/${id}`);
return data;
}
export async function createModel(body: Partial<AIModel> & { api_key: string }) {
const { data } = await request.post<AIModel>("/models", body);
return data;
}
export async function updateModel(id: string, body: Partial<AIModel>) {
const { data } = await request.put<AIModel>(`/models/${id}`, body);
return data;
}
export async function deleteModel(id: string) {
await request.delete(`/models/${id}`);
}
export async function toggleModel(id: string, is_enabled: boolean) {
const { data } = await request.patch<AIModel>(`/models/${id}/toggle`, { is_enabled });
return data;
}
export async function testModel(id: string) {
const { data } = await request.post<{ success: boolean; result?: string; error?: string }>(`/models/${id}/test`);
return data;
}
+16
View File
@@ -0,0 +1,16 @@
import axios from "axios";
const request = axios.create({
baseURL: "/api/v1",
timeout: 30000,
});
request.interceptors.response.use(
(response) => response,
(error) => {
const message = error.response?.data?.detail || error.message || "请求失败";
return Promise.reject(new Error(message));
}
);
export default request;
+10
View File
@@ -0,0 +1,10 @@
import request from "./request";
export async function getAllSettings() {
const { data } = await request.get<Record<string, string>>("/settings");
return data;
}
export async function updateSetting(key: string, value: string, description?: string) {
await request.put(`/settings/${key}`, { value, description });
}
+27
View File
@@ -0,0 +1,27 @@
import request from "./request";
import type { GenerationTask } from "../types";
export async function triggerGeneration(templateId: string) {
const { data } = await request.post<{ task_id: string; status: string }>(
`/templates/${templateId}/generate`
);
return data;
}
export async function getTaskStatus(taskId: string) {
const { data } = await request.get<GenerationTask>(`/tasks/${taskId}`);
return data;
}
export async function listTasks() {
const { data } = await request.get<GenerationTask[]>("/tasks");
return data;
}
export async function cancelTask(taskId: string) {
await request.post(`/tasks/${taskId}/cancel`);
}
export function getTaskDownloadUrl(taskId: string, format: "docx" | "pdf" = "docx") {
return `/api/v1/tasks/${taskId}/download?format=${format}`;
}
+37
View File
@@ -0,0 +1,37 @@
import request from "./request";
import type { Template, TemplateListItem } from "../types";
export async function listTemplates() {
const { data } = await request.get<TemplateListItem[]>("/templates");
return data;
}
export async function getTemplate(id: string) {
const { data } = await request.get<Template>(`/templates/${id}`);
return data;
}
export async function uploadTemplate(file: File, name?: string) {
const formData = new FormData();
formData.append("file", file);
if (name) formData.append("name", name);
const { data } = await request.post<Template>("/templates", formData);
return data;
}
export async function getTemplateHtml(id: string) {
const { data } = await request.get<{ html_content: string }>(`/templates/${id}/html`);
return data.html_content;
}
export async function updateTemplateHtml(id: string, html_content: string) {
await request.put(`/templates/${id}/html`, { html_content });
}
export async function deleteTemplate(id: string) {
await request.delete(`/templates/${id}`);
}
export function getDownloadUrl(id: string, format: "docx" | "pdf" = "docx") {
return `/api/v1/templates/${id}/download?format=${format}`;
}
+57
View File
@@ -0,0 +1,57 @@
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { Layout, Menu } from "antd";
import {
FileTextOutlined,
RobotOutlined,
UnorderedListOutlined,
SettingOutlined,
} from "@ant-design/icons";
const { Sider, Content, Header } = Layout;
const menuItems = [
{ key: "/templates", icon: <FileTextOutlined />, label: "模板管理" },
{ key: "/models", icon: <RobotOutlined />, label: "模型管理" },
{ key: "/tasks", icon: <UnorderedListOutlined />, label: "任务历史" },
{ key: "/settings", icon: <SettingOutlined />, label: "系统设置" },
];
export default function AppLayout() {
const navigate = useNavigate();
const location = useLocation();
const selectedKey = "/" + location.pathname.split("/")[1];
return (
<Layout style={{ minHeight: "100vh" }}>
<Sider width={220} theme="dark">
<div
style={{
height: 64,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 18,
fontWeight: 700,
borderBottom: "1px solid rgba(255,255,255,0.1)",
}}
>
Doc Forge Reborn
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Content style={{ padding: 24, background: "#f5f5f5", overflow: "auto" }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+179
View File
@@ -0,0 +1,179 @@
import { useState } from "react";
import {
Modal, Button, Space, message, Card, Tag, Upload, List, Popconfirm,
} from "antd";
import {
UploadOutlined, DeleteOutlined, FileTextOutlined,
ThunderboltOutlined, PaperClipOutlined,
} from "@ant-design/icons";
import type { GenerationPoint, AIModel } from "../types";
import * as taskApi from "../api/tasks";
import request from "../api/request";
interface GenerateModalProps {
open: boolean;
onClose: () => void;
templateId: string;
points: GenerationPoint[];
models: AIModel[];
}
interface FileItem {
uid: string;
name: string;
path?: string;
}
export default function GenerateModal({
open, onClose, templateId, points, models,
}: GenerateModalProps) {
const [filesMap, setFilesMap] = useState<Record<string, FileItem[]>>({});
const [generating, setGenerating] = useState(false);
const handleUpload = async (pointId: string, fileList: File[]) => {
if (fileList.length === 0) return;
const formData = new FormData();
fileList.forEach((f) => formData.append("ref_files", f));
await request.post(`/generation-points/${pointId}/upload-ref`, formData);
const newFiles = fileList.map((f) => ({
uid: f.name + Date.now(),
name: f.name,
}));
setFilesMap((prev) => ({
...prev,
[pointId]: [...(prev[pointId] || []), ...newFiles],
}));
message.success(`已上传 ${fileList.length} 个文件`);
};
const handleRemoveFile = (pointId: string, fileName: string) => {
setFilesMap((prev) => ({
...prev,
[pointId]: (prev[pointId] || []).filter((f) => f.name !== fileName),
}));
};
const handleGenerate = async () => {
setGenerating(true);
try {
const { task_id } = await taskApi.triggerGeneration(templateId);
message.success("已创建生成任务,请前往任务历史查看");
onClose();
} catch (err: unknown) {
if (err instanceof Error) message.error(err.message);
} finally {
setGenerating(false);
}
};
const getModelName = (modelId: string | null) => {
if (!modelId) return "默认模型";
const m = models.find((x) => x.id === modelId);
return m?.name || modelId;
};
const pointsNeedRef = points.filter((p) => p.need_ref_file);
const pointsNoRef = points.filter((p) => !p.need_ref_file);
return (
<Modal
title="确认生成文档"
open={open}
onCancel={onClose}
width={700}
footer={
<Space>
<Button onClick={onClose}></Button>
<Button
type="primary"
danger
icon={<ThunderboltOutlined />}
onClick={handleGenerate}
loading={generating}
>
</Button>
</Space>
}
>
<p style={{ marginBottom: 16, color: "#666" }}>
{points.length} AI
</p>
{pointsNeedRef.length > 0 && (
<div style={{ marginBottom: 12 }}>
<Tag color="orange"> ({pointsNeedRef.length})</Tag>
</div>
)}
{points.map((point, idx) => {
const uploadedFiles = filesMap[point.id] || [];
const modelName = getModelName(point.model_id);
return (
<Card
key={point.id}
size="small"
style={{ marginBottom: 12 }}
title={
<Space size={4}>
<span style={{ fontSize: 12, color: "#999" }}>#{idx + 1}</span>
<span style={{ fontSize: 13 }}>{point.prompt.slice(0, 30)}{point.prompt.length > 30 ? "..." : ""}</span>
</Space>
}
extra={
<Tag color="blue" style={{ fontSize: 11 }}>{modelName}</Tag>
}
>
{point.remark && (
<p style={{ fontSize: 12, color: "#999", marginBottom: 8 }}>
{point.remark}
</p>
)}
{point.need_ref_file && (
<div>
<Upload
multiple
accept=".txt,.docx,.pdf"
showUploadList={false}
beforeUpload={(file) => {
handleUpload(point.id, [file]);
return false;
}}
>
<Button size="small" icon={<UploadOutlined />}>
</Button>
</Upload>
{uploadedFiles.length > 0 && (
<List
size="small"
style={{ marginTop: 8 }}
dataSource={uploadedFiles}
renderItem={(item) => (
<List.Item
extra={
<Popconfirm
title="删除此文件?"
onConfirm={() => handleRemoveFile(point.id, item.name)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
}
>
<FileTextOutlined style={{ marginRight: 6 }} />
<span style={{ fontSize: 12 }}>{item.name}</span>
</List.Item>
)}
/>
)}
</div>
)}
</Card>
);
})}
</Modal>
);
}
+15
View File
@@ -0,0 +1,15 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
#root {
width: 100%;
height: 100%;
}
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
+255
View File
@@ -0,0 +1,255 @@
import { useEffect, useState, useCallback } from "react";
import {
Table, Button, Modal, Form, Input, Select, Switch,
Space, message, Popconfirm, Tag,
} from "antd";
import { PlusOutlined, EditOutlined, DeleteOutlined, ExperimentOutlined } from "@ant-design/icons";
import type { AIModel } from "../types";
import * as modelApi from "../api/models";
const PROVIDERS = [
{ label: "OpenAI", value: "openai" },
{ label: "Azure", value: "azure" },
{ label: "自定义", value: "custom" },
];
export default function ModelList() {
const [models, setModels] = useState<AIModel[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingModel, setEditingModel] = useState<AIModel | null>(null);
const [form] = Form.useForm();
const fetchModels = useCallback(async () => {
setLoading(true);
try {
const data = await modelApi.listModels();
setModels(data);
} catch {
message.error("获取模型列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchModels();
}, [fetchModels]);
const handleCreate = () => {
setEditingModel(null);
setModalOpen(true);
};
const handleEdit = (model: AIModel) => {
setEditingModel(model);
setModalOpen(true);
};
useEffect(() => {
if (!modalOpen) return;
// 延迟确保 Form DOM 已渲染
const timer = setTimeout(() => {
if (editingModel) {
const extra = editingModel.extra_params || {};
form.setFieldsValue({
name: editingModel.name,
provider: editingModel.provider,
model_name: extra.model || "",
endpoint: editingModel.endpoint,
api_key: "",
extra_params: JSON.stringify(extra, null, 2),
is_enabled: editingModel.is_enabled,
remark: editingModel.remark,
});
} else {
form.resetFields();
form.setFieldsValue({ provider: "openai", is_enabled: true, extra_params: "{}" });
}
}, 0);
return () => clearTimeout(timer);
}, [modalOpen, editingModel]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
let extraParams = {};
try {
extraParams = JSON.parse(values.extra_params || "{}");
} catch {
message.error("扩展参数 JSON 格式错误");
return;
}
const payload = {
...values,
extra_params: { ...extraParams, ...(values.model_name ? { model: values.model_name } : {}) },
};
delete payload.model_name;
if (editingModel) {
if (!payload.api_key) delete payload.api_key;
await modelApi.updateModel(editingModel.id, payload);
message.success("更新成功");
} else {
await modelApi.createModel(payload);
message.success("创建成功");
}
setModalOpen(false);
fetchModels();
} catch (err: unknown) {
if (err instanceof Error) message.error(err.message);
}
};
const handleDelete = async (id: string) => {
await modelApi.deleteModel(id);
message.success("删除成功");
fetchModels();
};
const handleToggle = async (id: string, enabled: boolean) => {
await modelApi.toggleModel(id, enabled);
fetchModels();
};
const handleTest = async (id: string, name: string) => {
message.loading({ content: `正在测试 ${name}...`, key: "test" });
try {
const res = await modelApi.testModel(id);
if (res.success) {
message.success({ content: `${name} 测试通过`, key: "test" });
Modal.info({ title: `测试结果 - ${name}`, content: res.result, width: 600 });
} else {
message.error({ content: `测试失败: ${res.error}`, key: "test", duration: 5 });
}
} catch (err: unknown) {
message.error({ content: err instanceof Error ? err.message : "测试失败", key: "test", duration: 5 });
}
};
const columns = [
{ title: "名称", dataIndex: "name", key: "name", width: 150 },
{
title: "供应商",
dataIndex: "provider",
key: "provider",
width: 80,
render: (v: string) => {
const label = PROVIDERS.find((p) => p.value === v)?.label || v;
return <Tag>{label}</Tag>;
},
},
{
title: "模型名",
key: "model_name",
width: 140,
render: (_: unknown, r: AIModel) => <Tag>{(r.extra_params as Record<string,unknown>)?.model as string || "-"}</Tag>,
},
{ title: "接口地址", dataIndex: "endpoint", key: "endpoint", ellipsis: true },
{
title: "启用",
dataIndex: "is_enabled",
key: "is_enabled",
width: 80,
render: (v: boolean, record: AIModel) => (
<Switch
checked={v}
size="small"
onChange={(checked) => handleToggle(record.id, checked)}
/>
),
},
{
title: "备注",
dataIndex: "remark",
key: "remark",
width: 150,
ellipsis: true,
render: (v: string | null) => v || "-",
},
{
title: "操作",
key: "actions",
width: 160,
render: (_: unknown, record: AIModel) => (
<Space>
<Button
type="link"
size="small"
icon={<ExperimentOutlined />}
onClick={() => handleTest(record.id, record.name)}
/>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
<Popconfirm
title="确定删除此模型?"
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
<h2></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
<Table
columns={columns}
dataSource={models}
rowKey="id"
loading={loading}
pagination={false}
/>
<Modal
title={editingModel ? "编辑模型" : "新建模型"}
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
onOk={handleSubmit}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="模型名称" />
</Form.Item>
<Form.Item name="provider" label="供应商" rules={[{ required: true }]}>
<Select options={PROVIDERS} />
</Form.Item>
<Form.Item name="model_name" label="模型名" rules={[{ required: true }]}>
<Input placeholder="例如 deepseek-chat / gpt-4 / gpt-3.5-turbo" />
</Form.Item>
<Form.Item name="endpoint" label="接口地址" rules={[{ required: true }]}>
<Input placeholder="https://api.openai.com/v1/chat/completions" />
</Form.Item>
<Form.Item
name="api_key"
label="API Key"
rules={editingModel ? [] : [{ required: true, message: "请输入 API Key" }]}
>
<Input.Password placeholder={editingModel ? "留空则不修改" : "sk-..."} />
</Form.Item>
<Form.Item name="extra_params" label="扩展参数 (JSON)">
<Input.TextArea rows={4} placeholder='{"temperature": 0.7, "max_tokens": 2000}' />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { Card, Form, Select, InputNumber, Button, message, Spin } from "antd";
import { SaveOutlined } from "@ant-design/icons";
import * as settingsApi from "../api/settings";
import * as modelApi from "../api/models";
export default function Settings() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [models, setModels] = useState<{ label: string; value: string }[]>([]);
useEffect(() => {
(async () => {
try {
const [settings, modelList] = await Promise.all([
settingsApi.getAllSettings(),
modelApi.listModels({ enabled: true }),
]);
form.setFieldsValue({
default_model: settings.default_model || undefined,
max_concurrency: parseInt(settings.max_concurrency || "3", 10),
timeout: parseInt(settings.timeout || "120", 10),
});
setModels(modelList.map((m) => ({ label: m.name, value: m.id })));
} catch {
message.error("加载设置失败");
} finally {
setLoading(false);
}
})();
}, [form]);
const handleSave = async () => {
setSaving(true);
try {
const values = await form.validateFields();
await Promise.all([
settingsApi.updateSetting("default_model", values.default_model || ""),
settingsApi.updateSetting("max_concurrency", String(values.max_concurrency)),
settingsApi.updateSetting("timeout", String(values.timeout)),
]);
message.success("设置保存成功");
} catch (err: unknown) {
if (err instanceof Error) message.error(err.message);
} finally {
setSaving(false);
}
};
if (loading) return <Spin style={{ display: "block", marginTop: 100 }} />;
return (
<div style={{ maxWidth: 600 }}>
<h2 style={{ marginBottom: 16 }}></h2>
<Card>
<Form form={form} layout="vertical">
<Form.Item name="default_model" label="全局默认 AI 模型">
<Select
placeholder="留空则生成点必须指定模型"
allowClear
options={models}
notFoundContent="暂无可用模型,请先在模型管理中添加"
/>
</Form.Item>
<Form.Item name="max_concurrency" label="最大并发数" rules={[{ required: true }]}>
<InputNumber min={1} max={10} />
</Form.Item>
<Form.Item name="timeout" label="AI 调用超时(秒)" rules={[{ required: true }]}>
<InputNumber min={10} max={600} />
</Form.Item>
<Form.Item>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Card, Tag, Button, Space, Spin, Descriptions, List, Empty,
} from "antd";
import {
ArrowLeftOutlined, DownloadOutlined, FileTextOutlined,
} from "@ant-design/icons";
import request from "../api/request";
import type { GenerationPoint, AIModel } from "../types";
import * as modelApi from "../api/models";
import * as taskApi from "../api/tasks";
interface TaskDetailData {
id: string;
template_id: string;
template_name: string;
status: string;
result_file_path: string | null;
error_msg: string | null;
created_at: string;
finished_at: string | null;
points: GenerationPoint[];
}
const STATUS_MAP: Record<string, { color: string; label: string }> = {
pending: { color: "default", label: "等待中" },
processing: { color: "processing", label: "生成中" },
done: { color: "success", label: "已完成" },
failed: { color: "error", label: "失败" },
};
export default function TaskDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [task, setTask] = useState<TaskDetailData | null>(null);
const [models, setModels] = useState<AIModel[]>([]);
const [loading, setLoading] = useState(true);
const fetchTask = async () => {
if (!id) return;
setLoading(true);
try {
const { data } = await request.get<TaskDetailData>(`/tasks/${id}`, { params: { detail: true } });
setTask(data);
} catch {
setTask(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
Promise.all([
fetchTask(),
modelApi.listModels({ enabled: true }).then(setModels).catch(() => {}),
]);
}, [id]);
useEffect(() => {
if (!task || task.status === "done" || task.status === "failed") return;
const timer = setInterval(fetchTask, 3000);
return () => clearInterval(timer);
}, [task?.status]);
const getModelName = (modelId: string | null) => {
if (!modelId) return "默认模型";
return models.find((m) => m.id === modelId)?.name || modelId;
};
if (loading) return <Spin style={{ display: "block", marginTop: 100 }} />;
if (!task) return <Empty description="任务不存在" />;
const status = STATUS_MAP[task.status] || { color: "default", label: task.status };
return (
<div>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/tasks")}></Button>
<h3 style={{ margin: 0 }}></h3>
</Space>
{task.status === "done" && (
<Space>
<Button icon={<DownloadOutlined />} onClick={() => window.open(taskApi.getTaskDownloadUrl(task.id))}>
DOCX
</Button>
<Button onClick={() => window.open(taskApi.getTaskDownloadUrl(task.id, "pdf"))}>
PDF
</Button>
</Space>
)}
</div>
<Card style={{ marginBottom: 16 }}>
<Descriptions column={3} size="small">
<Descriptions.Item label="模板名称">{task.template_name}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={status.color}>{status.label}</Tag></Descriptions.Item>
<Descriptions.Item label="创建时间">{new Date(task.created_at).toLocaleString()}</Descriptions.Item>
{task.finished_at && (
<Descriptions.Item label="完成时间">{new Date(task.finished_at).toLocaleString()}</Descriptions.Item>
)}
{task.error_msg && (
<Descriptions.Item label="错误信息" span={3}>
<span style={{ color: "red" }}>{task.error_msg}</span>
</Descriptions.Item>
)}
</Descriptions>
</Card>
<h4 style={{ marginBottom: 12 }}>AI ({task.points.length})</h4>
{task.points.map((point, idx) => {
const modelName = getModelName(point.model_id);
const hasFiles = point.ref_file_path && point.ref_file_path !== "[]";
let fileNames: string[] = [];
if (hasFiles) {
try { fileNames = JSON.parse(point.ref_file_path || "[]").map((f: string) => f.split("/").pop() || f); } catch {}
}
return (
<Card
key={point.id}
size="small"
style={{ marginBottom: 8 }}
title={
<Space size={4}>
<span style={{ fontSize: 12, color: "#999" }}>#{idx + 1}</span>
<span style={{ fontSize: 13 }}>{point.prompt.slice(0, 40)}{point.prompt.length > 40 ? "..." : ""}</span>
</Space>
}
extra={<Tag color="blue">{modelName}</Tag>}
>
{point.remark && <p style={{ fontSize: 12, color: "#999", marginBottom: 8 }}>{point.remark}</p>}
{point.need_ref_file && (
<div>
{hasFiles && fileNames.length > 0 ? (
<List
size="small"
dataSource={fileNames}
renderItem={(name: string) => (
<List.Item><FileTextOutlined style={{ marginRight: 6 }} />{name}</List.Item>
)}
/>
) : (
<span style={{ fontSize: 12, color: "#999" }}></span>
)}
</div>
)}
</Card>
);
})}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Table, Button, Space, Tag, message, Popconfirm } from "antd";
import { DownloadOutlined, StopOutlined, ReloadOutlined, EyeOutlined } from "@ant-design/icons";
import type { GenerationTask } from "../types";
import * as taskApi from "../api/tasks";
const STATUS_MAP: Record<string, { color: string; label: string }> = {
pending: { color: "default", label: "等待中" },
processing: { color: "processing", label: "生成中" },
done: { color: "success", label: "已完成" },
failed: { color: "error", label: "失败" },
};
export default function TaskList() {
const navigate = useNavigate();
const [tasks, setTasks] = useState<GenerationTask[]>([]);
const [loading, setLoading] = useState(false);
const fetchTasks = useCallback(async () => {
setLoading(true);
try {
const data = await taskApi.listTasks();
setTasks(data);
} catch {
message.error("获取任务列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const handleCancel = async (id: string) => {
await taskApi.cancelTask(id);
message.success("已取消");
fetchTasks();
};
const columns = [
{
title: "模板 ID",
dataIndex: "template_id",
key: "template_id",
width: 120,
ellipsis: true,
render: (v: string) => v.slice(0, 8) + "...",
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (v: string) => {
const cfg = STATUS_MAP[v] || { color: "default", label: v };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: "错误信息",
dataIndex: "error_msg",
key: "error_msg",
ellipsis: true,
render: (v: string | null) => v || "-",
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "完成时间",
dataIndex: "finished_at",
key: "finished_at",
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "操作",
key: "actions",
width: 200,
render: (_: unknown, record: GenerationTask) => (
<Space>
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/tasks/${record.id}`)}
>
</Button>
{record.status === "done" && (
<>
<Button
size="small"
icon={<DownloadOutlined />}
onClick={() => window.open(taskApi.getTaskDownloadUrl(record.id))}
>
DOCX
</Button>
<Button
size="small"
onClick={() => window.open(taskApi.getTaskDownloadUrl(record.id, "pdf"))}
>
PDF
</Button>
</>
)}
{(record.status === "pending" || record.status === "processing") && (
<Popconfirm title="确定取消此任务?" onConfirm={() => handleCancel(record.id)}>
<Button size="small" danger icon={<StopOutlined />}>
</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
<h2></h2>
<Button icon={<ReloadOutlined />} onClick={fetchTasks}>
</Button>
</div>
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
onRow={(record) => ({
style: { cursor: "pointer" },
onClick: () => navigate(`/tasks/${record.id}`),
})}
/>
</div>
);
}
+523
View File
@@ -0,0 +1,523 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Layout, Button, Modal, Form, Input, Select,
Space, message, Spin, Popconfirm, Card, Switch,
} from "antd";
import {
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined,
ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
MenuOutlined, PaperClipOutlined,
} from "@ant-design/icons";
import { Editor } from "@tinymce/tinymce-react";
import type { Editor as TinyMCEEditor } from "tinymce";
import type { GenerationPoint, AIModel } from "../types";
import * as templateApi from "../api/templates";
import * as pointApi from "../api/generationPoints";
import * as modelApi from "../api/models";
import request from "../api/request";
import GenerateModal from "../components/GenerateModal";
const { Sider, Content } = Layout;
export default function TemplateEditor() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const editorRef = useRef<TinyMCEEditor | null>(null);
const selectionRef = useRef<{ start: number; end: number; text: string } | null>(null);
const [htmlContent, setHtmlContent] = useState("");
const [templateName, setTemplateName] = useState("");
const [points, setPoints] = useState<GenerationPoint[]>([]);
const [models, setModels] = useState<AIModel[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pointModalOpen, setPointModalOpen] = useState(false);
const [editingPoint, setEditingPoint] = useState<GenerationPoint | null>(null);
const [showSelectionBtn, setShowSelectionBtn] = useState(false);
const [selBtnPos, setSelBtnPos] = useState({ x: 0, y: 0 });
const [pointForm] = Form.useForm();
const [panelCollapsed, setPanelCollapsed] = useState(false);
const [generateModalOpen, setGenerateModalOpen] = useState(false);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
const loadData = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const [template, pointsData, modelsData] = await Promise.all([
templateApi.getTemplate(id),
pointApi.listGenerationPoints(id),
modelApi.listModels({ enabled: true }),
]);
setTemplateName(template.name);
setHtmlContent(template.html_content || "");
setPoints(pointsData);
setModels(modelsData);
} catch {
message.error("加载数据失败");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
loadData();
}, [loadData]);
const handleSave = async () => {
if (!id) return;
setSaving(true);
try {
const html = editorRef.current?.getContent() || htmlContent;
await templateApi.updateTemplateHtml(id, html);
setHtmlContent(html);
message.success("保存成功");
} catch {
message.error("保存失败");
} finally {
setSaving(false);
}
};
const handleEditorInit = (_evt: unknown, editor: TinyMCEEditor) => {
editorRef.current = editor;
editor.on("selectionchange", () => {
const sel = editor.selection.getContent({ format: "text" });
if (sel && sel.trim().length > 0) {
const content = editor.getContent();
const rng = editor.selection.getRng();
if (rng) {
const preRange = rng.cloneRange();
preRange.selectNodeContents(editor.getBody());
preRange.setEnd(rng.startContainer, rng.startOffset);
const start = preRange.toString().length;
const end = start + sel.length;
selectionRef.current = { start, end, text: sel.trim() };
}
const ed = editor.getContainer();
const rect = ed.getBoundingClientRect();
setSelBtnPos({ x: rect.left + 100, y: rect.top + 10 });
setShowSelectionBtn(true);
} else {
setShowSelectionBtn(false);
}
});
};
const handleCreatePoint = () => {
setEditingPoint(null);
setPointModalOpen(true);
};
const handleEditPoint = (point: GenerationPoint) => {
setEditingPoint(point);
setPointModalOpen(true);
};
useEffect(() => {
if (!pointModalOpen) return;
const timer = setTimeout(() => {
if (editingPoint) {
pointForm.setFieldsValue({
prompt: editingPoint.prompt,
model_id: editingPoint.model_id,
need_ref_file: editingPoint.need_ref_file,
remark: editingPoint.remark,
});
} else {
pointForm.resetFields();
if (selectionRef.current) {
pointForm.setFieldsValue({
prompt: `根据上下文生成关于「${selectionRef.current.text.slice(0, 20)}...」的内容`,
});
}
}
}, 0);
return () => clearTimeout(timer);
}, [pointModalOpen]);
const handlePointSubmit = async () => {
if (!id) return;
try {
const values = await pointForm.validateFields();
const position = selectionRef.current
? { start: selectionRef.current.start, end: selectionRef.current.end }
: editingPoint?.position;
if (editingPoint) {
const updateData: Record<string, unknown> = {};
if (values.prompt) updateData.prompt = values.prompt;
if (values.model_id) updateData.model_id = values.model_id;
updateData.need_ref_file = values.need_ref_file || false;
if (values.remark !== undefined) updateData.remark = values.remark;
await pointApi.updateGenerationPoint(editingPoint.id, updateData);
message.success("更新成功");
} else if (position) {
await pointApi.createGenerationPoint({
template_id: id,
position: JSON.stringify(position),
prompt: values.prompt,
model_id: values.model_id,
order: points.length,
selected_text: selectionRef.current?.text,
need_ref_file: values.need_ref_file || false,
remark: values.remark || "",
});
message.success("创建成功");
selectionRef.current = null;
setShowSelectionBtn(false);
} else {
message.warning("请先在编辑器中选中文本");
return;
}
setPointModalOpen(false);
const updated = await pointApi.listGenerationPoints(id);
setPoints(updated);
} catch (err: unknown) {
if (err instanceof Error) message.error(err.message);
}
};
const handleDeletePoint = async (pointId: string) => {
try {
await pointApi.deleteGenerationPoint(pointId);
message.success("删除成功");
} catch (err: unknown) {
if (err instanceof Error) message.error(err.message);
return;
}
try {
const updated = await pointApi.listGenerationPoints(id!);
setPoints(updated);
} catch {
setPoints((prev) => prev.filter((p) => p.id !== pointId));
}
};
const handleDragStart = (_e: React.DragEvent, index: number) => {
dragItem.current = index;
};
const handleDragEnter = (_e: React.DragEvent, index: number) => {
dragOverItem.current = index;
};
const handleDragEnd = async () => {
if (dragItem.current === null || dragOverItem.current === null) return;
if (dragItem.current === dragOverItem.current) return;
const newPoints = [...points];
const [moved] = newPoints.splice(dragItem.current, 1);
newPoints.splice(dragOverItem.current!, 0, moved);
const reordered = newPoints.map((p, i) => ({ ...p, order: i }));
setPoints(reordered);
try {
await pointApi.batchUpdateOrder(
reordered.map((p) => ({ id: p.id, order: p.order }))
);
} catch {
message.error("排序更新失败");
}
dragItem.current = null;
dragOverItem.current = null;
};
const handleTestPoint = async (pointId: string) => {
const point = points.find((p) => p.id === pointId);
if (!point) return;
if (point.need_ref_file) {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.multiple = true;
fileInput.accept = ".txt,.docx,.pdf";
fileInput.onchange = async (e: Event) => {
const files = (e.target as HTMLInputElement).files;
if (!files || files.length === 0) return;
try {
message.loading({ content: "测试中...", key: "test" });
const formData = new FormData();
Array.from(files).forEach((f) => formData.append("ref_files", f));
const { default: request } = await import("../api/request");
const { data } = await request.post<{ result: string }>(
`/generation-points/${pointId}/test`,
formData
);
message.success({ content: "测试完成", key: "test" });
Modal.info({ title: "AI 生成结果", content: data.result, width: 600 });
} catch {
message.error({ content: "测试失败", key: "test" });
}
};
fileInput.click();
return;
}
try {
message.loading({ content: "测试中...", key: "test" });
const result = await pointApi.testGenerationPoint(pointId);
message.success({ content: "测试完成", key: "test" });
Modal.info({ title: "AI 生成结果", content: result, width: 600 });
} catch {
message.error({ content: "测试失败", key: "test" });
}
};
const handleGenerate = () => {
if (!id) return;
if (points.length === 0) {
message.warning("请先添加至少一个生成点");
return;
}
setGenerateModalOpen(true);
};
if (loading) {
return (
<div style={{ textAlign: "center", padding: 100 }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ height: "calc(100vh - 120px)" }}>
<div
style={{
marginBottom: 12,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/templates")}>
</Button>
<h3 style={{ margin: 0 }}>{templateName}</h3>
</Space>
<Space>
<Button
icon={panelCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setPanelCollapsed(!panelCollapsed)}
/>
<Button icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
</Button>
<Button
type="primary"
danger
icon={<ThunderboltOutlined />}
onClick={handleGenerate}
>
</Button>
</Space>
</div>
<Layout style={{ height: "calc(100% - 50px)", background: "#fff" }}>
<Content style={{ position: "relative", overflow: "hidden" }}>
{showSelectionBtn && (
<div style={{
position: "absolute",
zIndex: 1310,
top: 0,
right: 8,
padding: "4px 8px",
background: "#fff",
borderRadius: "0 0 4px 4px",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
}}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleCreatePoint}
>
AI
</Button>
</div>
)}
<Editor
key={id}
tinymceScriptSrc="/tinymce/tinymce.min.js"
initialValue={htmlContent}
onInit={handleEditorInit}
init={{
height: "100%",
menubar: true,
license_key: "gpl",
plugins: [
"advlist", "autolink", "lists", "link", "image",
"charmap", "preview", "anchor", "searchreplace",
"visualblocks", "code", "fullscreen", "insertdatetime",
"media", "table", "help", "wordcount",
],
toolbar:
"undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | removeformat | help",
content_style: "body { font-family: Arial, sans-serif; font-size: 14px; }",
valid_elements: "*[*]",
extended_valid_elements: "p[style],h1[style],h2[style],h3[style],h4[style],h5[style],h6[style],div[style],span[style],li[style]",
}}
/>
</Content>
{!panelCollapsed && (
<Sider width={320} theme="light" style={{ padding: 16, overflow: "auto", borderLeft: "1px solid #f0f0f0" }}>
<h4 style={{ marginBottom: 12 }}> ({points.length})</h4>
{points.map((point, index) => {
const model = models.find((m) => m.id === point.model_id);
return (
<div
key={point.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnter={(e) => handleDragEnter(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => e.preventDefault()}
style={{ marginBottom: 8, cursor: "grab" }}
>
<Card
size="small"
title={
<Space size={4}>
<MenuOutlined style={{ color: "#bbb", fontSize: 12, cursor: "grab" }} />
<span style={{ fontSize: 12, color: "#999" }}>#{index + 1}</span>
{model && (
<span style={{ fontSize: 12, color: "#1677ff" }}>
{model.name}
</span>
)}
</Space>
}
extra={
<Space size={4}>
{point.need_ref_file && (
<Button
type="text"
size="small"
icon={<PaperClipOutlined />}
onClick={async () => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = ".txt,.docx,.pdf";
input.onchange = async (e) => {
const files = (e.target as HTMLInputElement).files;
if (!files?.length) return;
const fd = new FormData();
Array.from(files).forEach((f) => fd.append("ref_files", f));
await request.post(`/generation-points/${point.id}/upload-ref`, fd);
message.success("上传成功");
const updated = await pointApi.listGenerationPoints(id!);
setPoints(updated);
};
input.click();
}}
/>
)}
<Button
type="text"
size="small"
icon={<ExperimentOutlined />}
onClick={() => handleTestPoint(point.id)}
/>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => handleEditPoint(point)}
/>
<Popconfirm
title="确定删除?"
onConfirm={() => handleDeletePoint(point.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
}
>
<p style={{ fontSize: 12, color: "#666", margin: 0, wordBreak: "break-all" }}>
{point.prompt.slice(0, 80)}
{point.prompt.length > 80 && "..."}
</p>
</Card>
</div>
);
})}
{points.length === 0 && (
<p style={{ color: "#999", fontSize: 13 }}>
"设为 AI 生成点"
</p>
)}
</Sider>
)}
</Layout>
<Modal
title={editingPoint ? "编辑生成点" : "新建生成点"}
open={pointModalOpen}
onCancel={() => { setPointModalOpen(false); pointForm.resetFields(); }}
onOk={handlePointSubmit}
width={560}
>
<Form form={pointForm} layout="vertical">
{selectionRef.current && !editingPoint && (
<div
style={{
marginBottom: 12,
padding: 8,
background: "#f6f8fa",
borderRadius: 6,
fontSize: 13,
color: "#666",
}}
>
: {selectionRef.current.text.slice(0, 50)}
{selectionRef.current.text.length > 50 ? "..." : ""}
</div>
)}
<Form.Item name="prompt" label="提示词" rules={[{ required: true }]}>
<Input.TextArea
rows={4}
placeholder="请输入 AI 生成提示词,例如:请根据参考文件内容,生成一段项目背景介绍..."
/>
</Form.Item>
<Form.Item name="model_id" label="AI 模型">
<Select
allowClear
placeholder="留空则使用全局默认模型"
options={models.map((m) => ({ label: m.name, value: m.id }))}
/>
</Form.Item>
<Form.Item name="need_ref_file" label="需要参考文件" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="补充说明(可选)" />
</Form.Item>
</Form>
</Modal>
<GenerateModal
open={generateModalOpen}
onClose={async () => {
setGenerateModalOpen(false);
try {
const updated = await pointApi.listGenerationPoints(id!);
setPoints(updated);
} catch {}
}}
templateId={id || ""}
points={points}
models={models}
/>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState, useCallback } from "react";
import { Table, Button, Upload, Space, message, Popconfirm } from "antd";
import { EditOutlined, DeleteOutlined, DownloadOutlined, PlusOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import type { TemplateListItem } from "../types";
import * as templateApi from "../api/templates";
export default function TemplateList() {
const navigate = useNavigate();
const [templates, setTemplates] = useState<TemplateListItem[]>([]);
const [loading, setLoading] = useState(false);
const [refresh, setRefresh] = useState(0);
const fetchTemplates = useCallback(async () => {
setLoading(true);
try {
const data = await templateApi.listTemplates();
setTemplates(data);
} catch {
message.error("获取模板列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTemplates();
}, [fetchTemplates, refresh]);
const handleDelete = async (id: string) => {
await templateApi.deleteTemplate(id);
message.success("删除成功");
setRefresh((r) => r + 1);
};
const columns = [
{ title: "模板名称", dataIndex: "name", key: "name" },
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "更新时间",
dataIndex: "updated_at",
key: "updated_at",
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "操作",
key: "actions",
width: 240,
render: (_: unknown, record: TemplateListItem) => (
<Space>
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/editor/${record.id}`)}
>
</Button>
<Button
size="small"
icon={<DownloadOutlined />}
onClick={() => window.open(templateApi.getDownloadUrl(record.id))}
>
DOCX
</Button>
<Button
size="small"
onClick={() => window.open(templateApi.getDownloadUrl(record.id, "pdf"))}
>
PDF
</Button>
<Popconfirm title="确定删除此模板?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
<h2></h2>
<Upload
accept=".docx"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
try {
await templateApi.uploadTemplate(file as File);
message.success("上传成功");
setRefresh((r) => r + 1);
onSuccess?.("ok");
} catch {
message.error("上传失败");
onError?.(new Error("上传失败"));
}
}}
>
<Button type="primary" icon={<PlusOutlined />}>
</Button>
</Upload>
</div>
<Table
columns={columns}
dataSource={templates}
rowKey="id"
loading={loading}
pagination={false}
/>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
export interface AIModel {
id: string;
name: string;
provider: string;
endpoint: string;
api_key: string;
extra_params: Record<string, unknown>;
is_enabled: boolean;
remark: string | null;
created_at: string;
updated_at: string;
}
export interface Template {
id: string;
name: string;
file_path: string;
html_content: string | null;
created_at: string;
updated_at: string;
}
export interface TemplateListItem {
id: string;
name: string;
created_at: string;
updated_at: string;
}
export interface GenerationPoint {
id: string;
template_id: string;
position: { start: number; end: number };
prompt: string;
model_id: string | null;
ref_file_path: string | null;
need_ref_file: boolean;
remark: string | null;
order: number;
selected_text: string | null;
created_at: string;
updated_at: string;
}
export interface GenerationTask {
id: string;
template_id: string;
template_name?: string;
status: "pending" | "processing" | "done" | "failed";
result_file_path: string | null;
error_msg: string | null;
created_at: string;
finished_at: string | null;
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
});
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends libreoffice libreoffice-writer fonts-noto-cjk fonts-dejavu fontconfig \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY converter.py /app/converter.py
COPY demo /app/demo
ENTRYPOINT ["python", "/app/converter.py"]
+193
View File
@@ -0,0 +1,193 @@
# Word ↔ HTML High-Fidelity Converter
一个基于开源工具的 `.docx``.html` 双向转换程序,目标是尽量保持 Word 文档在浏览器和再次转回 Word 后的视觉效果一致。
> 现实说明:DOCX 和 HTML/CSS 的排版模型不同,严格意义上的“完全无损双向转换”不能只靠普通 HTML 实现。本项目采用 LibreOffice Writer 的转换引擎做高保真转换,再做 HTML/CSS 后处理;同时提供 PDF 渲染级视觉对比报告,便于验收。
## 功能
- DOCX → HTML
- 保留字体、字号、颜色、粗体、斜体、下划线、删除线
- 保留段落对齐、缩进、行距、页边距、分页符
- 保留表格结构、边框、合并单元格、部分背景色样式
- 保留图片、大小、位置、页眉页脚文本
-`<style>` 中的规则尽量内联到元素 `style` 中,降低浏览器打开时样式丢失概率
- HTML → DOCX
- 使用 LibreOffice 的 Writer HTML 导入器,解析 HTML/CSS 并导出 DOCX
- 对本程序生成的 HTML 回转 DOCX,视觉一致性更好
- 验收校验
- DOCX → PDF 渲染
- 将原始 DOCX 与回转 DOCX 的 PDF 页面转图片后做像素差异报告
## 依赖
### 1. 安装 LibreOffice
Linux 示例:
```bash
sudo apt-get update
sudo apt-get install -y libreoffice libreoffice-writer fonts-noto-cjk fontconfig
```
macOS:安装 LibreOffice 后,通常路径为:
```bash
/Applications/LibreOffice.app/Contents/MacOS/soffice
```
Windows:安装 LibreOffice 后,通常路径为:
```powershell
C:\Program Files\LibreOffice\program\soffice.com
```
如程序找不到 LibreOffice,可以设置:
```bash
export SOFFICE_BIN=/path/to/soffice
```
### 2. 安装 Python 依赖
```bash
pip install -r requirements.txt
```
## 快速使用
### DOCX 转 HTML
```bash
python converter.py docx2html demo/demo.docx output/demo.html
```
### HTML 转 DOCX
```bash
python converter.py html2docx output/demo2.html output/demo2.back.docx
```
### 一键往返转换
```bash
python converter.py roundtrip demo/demo.docx output/roundtrip
```
### 一键往返并生成视觉差异报告
```bash
python converter.py roundtrip demo/demo.docx output/roundtrip --verify
```
生成文件示例:
```text
output/roundtrip/demo.html
output/roundtrip/demo.roundtrip.docx
output/roundtrip/demo.visual-report.json
```
视觉报告字段说明:
```json
{
"exact_page_count": true,
"page_reports": [
{
"page": 1,
"same_size": true,
"mean_abs_diff_0_255": 4.2738
}
]
}
```
- `exact_page_count=true`:页数一致
- `same_size=true`:渲染页面尺寸一致
- `mean_abs_diff_0_255`:平均像素差异,越低越接近;复杂文档可设自己的验收阈值
## 生成测试 DOCX
项目里已经包含 `demo/demo.docx`。如果要重新生成:
```bash
python demo/make_demo_docx.py
```
## Docker 使用
构建镜像:
```bash
docker build -t word-html-converter .
```
DOCX 转 HTML
```bash
docker run --rm -v "$PWD:/work" word-html-converter \
docx2html /work/demo/demo.docx /work/output/demo.html
```
HTML 转 DOCX
```bash
docker run --rm -v "$PWD:/work" word-html-converter \
html2docx /work/output/demo.html /work/output/demo.back.docx
```
## 关键设计说明
### 为什么不用 Mammoth 或 Pandoc 作为主引擎?
- Mammoth 更适合把 DOCX 转成语义清晰的 HTML,它明确不是为了逐像素复制 Word 样式。
- Pandoc 很适合文档格式互转,但更偏结构化内容转换,不适合要求高度还原 Word 页面排版的场景。
- LibreOffice Writer 的 DOCX/HTML 导入导出更接近真实办公软件排版结果,所以本项目把它作为主转换内核。
### 为什么 HTML 转 DOCX 要强制 `HTML (StarWriter)` 输入过滤器?
LibreOffice 默认可能把 HTML 当作 Web 文档打开,导致导出 DOCX 时出现“no export filter”或排版丢失。本项目使用:
```bash
--infilter="HTML (StarWriter)"
```
让 HTML 作为 Writer 文档导入,再导出 Office Open XML DOCX。
### 可选归档模式
DOCX 转 HTML 时可以加:
```bash
python converter.py docx2html input.docx output.html --embed-source
```
这样会把原始 DOCX 以 base64 形式嵌入 HTML。HTML 转 DOCX 时可加:
```bash
python converter.py html2docx output.html restored.docx --prefer-embedded-source
```
这适合“HTML 只用于预览/存档,希望完全恢复原始 DOCX”的场景。注意:如果用户在 HTML 中编辑了内容,使用该模式会恢复原 DOCX,不会合并 HTML 编辑内容。
## 已知边界
以下内容在开源转换链路中很难保证完全一致,需要单独测试:
- 复杂浮动图片、环绕方式、文本框、艺术字、SmartArt
- Word 域、目录、脚注尾注、批注、修订痕迹
- 复杂多级编号、样式继承、主题字体
- 页面级精确排版,如不同 Word/LibreOffice 版本的字体度量差异
- 浏览器编辑 HTML 后再转 DOCX,不能保证所有 CSS 都能被 Writer 完整识别
## 推荐验收标准
建议不要用“字节级相同”验收 DOCX,因为二次生成的 DOCX 内部 XML 顺序、关系 ID、压缩结果通常会变。建议用:
1. 页数一致;
2. 关键表格行列、合并单元格、图片数量一致;
3. PDF 渲染视觉差异低于业务阈值;
4. 典型复杂样例人工抽检。
+596
View File
@@ -0,0 +1,596 @@
#!/usr/bin/env python3
"""
word-html-converter
Open-source DOCX <-> HTML conversion wrapper focused on high visual fidelity.
Core engine: LibreOffice headless conversion.
HTML post-processing: inline CSS, keep @page rules, normalize metadata.
Commands:
python converter.py docx2html input.docx output.html
python converter.py html2docx input.html output.docx
python converter.py roundtrip input.docx output_dir
python converter.py verify original.docx converted.docx report.json
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import warnings
import tempfile
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
import tinycss2
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
APP_NAME = "word-html-converter"
DEFAULT_DOCX_TO_HTML_FILTERS = [
# Writer/Web HTML filter preserves more page/header/footer information than the clean XHTML filter.
"html",
# Fallback clean XHTML Writer filter.
"html:XHTML Writer File:UTF8",
]
DEFAULT_HTML_TO_DOCX_FILTERS = [
'docx:"Office Open XML Text"',
"docx",
]
@dataclass
class ConvertResult:
source: str
output: str
command: List[str]
stdout: str
stderr: str
filter_name: str
class ConversionError(RuntimeError):
pass
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def find_soffice(explicit: Optional[str] = None) -> str:
"""Find LibreOffice/soffice executable on Linux/macOS/Windows."""
candidates: List[str] = []
if explicit:
candidates.append(explicit)
if os.environ.get("SOFFICE_BIN"):
candidates.append(os.environ["SOFFICE_BIN"])
candidates.extend([
"soffice",
"libreoffice",
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
r"C:\Program Files\LibreOffice\program\soffice.com",
r"C:\Program Files\LibreOffice\program\soffice.exe",
r"C:\Program Files (x86)\LibreOffice\program\soffice.com",
r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
])
for c in candidates:
if not c:
continue
if Path(c).exists():
return str(Path(c))
resolved = shutil.which(c)
if resolved:
return resolved
raise ConversionError(
"LibreOffice executable not found. Install LibreOffice, or set SOFFICE_BIN=/path/to/soffice."
)
def file_uri(path: Path) -> str:
return path.resolve().as_uri()
def run_soffice_convert(
source: Path,
outdir: Path,
convert_to: str,
soffice_bin: Optional[str] = None,
timeout: int = 120,
input_filter: Optional[str] = None,
) -> Tuple[subprocess.CompletedProcess[str], List[str]]:
source = source.resolve()
outdir = outdir.resolve()
outdir.mkdir(parents=True, exist_ok=True)
soffice = find_soffice(soffice_bin)
# Use an isolated LO user profile so conversion works even if desktop LO is open.
profile_dir = Path(tempfile.mkdtemp(prefix="lo-profile-"))
profile_uri = file_uri(profile_dir)
cmd = [
soffice,
f"-env:UserInstallation={profile_uri}",
"--headless",
"--nologo",
"--nofirststartwizard",
"--nodefault",
"--nolockcheck",
"--norestore",
]
if input_filter:
cmd.append(f"--infilter={input_filter}")
cmd.extend([
"--convert-to",
convert_to,
"--outdir",
str(outdir),
str(source),
])
try:
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
finally:
shutil.rmtree(profile_dir, ignore_errors=True)
return proc, cmd
def find_converted_file(before: set[Path], outdir: Path, source_stem: str, expected_ext: str) -> Optional[Path]:
expected = outdir / f"{source_stem}{expected_ext}"
if expected.exists():
return expected
after = set(outdir.iterdir())
created = [p for p in after - before if p.is_file() and p.suffix.lower() == expected_ext.lower()]
if created:
return max(created, key=lambda p: p.stat().st_mtime)
candidates = list(outdir.glob(f"{source_stem}*{expected_ext}"))
if candidates:
return max(candidates, key=lambda p: p.stat().st_mtime)
return None
def convert_with_fallbacks(
source: Path,
output: Path,
filters: Iterable[str],
expected_ext: str,
soffice_bin: Optional[str] = None,
timeout: int = 120,
input_filter: Optional[str] = None,
) -> ConvertResult:
source = source.resolve()
output = output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
last_error = ""
with tempfile.TemporaryDirectory(prefix="convert-work-") as tmp:
tmpdir = Path(tmp)
for filter_name in filters:
before = set(tmpdir.iterdir())
proc, cmd = run_soffice_convert(source, tmpdir, filter_name, soffice_bin, timeout, input_filter=input_filter)
converted = find_converted_file(before, tmpdir, source.stem, expected_ext)
if proc.returncode == 0 and converted and converted.exists():
if output.exists():
output.unlink()
shutil.move(str(converted), str(output))
# Move sidecar image/assets generated by HTML export.
for item in tmpdir.iterdir():
if item.is_file() and item.name != output.name:
dest = output.parent / item.name
if dest.exists():
dest.unlink()
shutil.move(str(item), str(dest))
return ConvertResult(
source=str(source),
output=str(output),
command=cmd,
stdout=proc.stdout,
stderr=proc.stderr,
filter_name=filter_name,
)
last_error = (
f"Filter failed: {filter_name}\n"
f"Return code: {proc.returncode}\nSTDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}"
)
raise ConversionError(last_error or "No LibreOffice conversion filter succeeded.")
def style_to_dict(style: str) -> Dict[str, str]:
result: Dict[str, str] = {}
if not style:
return result
declarations = tinycss2.parse_declaration_list(style, skip_comments=True, skip_whitespace=True)
for d in declarations:
if getattr(d, "type", None) == "declaration" and not d.name.startswith("--"):
value = tinycss2.serialize(d.value).strip()
if d.important:
value = f"{value} !important"
result[d.name.lower()] = value
return result
def dict_to_style(d: Dict[str, str]) -> str:
return "; ".join(f"{k}: {v}" for k, v in d.items() if v) + (";" if d else "")
def merge_inline_style(tag, declarations: Dict[str, str]) -> None:
current = style_to_dict(tag.get("style", ""))
current.update(declarations)
tag["style"] = dict_to_style(current)
def parse_css_rules(css: str) -> Tuple[List[Tuple[str, Dict[str, str]]], str]:
"""Return normal CSS rules plus preserved at-rules such as @page/@media."""
normal_rules: List[Tuple[str, Dict[str, str]]] = []
preserved_at_rules: List[str] = []
rules = tinycss2.parse_stylesheet(css, skip_comments=True, skip_whitespace=True)
for rule in rules:
if rule.type == "qualified-rule":
selector = tinycss2.serialize(rule.prelude).strip()
declarations: Dict[str, str] = {}
for d in tinycss2.parse_declaration_list(rule.content, skip_comments=True, skip_whitespace=True):
if getattr(d, "type", None) == "declaration":
value = tinycss2.serialize(d.value).strip()
if d.important:
value = f"{value} !important"
declarations[d.name.lower()] = value
if selector and declarations:
normal_rules.append((selector, declarations))
elif rule.type == "at-rule":
# @page is important for print/page-like preview; @media may contain print rules.
preserved_at_rules.append(tinycss2.serialize([rule]).strip())
return normal_rules, "\n".join(preserved_at_rules)
def inline_css(html: str, keep_style_tag: bool = True) -> str:
"""Inline style rules from <style> tags. This is intentionally conservative.
It handles most simple selectors generated by LibreOffice. Unsupported complex selectors
are skipped rather than breaking conversion.
"""
soup = BeautifulSoup(html, "lxml")
style_tags = soup.find_all("style")
preserved_css: List[str] = []
for st in style_tags:
css = st.string or st.get_text() or ""
normal_rules, at_rules = parse_css_rules(css)
if at_rules:
preserved_css.append(at_rules)
for selector, declarations in normal_rules:
selectors = [s.strip() for s in selector.split(",") if s.strip()]
for sel in selectors:
# Browser-only pseudo-selectors do not make sense for DOCX reconstruction.
if ":" in sel and not re.search(r":(first-child|last-child|nth-child)", sel):
continue
try:
matches = soup.select(sel)
except Exception:
continue
for tag in matches:
merge_inline_style(tag, declarations)
if not keep_style_tag:
st.decompose()
if keep_style_tag:
head = soup.head or soup.new_tag("head")
if not soup.head and soup.html:
soup.html.insert(0, head)
if preserved_css:
new_style = soup.new_tag("style")
new_style.string = "\n".join(preserved_css)
head.append(new_style)
return str(soup)
def ensure_html_metadata(html_path: Path, source_docx: Optional[Path] = None) -> None:
html = html_path.read_text(encoding="utf-8", errors="replace")
soup = BeautifulSoup(html, "lxml")
if soup.html is None:
new_html = soup.new_tag("html")
new_html.extend(soup.contents)
soup.append(new_html)
if soup.head is None:
head = soup.new_tag("head")
soup.html.insert(0, head)
if not soup.head.find("meta", attrs={"charset": True}):
meta = soup.new_tag("meta", charset="utf-8")
soup.head.insert(0, meta)
if not soup.head.find("meta", attrs={"name": "generator"}):
meta = soup.new_tag("meta")
meta["name"] = "generator"
meta["content"] = f"{APP_NAME}; LibreOffice headless"
soup.head.append(meta)
if source_docx and not soup.head.find("meta", attrs={"name": "docx-source-sha256"}):
meta = soup.new_tag("meta")
meta["name"] = "docx-source-sha256"
meta["content"] = sha256_file(source_docx)
soup.head.append(meta)
html_path.write_text(str(soup), encoding="utf-8")
def embed_source_docx(html_path: Path, source_docx: Path) -> None:
"""Optional: embed original docx for audit/archival round-trip fallback.
This does NOT merge arbitrary HTML edits back into the original docx. It is useful for
proving byte-exact preservation when the HTML is only used for preview/storage.
"""
html = html_path.read_text(encoding="utf-8", errors="replace")
soup = BeautifulSoup(html, "lxml")
if soup.body is None:
body = soup.new_tag("body")
if soup.html:
soup.html.append(body)
else:
soup.append(body)
old = soup.find(id="__source_docx_base64__")
if old:
old.decompose()
script = soup.new_tag("script")
script["type"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document+base64"
script["id"] = "__source_docx_base64__"
script.string = base64.b64encode(source_docx.read_bytes()).decode("ascii")
soup.body.append(script)
html_path.write_text(str(soup), encoding="utf-8")
def restore_embedded_docx_if_present(html_path: Path, output_docx: Path) -> bool:
html = html_path.read_text(encoding="utf-8", errors="replace")
soup = BeautifulSoup(html, "lxml")
script = soup.find(id="__source_docx_base64__")
if not script or not script.string:
return False
output_docx.parent.mkdir(parents=True, exist_ok=True)
output_docx.write_bytes(base64.b64decode(script.string.strip()))
return True
def docx_to_html(
source_docx: Path,
output_html: Path,
soffice_bin: Optional[str] = None,
inline: bool = True,
embed_source: bool = False,
timeout: int = 120,
) -> ConvertResult:
if source_docx.suffix.lower() != ".docx":
raise ConversionError("Input file must be .docx")
result = convert_with_fallbacks(
source=source_docx,
output=output_html,
filters=DEFAULT_DOCX_TO_HTML_FILTERS,
expected_ext=".html",
soffice_bin=soffice_bin,
timeout=timeout,
)
ensure_html_metadata(output_html, source_docx)
if inline:
html = output_html.read_text(encoding="utf-8", errors="replace")
output_html.write_text(inline_css(html, keep_style_tag=True), encoding="utf-8")
if embed_source:
embed_source_docx(output_html, source_docx)
return result
def html_to_docx(
source_html: Path,
output_docx: Path,
soffice_bin: Optional[str] = None,
timeout: int = 120,
prefer_embedded_source: bool = False,
) -> ConvertResult:
if source_html.suffix.lower() not in {".html", ".htm", ".xhtml"}:
raise ConversionError("Input file must be .html/.htm/.xhtml")
if prefer_embedded_source and restore_embedded_docx_if_present(source_html, output_docx):
return ConvertResult(
source=str(source_html.resolve()),
output=str(output_docx.resolve()),
command=["restore-embedded-docx"],
stdout="Restored embedded source DOCX.",
stderr="",
filter_name="embedded-docx",
)
return convert_with_fallbacks(
source=source_html,
output=output_docx,
filters=DEFAULT_HTML_TO_DOCX_FILTERS,
expected_ext=".docx",
soffice_bin=soffice_bin,
timeout=timeout,
input_filter="HTML (StarWriter)",
)
def convert_to_pdf(source: Path, output_pdf: Path, soffice_bin: Optional[str] = None, timeout: int = 120) -> ConvertResult:
return convert_with_fallbacks(
source=source,
output=output_pdf,
filters=['pdf:"writer_pdf_Export"', "pdf"],
expected_ext=".pdf",
soffice_bin=soffice_bin,
timeout=timeout,
)
def verify_docx_visual_similarity(
original_docx: Path,
converted_docx: Path,
report_json: Path,
soffice_bin: Optional[str] = None,
dpi: int = 120,
timeout: int = 120,
) -> Dict[str, object]:
"""Render two DOCX files to PDF, then compare page images.
Requires PyMuPDF and Pillow. The score is pragmatic, not a formal proof.
exact_page_count=true and low mean_abs_diff are good signs.
"""
try:
import fitz # type: ignore
from PIL import ImageChops, ImageStat # type: ignore
except Exception as exc:
raise ConversionError("verify requires pymupdf and pillow: pip install pymupdf pillow") from exc
with tempfile.TemporaryDirectory(prefix="verify-") as tmp:
tmpdir = Path(tmp)
pdf1 = tmpdir / "original.pdf"
pdf2 = tmpdir / "converted.pdf"
convert_to_pdf(original_docx, pdf1, soffice_bin, timeout)
convert_to_pdf(converted_docx, pdf2, soffice_bin, timeout)
doc1 = fitz.open(str(pdf1))
doc2 = fitz.open(str(pdf2))
pages = min(len(doc1), len(doc2))
page_reports: List[Dict[str, object]] = []
zoom = dpi / 72.0
matrix = fitz.Matrix(zoom, zoom)
for i in range(pages):
p1 = doc1.load_page(i).get_pixmap(matrix=matrix, alpha=False)
p2 = doc2.load_page(i).get_pixmap(matrix=matrix, alpha=False)
img1 = p1.pil_image()
img2 = p2.pil_image()
same_size = img1.size == img2.size
if not same_size:
# Compare common area, record size mismatch.
w = min(img1.width, img2.width)
h = min(img1.height, img2.height)
img1 = img1.crop((0, 0, w, h))
img2 = img2.crop((0, 0, w, h))
diff = ImageChops.difference(img1, img2)
stat = ImageStat.Stat(diff)
mean_abs_diff = sum(stat.mean) / len(stat.mean)
page_reports.append({
"page": i + 1,
"same_size": same_size,
"mean_abs_diff_0_255": round(mean_abs_diff, 4),
})
report: Dict[str, object] = {
"original": str(original_docx.resolve()),
"converted": str(converted_docx.resolve()),
"original_sha256": sha256_file(original_docx),
"converted_sha256": sha256_file(converted_docx),
"original_pages": len(doc1),
"converted_pages": len(doc2),
"exact_page_count": len(doc1) == len(doc2),
"dpi": dpi,
"pages_compared": pages,
"page_reports": page_reports,
}
report_json.parent.mkdir(parents=True, exist_ok=True)
report_json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return report
def cmd_docx2html(args: argparse.Namespace) -> None:
r = docx_to_html(
Path(args.input),
Path(args.output),
soffice_bin=args.soffice,
inline=not args.no_inline_css,
embed_source=args.embed_source,
timeout=args.timeout,
)
print(json.dumps(asdict(r), ensure_ascii=False, indent=2))
def cmd_html2docx(args: argparse.Namespace) -> None:
r = html_to_docx(
Path(args.input),
Path(args.output),
soffice_bin=args.soffice,
timeout=args.timeout,
prefer_embedded_source=args.prefer_embedded_source,
)
print(json.dumps(asdict(r), ensure_ascii=False, indent=2))
def cmd_roundtrip(args: argparse.Namespace) -> None:
src = Path(args.input).resolve()
outdir = Path(args.output_dir).resolve()
outdir.mkdir(parents=True, exist_ok=True)
html = outdir / f"{src.stem}.html"
docx_back = outdir / f"{src.stem}.roundtrip.docx"
report = outdir / f"{src.stem}.visual-report.json"
r1 = docx_to_html(src, html, args.soffice, inline=not args.no_inline_css, embed_source=args.embed_source, timeout=args.timeout)
r2 = html_to_docx(html, docx_back, args.soffice, timeout=args.timeout, prefer_embedded_source=args.prefer_embedded_source)
payload = {"docx2html": asdict(r1), "html2docx": asdict(r2)}
if args.verify:
payload["verify"] = verify_docx_visual_similarity(src, docx_back, report, args.soffice, args.dpi, args.timeout)
print(json.dumps(payload, ensure_ascii=False, indent=2))
def cmd_verify(args: argparse.Namespace) -> None:
report = verify_docx_visual_similarity(
Path(args.original),
Path(args.converted),
Path(args.report),
soffice_bin=args.soffice,
dpi=args.dpi,
timeout=args.timeout,
)
print(json.dumps(report, ensure_ascii=False, indent=2))
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Bidirectional high-fidelity DOCX <-> HTML converter using open-source tools.")
p.add_argument("--soffice", default=None, help="Path to LibreOffice soffice. Can also use SOFFICE_BIN env var.")
p.add_argument("--timeout", type=int, default=120, help="Conversion timeout seconds. Default: 120")
sub = p.add_subparsers(dest="command", required=True)
a = sub.add_parser("docx2html", help="Convert DOCX to HTML")
a.add_argument("input")
a.add_argument("output")
a.add_argument("--no-inline-css", action="store_true", help="Do not inline CSS from <style> tags")
a.add_argument("--embed-source", action="store_true", help="Embed original DOCX in HTML as base64 for archival fallback")
a.set_defaults(func=cmd_docx2html)
a = sub.add_parser("html2docx", help="Convert HTML to DOCX")
a.add_argument("input")
a.add_argument("output")
a.add_argument("--prefer-embedded-source", action="store_true", help="If HTML contains embedded original DOCX, restore it instead of converting")
a.set_defaults(func=cmd_html2docx)
a = sub.add_parser("roundtrip", help="DOCX -> HTML -> DOCX")
a.add_argument("input")
a.add_argument("output_dir")
a.add_argument("--no-inline-css", action="store_true")
a.add_argument("--embed-source", action="store_true")
a.add_argument("--prefer-embedded-source", action="store_true")
a.add_argument("--verify", action="store_true", help="Generate PDF-render visual diff report")
a.add_argument("--dpi", type=int, default=120)
a.set_defaults(func=cmd_roundtrip)
a = sub.add_parser("verify", help="Render two DOCX files to PDF and compare page images")
a.add_argument("original")
a.add_argument("converted")
a.add_argument("report")
a.add_argument("--dpi", type=int, default=120)
a.set_defaults(func=cmd_verify)
return p
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
try:
args.func(args)
return 0
except ConversionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
@@ -0,0 +1,71 @@
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Cm, Pt, RGBColor
from PIL import Image, ImageDraw
out_dir = Path(__file__).resolve().parent
img_path = out_dir / "demo-image.png"
image = Image.new("RGB", (420, 160), "white")
d = ImageDraw.Draw(image)
d.rectangle([10, 10, 410, 150], outline="black", width=2)
d.text((30, 60), "DOCX embedded image", fill="black")
image.save(img_path)
doc = Document()
section = doc.sections[0]
section.top_margin = Cm(2)
section.bottom_margin = Cm(2)
section.left_margin = Cm(2)
section.right_margin = Cm(2)
section.header.paragraphs[0].text = "页眉:Word/HTML 双向转换测试"
section.footer.paragraphs[0].text = "页脚:保留页眉页脚测试"
style = doc.styles["Normal"]
style.font.name = "Microsoft YaHei"
style.font.size = Pt(12)
h = doc.add_heading("Word 转 HTML 测试文件", level=1)
h.alignment = WD_ALIGN_PARAGRAPH.CENTER
p = doc.add_paragraph()
p.paragraph_format.first_line_indent = Cm(0.74)
p.paragraph_format.line_spacing = 1.5
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
r = p.add_run("这是一段测试文本:")
r.font.size = Pt(12)
r.font.color.rgb = RGBColor(0, 0, 0)
r = p.add_run("加粗")
r.bold = True
r = p.add_run("、斜体")
r.italic = True
r = p.add_run("、下划线")
r.underline = True
r = p.add_run("、删除线")
r.font.strike = True
r = p.add_run("、红色文字。")
r.font.color.rgb = RGBColor(192, 0, 0)
for text in ["一级项目 1", "一级项目 2"]:
doc.add_paragraph(text, style="List Bullet")
for text in ["编号项目 1", "编号项目 2"]:
doc.add_paragraph(text, style="List Number")
table = doc.add_table(rows=3, cols=3)
table.style = "Table Grid"
for i, row in enumerate(table.rows):
for j, cell in enumerate(row.cells):
cell.text = f"R{i+1}C{j+1}"
# Merge first row cells 1-2
merged = table.cell(0, 0).merge(table.cell(0, 1))
merged.text = "合并单元格"
p = doc.add_paragraph("下方是一张嵌入图片:")
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
doc.add_picture(str(img_path), width=Cm(8))
doc.add_page_break()
doc.add_paragraph("第二页内容,用于测试分页。")
doc.save(out_dir / "demo.docx")
print(out_dir / "demo.docx")
+6
View File
@@ -0,0 +1,6 @@
beautifulsoup4>=4.12
lxml>=5.0
tinycss2>=1.2
python-docx>=1.1
pillow>=10.0
pymupdf>=1.24