Compare commits

..

No commits in common. "c7e1baf41733b7d4e6aa565cebcff354184e9cd3" and "cc57cd168961b90bd209d7c6889b5b5f36da292e" have entirely different histories.

92 changed files with 15 additions and 8130 deletions

View File

@ -1,27 +0,0 @@
# Database
POSTGRES_USER=docforge
POSTGRES_PASSWORD=change-this-password
POSTGRES_DB=docforge
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
# Redis
REDIS_URL=redis://localhost:6379/0
# Backend
SECRET_KEY=change-me-to-a-random-secret-key
DEFAULT_MODEL_ID=
# File Storage
STORAGE_ROOT=./storage
# Celery
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/1
# CORS (JSON array format)
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
# AI (optional, can be configured in the web UI)
OPENAI_API_KEY=
AZURE_API_KEY=

5
.gitignore vendored
View File

@ -1,7 +1,3 @@
# Storage
storage/
backend/.vite/
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@ -298,4 +294,3 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
.vite/

View File

@ -8,41 +8,28 @@ Doc Forge Reds 是一款外部 Web 工具,支持上传 Word 模板、在线编
- ⚙️ 异步任务生成,状态追踪,结果下载 - ⚙️ 异步任务生成,状态追踪,结果下载
## 技术栈 ## 技术栈
- **前端**React 18 + TypeScript + Ant Design + TinyMCE - **前端**React 18 + TypeScript + Ant Design + TinyMCE
- **后端**Python 3.12 + FastAPI + SQLAlchemy - **后端**Python 3.10 + FastAPI + SQLAlchemy
- **异步任务**Celery + Redis - **异步任务**Celery + Redis
- **数据库**PostgreSQL 16 - **数据库**PostgreSQL 18.3
- **文档处理**Mammoth + python-docx + reportlab - **文档处理**Aspose.Words (或 Mammoth + python-docx)
- **部署**Docker + Docker Compose + Nginx - **部署**Docker + Docker Compose
## 快速开始 ## 快速开始
### 环境要求 ### 环境要求
- Docker & Docker Compose - Docker & Docker Compose
- Python 3.12+ (本地开发) - Python 3.10+ (本地开发)
- Node.js 22+ (本地开发) - Node.js 18+ (本地开发)
### 生产部署(一键启动) ### 使用 Docker 一键启动
```bash ```bash
cp .env.example .env # 编辑 SECRET_KEY git clone <repository-url>
./deploy.sh # 构建镜像 + 启动 + 数据库迁移 cd doc-forge-reds
docker-compose up -d
``` ```
访问 `http://localhost:3000` 访问 `http://localhost:3000` 进入前端界面
### 本地开发 ### 本地开发
```bash 1. 启动依赖服务`docker-compose up -d postgres redis`
# 1. 启动依赖服务 2. 后端`cd backend && python -m venv venv && source venv/bin/activate && pip install -r requirements.txt && uvicorn app.main:app --reload`
docker compose up -d postgres redis 3. 前端`cd web && npm install && npm run dev`
4. Celery Worker`celery -A app.tasks.celery_app worker --loglevel=info`
# 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
```
## 项目目录结构 ## 项目目录结构
``` ```
. .

View File

@ -1,9 +0,0 @@
venv/
__pycache__/
*.pyc
.env
.git
storage/
alembic/versions/
*.egg-info/
.pytest_cache/

View File

@ -1,21 +0,0 @@
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"]

View File

@ -1,19 +0,0 @@
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"]

View File

@ -1,37 +0,0 @@
[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

@ -1,51 +0,0 @@
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()

View File

@ -1,23 +0,0 @@
"""${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"}

View File

@ -1,27 +0,0 @@
"""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 ###

View File

@ -1,88 +0,0 @@
"""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 ###

View File

@ -1,33 +0,0 @@
"""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 ###

View File

@ -1,29 +0,0 @@
"""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

@ -1,128 +0,0 @@
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": "排序更新成功"}

View File

@ -1,115 +0,0 @@
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)}

View File

@ -1,16 +0,0 @@
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)

View File

@ -1,34 +0,0 @@
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}

View File

@ -1,233 +0,0 @@
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)}

View File

@ -1,150 +0,0 @@
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

@ -1,47 +0,0 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
PROJECT_NAME: str = "Doc Forge Reds"
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()

View File

@ -1,35 +0,0 @@
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()

View File

@ -1,25 +0,0 @@
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")

View File

@ -1,32 +0,0 @@
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

View File

@ -1,29 +0,0 @@
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"}

View File

@ -1,17 +0,0 @@
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",
]

View File

@ -1,18 +0,0 @@
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)

View File

@ -1,17 +0,0 @@
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)

View File

@ -1,27 +0,0 @@
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")

View File

@ -1,23 +0,0 @@
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")

View File

@ -1,11 +0,0 @@
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)

View File

@ -1,14 +0,0 @@
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

@ -1,46 +0,0 @@
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 "***"

View File

@ -1,45 +0,0 @@
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}, ...]")

View File

@ -1,44 +0,0 @@
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

View File

@ -1,31 +0,0 @@
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

@ -1,181 +0,0 @@
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)}")

View File

@ -1,168 +0,0 @@
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()

View File

@ -1,43 +0,0 @@
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)

View File

@ -1,46 +0,0 @@
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

@ -1,25 +0,0 @@
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"])

View File

@ -1,225 +0,0 @@
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()

View File

@ -1,16 +0,0 @@
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

View File

@ -1,34 +0,0 @@
#!/bin/bash
set -e
echo ">>> 启动 Doc Forge Reds <<<"
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"

View File

@ -1,101 +0,0 @@
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:

View File

@ -1,78 +0,0 @@
# 任务执行摘要
## 会话 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 模型管理 APICRUD + 启用/禁用切换6 个端点)。
12. 实现模板管理 API上传/获取HTML/更新HTML/下载docx+pdf6 个端点)。
13. 实现生成点管理 APICRUD + 参考文件上传 + 批量排序更新5 个端点)。
14. 注册路由api/router.py更新 main.py启动验证通过/health 返回 OK
- **执行结果**:
- 阶段一基础设施45 个文件,后端 5 张表模型导入正常,前端 TypeScript 编译零错误。
- 阶段二(核心业务 API17 个 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 进行端到端测试。

View File

@ -1,5 +0,0 @@
node_modules/
dist/
.git
*.local
public/tinymce/

25
web/.gitignore vendored
View File

@ -1,25 +0,0 @@
# 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?

View File

@ -1,8 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

View File

@ -1,18 +0,0 @@
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;"]

View File

@ -1,13 +0,0 @@
<!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>

View File

@ -1,26 +0,0 @@
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
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +0,0 @@
{
"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

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -1,24 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -1,26 +0,0 @@
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;

View File

@ -1,57 +0,0 @@
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;
}

View File

@ -1,36 +0,0 @@
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;
}

View File

@ -1,16 +0,0 @@
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;

View File

@ -1,10 +0,0 @@
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 });
}

View File

@ -1,27 +0,0 @@
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}`;
}

View File

@ -1,37 +0,0 @@
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}`;
}

View File

@ -1,57 +0,0 @@
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 Reds
</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>
);
}

View File

@ -1,179 +0,0 @@
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>
);
}

View File

@ -1,15 +0,0 @@
* {
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%;
}

View File

@ -1,12 +0,0 @@
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>
);

View File

@ -1,255 +0,0 @@
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>
);
}

View File

@ -1,86 +0,0 @@
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>
);
}

View File

@ -1,154 +0,0 @@
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>
);
}

View File

@ -1,143 +0,0 @@
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>
);
}

View File

@ -1,523 +0,0 @@
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>
);
}

View File

@ -1,118 +0,0 @@
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>
);
}

View File

@ -1,54 +0,0 @@
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;
}

View File

@ -1,26 +0,0 @@
{
"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"]
}

View File

@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -1,23 +0,0 @@
{
"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"]
}

View File

@ -1,15 +0,0 @@
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,
},
},
},
});

View File

@ -1,13 +0,0 @@
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"]

View File

@ -1,193 +0,0 @@
# 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. 典型复杂样例人工抽检。

View File

@ -1,596 +0,0 @@
#!/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.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

View File

@ -1,71 +0,0 @@
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")

View File

@ -1,6 +0,0 @@
beautifulsoup4>=4.12
lxml>=5.0
tinycss2>=1.2
python-docx>=1.1
pillow>=10.0
pymupdf>=1.24