init: 初始化项目
This commit is contained in:
@@ -6,9 +6,13 @@
|
||||
# TODO: where does this rule come from?
|
||||
docs/_book
|
||||
|
||||
.idea
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
test/
|
||||
|
||||
**/venv/**
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -1,3 +1,78 @@
|
||||
# doc-forge-reborn
|
||||
# AI 智能文档生成系统 (Doc Forge Reborn)
|
||||
## 项目简介
|
||||
Doc Forge Reborn 是一款外部 Web 工具,支持上传 Word 模板、在线编辑与预览,通过可视化方式标注文档中需要 AI 生成内容的区域,并可配置不同的 AI 模型进行内容生成。系统提供模型管理、模板管理、生成点管理、异步任务执行等完整功能,最终导出的文档保持原样式。
|
||||
## 核心功能
|
||||
- 📤 上传 Word 模板,在线编辑并预览
|
||||
- 🎯 框选标注生成点,配置提示词、参考文件和指定 AI 模型
|
||||
- 🤖 AI 模型管理(多供应商:OpenAI、Azure、自定义)
|
||||
- ⚙️ 异步任务生成,状态追踪,结果下载
|
||||
## 技术栈
|
||||
- **前端**:React 18 + TypeScript + Ant Design + TinyMCE
|
||||
- **后端**:Python 3.12 + FastAPI + SQLAlchemy
|
||||
- **异步任务**:Celery + Redis
|
||||
- **数据库**:PostgreSQL 16
|
||||
- **文档处理**:Mammoth + python-docx + reportlab
|
||||
- **部署**:Docker + Docker Compose + Nginx
|
||||
## 快速开始
|
||||
### 环境要求
|
||||
- Docker & Docker Compose
|
||||
- Python 3.12+ (本地开发)
|
||||
- Node.js 22+ (本地开发)
|
||||
### 生产部署(一键启动)
|
||||
```bash
|
||||
cp .env.example .env # 编辑 SECRET_KEY
|
||||
./deploy.sh # 构建镜像 + 启动 + 数据库迁移
|
||||
```
|
||||
访问 `http://localhost:3000`。
|
||||
### 本地开发
|
||||
```bash
|
||||
# 1. 启动依赖服务
|
||||
docker compose up -d postgres redis
|
||||
|
||||
对比 doc-forge 富文本编辑组件,文本标注ai块
|
||||
# 2. 后端
|
||||
cd backend
|
||||
python -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# 3. Celery Worker(新终端)
|
||||
cd backend && source venv/bin/activate
|
||||
celery -A app.tasks.celery_app worker --loglevel=info
|
||||
|
||||
# 4. 前端(新终端)
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
## 项目目录结构
|
||||
```
|
||||
.
|
||||
├── backend/ # Python 后端
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ ├── core/ # 配置、安全、数据库
|
||||
│ │ ├── models/ # ORM 模型
|
||||
│ │ ├── schemas/ # Pydantic 模式
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ ├── tasks/ # Celery 任务
|
||||
│ │ └── main.py
|
||||
│ ├── migrations/ # Alembic 迁移
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
├── web/ # React 前端
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 调用封装
|
||||
│ │ ├── components/ # 可复用 UI
|
||||
│ │ ├── pages/ # 页面
|
||||
│ │ ├── hooks/ # 自定义 Hooks
|
||||
│ │ └── store/ # 状态管理
|
||||
│ ├── package.json
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── docs/ # 项目文档
|
||||
```
|
||||
## 文档索引
|
||||
- [技术方案概述](docs/技术方案概述.md)
|
||||
- [数据库设计](docs/数据库设计.md)
|
||||
- [API 设计](docs/API设计.md)
|
||||
- [任务拆解清单](docs/任务拆解清单.md)
|
||||
- [部署指南](docs/部署指南.md)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.git
|
||||
storage/
|
||||
alembic/versions/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "1e9afdbb",
|
||||
"configHash": "ac7453e4",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "29840d3b",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libpq-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /app/storage/templates /app/storage/ref_files /app/storage/results
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libpq-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /app/storage/templates /app/storage/ref_files /app/storage/results
|
||||
|
||||
CMD ["celery", "-A", "app.tasks.celery_app", "worker", "--loglevel=info", "--concurrency=4"]
|
||||
@@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql://docforge:docforge@localhost:5432/docforge
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,51 @@
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add selected_text to generation_points
|
||||
|
||||
Revision ID: 011bde04d871
|
||||
Revises: 857f5a3874dd
|
||||
Create Date: 2026-07-06 18:19:15.771438
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '011bde04d871'
|
||||
down_revision: Union[str, None] = '857f5a3874dd'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('generation_points', sa.Column('selected_text', sa.Text(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('generation_points', 'selected_text')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 857f5a3874dd
|
||||
Revises:
|
||||
Create Date: 2026-07-06 16:42:37.845634
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = '857f5a3874dd'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('models',
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('provider', sa.String(length=50), nullable=False),
|
||||
sa.Column('endpoint', sa.String(length=255), nullable=False),
|
||||
sa.Column('api_key', sa.Text(), nullable=False),
|
||||
sa.Column('extra_params', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('is_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('remark', sa.Text(), nullable=True),
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('system_config',
|
||||
sa.Column('key', sa.String(length=50), nullable=False),
|
||||
sa.Column('value', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.String(length=200), nullable=True),
|
||||
sa.PrimaryKeyConstraint('key')
|
||||
)
|
||||
op.create_table('templates',
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('file_path', sa.String(length=500), nullable=False),
|
||||
sa.Column('html_content', sa.Text(), nullable=True),
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('generation_points',
|
||||
sa.Column('template_id', sa.UUID(), nullable=False),
|
||||
sa.Column('position', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('prompt', sa.Text(), nullable=False),
|
||||
sa.Column('model_id', sa.UUID(), nullable=True),
|
||||
sa.Column('ref_file_path', sa.String(length=500), nullable=True),
|
||||
sa.Column('order', sa.Integer(), nullable=False),
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['model_id'], ['models.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['template_id'], ['templates.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_generation_points_template_id'), 'generation_points', ['template_id'], unique=False)
|
||||
op.create_table('generation_tasks',
|
||||
sa.Column('template_id', sa.UUID(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('result_file_path', sa.String(length=500), nullable=True),
|
||||
sa.Column('error_msg', sa.Text(), nullable=True),
|
||||
sa.Column('celery_task_id', sa.String(length=100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['template_id'], ['templates.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_generation_tasks_template_id'), 'generation_tasks', ['template_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_generation_tasks_template_id'), table_name='generation_tasks')
|
||||
op.drop_table('generation_tasks')
|
||||
op.drop_index(op.f('ix_generation_points_template_id'), table_name='generation_points')
|
||||
op.drop_table('generation_points')
|
||||
op.drop_table('templates')
|
||||
op.drop_table('system_config')
|
||||
op.drop_table('models')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Change ref_file_path to Text for multi-file support
|
||||
|
||||
Revision ID: 9066ecf60786
|
||||
Revises: b4b91d4466e4
|
||||
Create Date: 2026-07-06 21:01:41.552375
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '9066ecf60786'
|
||||
down_revision: Union[str, None] = 'b4b91d4466e4'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('generation_points', 'ref_file_path',
|
||||
existing_type=sa.VARCHAR(length=500),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('generation_points', 'ref_file_path',
|
||||
existing_type=sa.Text(),
|
||||
type_=sa.VARCHAR(length=500),
|
||||
existing_nullable=True)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Add need_ref_file and remark to generation_points
|
||||
|
||||
Revision ID: b4b91d4466e4
|
||||
Revises: 011bde04d871
|
||||
Create Date: 2026-07-06 18:27:03.054325
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'b4b91d4466e4'
|
||||
down_revision: Union[str, None] = '011bde04d871'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('generation_points', sa.Column('need_ref_file', sa.Boolean(), nullable=False))
|
||||
op.add_column('generation_points', sa.Column('remark', sa.Text(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('generation_points', 'remark')
|
||||
op.drop_column('generation_points', 'need_ref_file')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,128 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security_middleware import validate_file_extension
|
||||
from app.models.generation_point import GenerationPoint
|
||||
from app.models.template import Template
|
||||
from app.models.ai_model import AIModel
|
||||
from app.schemas.generation_point import (
|
||||
GenerationPointCreate,
|
||||
GenerationPointUpdate,
|
||||
GenerationPointResponse,
|
||||
BatchOrderUpdate,
|
||||
)
|
||||
from app.services.file_storage import save_upload, REF_FILES_DIR
|
||||
|
||||
router = APIRouter(prefix="/generation-points", tags=["生成点管理"])
|
||||
|
||||
|
||||
@router.post("", response_model=GenerationPointResponse)
|
||||
async def create_generation_point(
|
||||
template_id: uuid.UUID = Form(...),
|
||||
position: str = Form(...),
|
||||
prompt: str = Form(...),
|
||||
model_id: uuid.UUID | None = Form(None),
|
||||
order: int = Form(0),
|
||||
selected_text: str | None = Form(None),
|
||||
need_ref_file: bool = Form(False),
|
||||
remark: str | None = Form(None),
|
||||
ref_file: UploadFile | None = File(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
|
||||
template_result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
if not template_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
if model_id:
|
||||
model_result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
if not model_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
|
||||
ref_file_path = None
|
||||
if ref_file and ref_file.filename:
|
||||
validate_file_extension(ref_file.filename)
|
||||
ref_file_path = await save_upload(ref_file, REF_FILES_DIR)
|
||||
|
||||
point = GenerationPoint(
|
||||
template_id=template_id,
|
||||
position=json.loads(position),
|
||||
prompt=prompt,
|
||||
model_id=model_id,
|
||||
order=order,
|
||||
ref_file_path=ref_file_path,
|
||||
selected_text=selected_text,
|
||||
need_ref_file=need_ref_file,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(point)
|
||||
await db.flush()
|
||||
await db.refresh(point)
|
||||
return point
|
||||
|
||||
|
||||
@router.get("", response_model=list[GenerationPointResponse])
|
||||
async def list_generation_points(
|
||||
template_id: uuid.UUID = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = (
|
||||
select(GenerationPoint)
|
||||
.where(GenerationPoint.template_id == template_id)
|
||||
.order_by(GenerationPoint.order.asc(), GenerationPoint.created_at.asc())
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{point_id}", response_model=GenerationPointResponse)
|
||||
async def get_generation_point(point_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
||||
point = result.scalar_one_or_none()
|
||||
if not point:
|
||||
raise HTTPException(status_code=404, detail="生成点不存在")
|
||||
return point
|
||||
|
||||
|
||||
@router.put("/{point_id}", response_model=GenerationPointResponse)
|
||||
async def update_generation_point(
|
||||
point_id: str,
|
||||
data: GenerationPointUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
||||
point = result.scalar_one_or_none()
|
||||
if not point:
|
||||
raise HTTPException(status_code=404, detail="生成点不存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(point, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(point)
|
||||
return point
|
||||
|
||||
|
||||
@router.delete("/{point_id}")
|
||||
async def delete_generation_point(point_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
||||
point = result.scalar_one_or_none()
|
||||
if not point:
|
||||
raise HTTPException(status_code=404, detail="生成点不存在")
|
||||
await db.delete(point)
|
||||
return {"detail": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/batch-order")
|
||||
async def batch_update_order(data: BatchOrderUpdate, db: AsyncSession = Depends(get_db)):
|
||||
for item in data.points:
|
||||
result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == item["id"]))
|
||||
point = result.scalar_one_or_none()
|
||||
if point:
|
||||
point.order = item["order"]
|
||||
await db.flush()
|
||||
return {"detail": "排序更新成功"}
|
||||
@@ -0,0 +1,115 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security import encrypt_api_key
|
||||
from app.models.ai_model import AIModel
|
||||
from app.schemas.ai_model import AIModelCreate, AIModelUpdate, AIModelToggle, AIModelResponse
|
||||
from app.services.ai_adapter import call_ai_model
|
||||
|
||||
router = APIRouter(prefix="/models", tags=["AI模型管理"])
|
||||
|
||||
|
||||
@router.post("", response_model=AIModelResponse)
|
||||
async def create_model(data: AIModelCreate, db: AsyncSession = Depends(get_db)):
|
||||
encrypted_key = encrypt_api_key(data.api_key)
|
||||
model = AIModel(
|
||||
name=data.name,
|
||||
provider=data.provider,
|
||||
endpoint=data.endpoint,
|
||||
api_key=encrypted_key,
|
||||
extra_params=data.extra_params,
|
||||
is_enabled=data.is_enabled,
|
||||
remark=data.remark,
|
||||
)
|
||||
db.add(model)
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
return model
|
||||
|
||||
|
||||
@router.get("", response_model=list[AIModelResponse])
|
||||
async def list_models(
|
||||
enabled: bool | None = Query(None, description="过滤启用/禁用"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(AIModel)
|
||||
if enabled is not None:
|
||||
query = query.where(AIModel.is_enabled == enabled)
|
||||
query = query.offset(skip).limit(limit).order_by(AIModel.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
models = result.scalars().all()
|
||||
return models
|
||||
|
||||
|
||||
@router.get("/{model_id}", response_model=AIModelResponse)
|
||||
async def get_model(model_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
return model
|
||||
|
||||
|
||||
@router.put("/{model_id}", response_model=AIModelResponse)
|
||||
async def update_model(model_id: str, data: AIModelUpdate, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "api_key" in update_data and update_data["api_key"] is not None:
|
||||
update_data["api_key"] = encrypt_api_key(update_data["api_key"])
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(model, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
return model
|
||||
|
||||
|
||||
@router.delete("/{model_id}")
|
||||
async def delete_model(model_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
await db.delete(model)
|
||||
return {"detail": "删除成功"}
|
||||
|
||||
|
||||
@router.patch("/{model_id}/toggle", response_model=AIModelResponse)
|
||||
async def toggle_model(model_id: str, data: AIModelToggle, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
model.is_enabled = data.is_enabled
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
return model
|
||||
|
||||
|
||||
@router.post("/{model_id}/test")
|
||||
async def test_model(model_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(AIModel).where(AIModel.id == model_id))
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
|
||||
model_config = {
|
||||
"provider": model.provider,
|
||||
"endpoint": model.endpoint,
|
||||
"api_key": model.api_key,
|
||||
"extra_params": model.extra_params,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await call_ai_model(model_config, "请用一句话介绍你自己。")
|
||||
return {"success": True, "result": response}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.models import router as models_router
|
||||
from app.api.templates import router as templates_router
|
||||
from app.api.generation_points import router as generation_points_router
|
||||
from app.api.tasks import router as tasks_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
api_router = APIRouter(prefix=settings.API_V1_PREFIX)
|
||||
api_router.include_router(models_router)
|
||||
api_router.include_router(templates_router)
|
||||
api_router.include_router(generation_points_router)
|
||||
api_router.include_router(tasks_router, prefix="")
|
||||
api_router.include_router(settings_router)
|
||||
@@ -0,0 +1,34 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_all_settings(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(SystemConfig))
|
||||
configs = result.scalars().all()
|
||||
return {c.key: c.value for c in configs}
|
||||
|
||||
|
||||
@router.put("/{key}")
|
||||
async def update_setting(key: str, body: dict, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
value = body.get("value", "")
|
||||
description = body.get("description", "")
|
||||
|
||||
if config:
|
||||
config.value = value
|
||||
if description:
|
||||
config.description = description
|
||||
else:
|
||||
config = SystemConfig(key=key, value=value, description=description)
|
||||
db.add(config)
|
||||
|
||||
await db.flush()
|
||||
return {"key": key, "value": value}
|
||||
@@ -0,0 +1,233 @@
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||
from fastapi.responses import Response
|
||||
from typing import List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security_middleware import validate_file_extension
|
||||
from app.models.generation_task import GenerationTask
|
||||
from app.models.generation_point import GenerationPoint
|
||||
from app.models.template import Template
|
||||
from app.models.ai_model import AIModel
|
||||
from app.schemas.generation_task import TaskResponse, GenerateResponse, SingleTestRequest, SingleTestResponse, TaskDetailResponse
|
||||
from app.services.document_processor import docx_to_pdf_bytes
|
||||
from app.services.file_storage import get_file_content, save_upload, REF_FILES_DIR
|
||||
from app.services.ai_adapter import call_ai_model
|
||||
from app.services.ref_parser import parse_reference_file, parse_reference_files
|
||||
from app.tasks.generate import generate_document
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/generate", response_model=GenerateResponse)
|
||||
async def trigger_generation(template_id: str, db: AsyncSession = Depends(get_db)):
|
||||
template_result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
if not template_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
points_result = await db.execute(
|
||||
select(GenerationPoint).where(GenerationPoint.template_id == template_id)
|
||||
)
|
||||
points = points_result.scalars().all()
|
||||
if not points:
|
||||
raise HTTPException(status_code=400, detail="模板没有生成点")
|
||||
|
||||
task = GenerationTask(
|
||||
template_id=uuid.UUID(template_id),
|
||||
status="pending",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
|
||||
celery_task = generate_document.delay(str(task.id))
|
||||
task.celery_task_id = celery_task.id
|
||||
await db.commit()
|
||||
|
||||
return GenerateResponse(task_id=task.id, status="pending")
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str, detail: bool = Query(False), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if detail:
|
||||
tpl_result = await db.execute(select(Template).where(Template.id == task.template_id))
|
||||
tpl = tpl_result.scalar_one_or_none()
|
||||
points_result = await db.execute(
|
||||
select(GenerationPoint).where(GenerationPoint.template_id == task.template_id).order_by(GenerationPoint.order.asc())
|
||||
)
|
||||
points = points_result.scalars().all()
|
||||
return TaskDetailResponse(
|
||||
id=task.id,
|
||||
template_id=task.template_id,
|
||||
template_name=tpl.name if tpl else "",
|
||||
status=task.status,
|
||||
result_file_path=task.result_file_path,
|
||||
error_msg=task.error_msg,
|
||||
created_at=task.created_at,
|
||||
finished_at=task.finished_at,
|
||||
points=[{
|
||||
"id": str(p.id),
|
||||
"prompt": p.prompt,
|
||||
"position": p.position,
|
||||
"model_id": str(p.model_id) if p.model_id else None,
|
||||
"ref_file_path": p.ref_file_path,
|
||||
"selected_text": p.selected_text,
|
||||
"need_ref_file": p.need_ref_file,
|
||||
"remark": p.remark,
|
||||
"order": p.order,
|
||||
} for p in points],
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/download")
|
||||
async def download_task_result(
|
||||
task_id: str,
|
||||
format: str = Query("docx", pattern="^(docx|pdf)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if task.status != "done" or not task.result_file_path:
|
||||
raise HTTPException(status_code=400, detail="任务未完成或无结果文件")
|
||||
|
||||
file_content = await get_file_content(task.result_file_path)
|
||||
|
||||
if format == "pdf":
|
||||
file_content = docx_to_pdf_bytes(file_content)
|
||||
media_type = "application/pdf"
|
||||
filename = f"result_{task_id}.pdf"
|
||||
else:
|
||||
media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
filename = f"result_{task_id}.docx"
|
||||
|
||||
return Response(
|
||||
content=file_content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(GenerationTask).offset(skip).limit(limit).order_by(GenerationTask.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if task.status not in ("pending", "processing"):
|
||||
raise HTTPException(status_code=400, detail="任务无法取消")
|
||||
|
||||
if task.celery_task_id:
|
||||
from app.tasks.celery_app import celery_app
|
||||
celery_app.control.revoke(task.celery_task_id, terminate=True)
|
||||
|
||||
task.status = "failed"
|
||||
task.error_msg = "用户取消"
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"detail": "任务已取消"}
|
||||
|
||||
|
||||
@router.post("/generation-points/{point_id}/test", response_model=SingleTestResponse)
|
||||
async def test_single_point(
|
||||
point_id: str,
|
||||
ref_files: List[UploadFile] = File(default=[]),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
point_result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
||||
point = point_result.scalar_one_or_none()
|
||||
if not point:
|
||||
raise HTTPException(status_code=404, detail="生成点不存在")
|
||||
|
||||
tmp_paths = []
|
||||
if point.need_ref_file:
|
||||
if not ref_files and not point.ref_file_path:
|
||||
raise HTTPException(status_code=400, detail="此生成点需要上传参考文件")
|
||||
|
||||
for f in ref_files:
|
||||
if f.filename:
|
||||
validate_file_extension(f.filename)
|
||||
path = await save_upload(f, REF_FILES_DIR)
|
||||
tmp_paths.append(path)
|
||||
|
||||
model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}}
|
||||
if point.model_id:
|
||||
model_result = await db.execute(select(AIModel).where(AIModel.id == point.model_id))
|
||||
model = model_result.scalar_one_or_none()
|
||||
if model:
|
||||
model_config = {
|
||||
"provider": model.provider,
|
||||
"endpoint": model.endpoint,
|
||||
"api_key": model.api_key,
|
||||
"extra_params": model.extra_params,
|
||||
}
|
||||
|
||||
ref_content = ""
|
||||
if tmp_paths:
|
||||
ref_content = await parse_reference_files(json.dumps(tmp_paths))
|
||||
elif point.ref_file_path:
|
||||
ref_content = await parse_reference_files(point.ref_file_path)
|
||||
|
||||
try:
|
||||
ai_result = await call_ai_model(model_config, point.prompt, ref_content)
|
||||
return SingleTestResponse(result=ai_result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"AI 调用失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generation-points/{point_id}/upload-ref")
|
||||
async def upload_ref_files(
|
||||
point_id: str,
|
||||
ref_files: List[UploadFile] = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
point_result = await db.execute(select(GenerationPoint).where(GenerationPoint.id == point_id))
|
||||
point = point_result.scalar_one_or_none()
|
||||
if not point:
|
||||
raise HTTPException(status_code=404, detail="生成点不存在")
|
||||
|
||||
paths = []
|
||||
for f in ref_files:
|
||||
if f.filename:
|
||||
validate_file_extension(f.filename)
|
||||
path = await save_upload(f, REF_FILES_DIR)
|
||||
paths.append(path)
|
||||
|
||||
existing = []
|
||||
if point.ref_file_path:
|
||||
try:
|
||||
existing = json.loads(point.ref_file_path)
|
||||
if not isinstance(existing, list):
|
||||
existing = [point.ref_file_path]
|
||||
except json.JSONDecodeError:
|
||||
existing = [point.ref_file_path] if point.ref_file_path else []
|
||||
|
||||
all_paths = existing + paths
|
||||
point.ref_file_path = json.dumps(all_paths)
|
||||
await db.commit()
|
||||
|
||||
return {"success": True, "count": len(all_paths)}
|
||||
@@ -0,0 +1,150 @@
|
||||
import uuid
|
||||
import bleach
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security_middleware import validate_file_extension
|
||||
from app.models.template import Template
|
||||
from app.schemas.template import TemplateResponse, TemplateListItem, HTMLContentResponse, HTMLUpdateRequest
|
||||
from app.services.file_storage import save_upload, get_file_content, delete_file, TEMPLATES_DIR
|
||||
from app.services.document_processor import docx_to_html, html_to_docx_bytes, docx_to_pdf_bytes
|
||||
|
||||
ALLOWED_TAGS = [
|
||||
"p", "div", "span", "br", "hr",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li",
|
||||
"a", "img", "table", "thead", "tbody", "tr", "td", "th",
|
||||
"b", "i", "u", "strong", "em", "del", "sub", "sup",
|
||||
"pre", "code", "blockquote",
|
||||
]
|
||||
ALLOWED_ATTRS = {
|
||||
"a": ["href", "title", "target"],
|
||||
"img": ["src", "alt", "width", "height"],
|
||||
"td": ["colspan", "rowspan"],
|
||||
"th": ["colspan", "rowspan"],
|
||||
"p": ["style"],
|
||||
"span": ["style"],
|
||||
"div": ["style"],
|
||||
"table": ["style"],
|
||||
}
|
||||
|
||||
router = APIRouter(prefix="/templates", tags=["模板管理"])
|
||||
|
||||
|
||||
@router.post("", response_model=TemplateResponse)
|
||||
async def upload_template(
|
||||
file: UploadFile = File(...),
|
||||
name: str | None = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not file.filename or not file.filename.endswith(".docx"):
|
||||
raise HTTPException(status_code=400, detail="仅支持 .docx 文件")
|
||||
|
||||
validate_file_extension(file.filename)
|
||||
file_path = await save_upload(file, TEMPLATES_DIR)
|
||||
file_content = await get_file_content(file_path)
|
||||
html_content = await docx_to_html(file_content)
|
||||
|
||||
template = Template(
|
||||
name=name or file.filename.replace(".docx", ""),
|
||||
file_path=file_path,
|
||||
html_content=html_content,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
@router.get("", response_model=list[TemplateListItem])
|
||||
async def list_templates(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Template).offset(skip).limit(limit).order_by(Template.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateResponse)
|
||||
async def get_template(template_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return template
|
||||
|
||||
|
||||
@router.get("/{template_id}/html", response_model=HTMLContentResponse)
|
||||
async def get_template_html(template_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return HTMLContentResponse(html_content=template.html_content or "")
|
||||
|
||||
|
||||
@router.put("/{template_id}/html")
|
||||
async def update_template_html(
|
||||
template_id: str,
|
||||
data: HTMLUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
template.html_content = bleach.clean(
|
||||
data.html_content, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True
|
||||
)
|
||||
|
||||
docx_bytes = html_to_docx_bytes(template.html_content)
|
||||
with open(template.file_path, "wb") as f:
|
||||
f.write(docx_bytes)
|
||||
|
||||
await db.flush()
|
||||
return {"detail": "保存成功"}
|
||||
|
||||
|
||||
@router.get("/{template_id}/download")
|
||||
async def download_template(
|
||||
template_id: str,
|
||||
format: str = Query("docx", pattern="^(docx|pdf)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
file_content = await get_file_content(template.file_path)
|
||||
|
||||
if format == "pdf":
|
||||
file_content = docx_to_pdf_bytes(file_content)
|
||||
media_type = "application/pdf"
|
||||
filename = f"{template.name}.pdf"
|
||||
else:
|
||||
media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
filename = f"{template.name}.docx"
|
||||
|
||||
return Response(
|
||||
content=file_content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def delete_template(template_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
delete_file(template.file_path)
|
||||
await db.delete(template)
|
||||
return {"detail": "删除成功"}
|
||||
@@ -0,0 +1,47 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "Doc Forge Reborn"
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
DEBUG: bool = False
|
||||
|
||||
POSTGRES_USER: str = "docforge"
|
||||
POSTGRES_PASSWORD: str = "docforge"
|
||||
POSTGRES_DB: str = "docforge"
|
||||
POSTGRES_HOST: str = "localhost"
|
||||
POSTGRES_PORT: int = 5432
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
DEFAULT_MODEL_ID: str = ""
|
||||
|
||||
STORAGE_ROOT: str = "./storage"
|
||||
MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024
|
||||
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
return (
|
||||
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
|
||||
@property
|
||||
def DATABASE_URL_SYNC(self) -> str:
|
||||
return (
|
||||
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "case_sensitive": True}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,25 @@
|
||||
import base64
|
||||
import hashlib
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def _derive_fernet_key(secret: str) -> bytes:
|
||||
digest = hashlib.sha256(secret.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
settings = get_settings()
|
||||
key = _derive_fernet_key(settings.SECRET_KEY)
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_api_key(api_key: str) -> str:
|
||||
f = _get_fernet()
|
||||
return f.encrypt(api_key.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_api_key(encrypted_key: str) -> str:
|
||||
f = _get_fernet()
|
||||
return f.decrypt(encrypted_key.encode("utf-8")).decode("utf-8")
|
||||
@@ -0,0 +1,32 @@
|
||||
from fastapi import Request, HTTPException
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class SecurityMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method in ("POST", "PUT", "PATCH"):
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and int(content_length) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"文件大小不能超过 {settings.MAX_UPLOAD_SIZE // 1024 // 1024}MB",
|
||||
)
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
ALLOWED_EXTENSIONS = {".docx", ".txt", ".pdf"}
|
||||
|
||||
|
||||
def validate_file_extension(filename: str) -> str:
|
||||
import os
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的文件类型: {ext},允许的类型: {', '.join(ALLOWED_EXTENSIONS)}",
|
||||
)
|
||||
return ext
|
||||
@@ -0,0 +1,29 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import get_settings
|
||||
from app.core.security_middleware import SecurityMiddleware
|
||||
from app.api.router import api_router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_middleware(SecurityMiddleware)
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "version": "0.1.0"}
|
||||
@@ -0,0 +1,17 @@
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
from app.models.ai_model import AIModel
|
||||
from app.models.template import Template
|
||||
from app.models.generation_point import GenerationPoint
|
||||
from app.models.generation_task import GenerationTask
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"TimestampMixin",
|
||||
"UUIDMixin",
|
||||
"AIModel",
|
||||
"Template",
|
||||
"GenerationPoint",
|
||||
"GenerationTask",
|
||||
"SystemConfig",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
import uuid
|
||||
from sqlalchemy import Boolean, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class AIModel(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "models"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
endpoint: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
api_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
extra_params: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,17 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from sqlalchemy import Integer, String, Text, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class GenerationPoint(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "generation_points"
|
||||
|
||||
template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("templates.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
position: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
model_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("models.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
ref_file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
need_ref_file: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
selected_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
template = relationship("Template")
|
||||
model = relationship("AIModel")
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
from app.models.base import UUIDMixin
|
||||
|
||||
|
||||
class GenerationTask(Base, UUIDMixin):
|
||||
__tablename__ = "generation_tasks"
|
||||
|
||||
template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("templates.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
result_file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
celery_task_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
template = relationship("Template")
|
||||
@@ -0,0 +1,11 @@
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "system_config"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
@@ -0,0 +1,14 @@
|
||||
import uuid
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Template(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "templates"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
html_content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,46 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
|
||||
|
||||
class AIModelCreate(BaseModel):
|
||||
name: str = Field(..., max_length=100)
|
||||
provider: str = Field(..., max_length=50)
|
||||
endpoint: str = Field(..., max_length=255)
|
||||
api_key: str
|
||||
extra_params: dict = Field(default_factory=dict)
|
||||
is_enabled: bool = True
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class AIModelUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=100)
|
||||
provider: str | None = Field(None, max_length=50)
|
||||
endpoint: str | None = Field(None, max_length=255)
|
||||
api_key: str | None = None
|
||||
extra_params: dict | None = None
|
||||
is_enabled: bool | None = None
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class AIModelToggle(BaseModel):
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class AIModelResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
provider: str
|
||||
endpoint: str
|
||||
api_key: str
|
||||
extra_params: dict
|
||||
is_enabled: bool
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_serializer("api_key")
|
||||
def mask_api_key(self, value: str) -> str:
|
||||
return "***"
|
||||
@@ -0,0 +1,45 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GenerationPointCreate(BaseModel):
|
||||
template_id: uuid.UUID
|
||||
position: dict = Field(..., description="选区位置信息")
|
||||
prompt: str
|
||||
model_id: uuid.UUID | None = None
|
||||
order: int = 0
|
||||
selected_text: str | None = None
|
||||
need_ref_file: bool = False
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class GenerationPointUpdate(BaseModel):
|
||||
position: dict | None = None
|
||||
prompt: str | None = None
|
||||
model_id: uuid.UUID | None = None
|
||||
order: int | None = None
|
||||
selected_text: str | None = None
|
||||
need_ref_file: bool | None = None
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class GenerationPointResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
template_id: uuid.UUID
|
||||
position: dict
|
||||
prompt: str
|
||||
model_id: uuid.UUID | None = None
|
||||
ref_file_path: str | None = None
|
||||
need_ref_file: bool = False
|
||||
remark: str | None = None
|
||||
order: int
|
||||
selected_text: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BatchOrderUpdate(BaseModel):
|
||||
points: list[dict] = Field(..., description="[{id: uuid, order: int}, ...]")
|
||||
@@ -0,0 +1,44 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
template_id: uuid.UUID
|
||||
status: str
|
||||
result_file_path: str | None = None
|
||||
error_msg: str | None = None
|
||||
created_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TaskDetailResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
template_id: uuid.UUID
|
||||
template_name: str = ""
|
||||
status: str
|
||||
result_file_path: str | None = None
|
||||
error_msg: str | None = None
|
||||
created_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
points: list[dict] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
task_id: uuid.UUID
|
||||
status: str
|
||||
|
||||
|
||||
class SingleTestRequest(BaseModel):
|
||||
prompt: str
|
||||
model_id: uuid.UUID | None = None
|
||||
ref_file_path: str | None = None
|
||||
|
||||
|
||||
class SingleTestResponse(BaseModel):
|
||||
result: str
|
||||
@@ -0,0 +1,31 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
file_path: str
|
||||
html_content: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TemplateListItem(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class HTMLContentResponse(BaseModel):
|
||||
html_content: str
|
||||
|
||||
|
||||
class HTMLUpdateRequest(BaseModel):
|
||||
html_content: str
|
||||
@@ -0,0 +1,181 @@
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
import httpx
|
||||
from app.core.security import decrypt_api_key
|
||||
|
||||
PROVIDER_OPENAI = "openai"
|
||||
PROVIDER_AZURE = "azure"
|
||||
PROVIDER_CUSTOM = "custom"
|
||||
|
||||
|
||||
class AIAdapter(ABC):
|
||||
@abstractmethod
|
||||
def build_request(self, model_config: dict, prompt: str, ref_content: str | None = None) -> dict:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_response(self, response_data: dict) -> str:
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def provider(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class OpenAIAdapter(AIAdapter):
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return PROVIDER_OPENAI
|
||||
|
||||
def build_request(self, model_config: dict, prompt: str, ref_content: str | None = None) -> dict:
|
||||
extra = model_config.get("extra_params", {})
|
||||
temperature = extra.get("temperature", 0.7)
|
||||
max_tokens = extra.get("max_tokens", 2000)
|
||||
|
||||
messages = [{"role": "system", "content": "你是一个专业的文档内容生成助手。"}]
|
||||
user_content = prompt
|
||||
if ref_content:
|
||||
user_content = f"参考以下内容:\n{ref_content}\n\n任务:{prompt}"
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
return {
|
||||
"url": model_config["endpoint"],
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {decrypt_api_key(model_config['api_key'])}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"json": {
|
||||
"model": extra.get("model", "gpt-4"),
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
},
|
||||
}
|
||||
|
||||
def parse_response(self, response_data: dict) -> str:
|
||||
return response_data["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
class AzureAdapter(AIAdapter):
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return PROVIDER_AZURE
|
||||
|
||||
def build_request(self, model_config: dict, prompt: str, ref_content: str | None = None) -> dict:
|
||||
extra = model_config.get("extra_params", {})
|
||||
temperature = extra.get("temperature", 0.7)
|
||||
max_tokens = extra.get("max_tokens", 2000)
|
||||
|
||||
messages = [{"role": "system", "content": "你是一个专业的文档内容生成助手。"}]
|
||||
user_content = prompt
|
||||
if ref_content:
|
||||
user_content = f"参考以下内容:\n{ref_content}\n\n任务:{prompt}"
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
api_version = extra.get("api_version", "2024-02-15-preview")
|
||||
endpoint = model_config["endpoint"]
|
||||
url = f"{endpoint}?api-version={api_version}"
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"headers": {
|
||||
"api-key": decrypt_api_key(model_config["api_key"]),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"json": {
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
},
|
||||
}
|
||||
|
||||
def parse_response(self, response_data: dict) -> str:
|
||||
return response_data["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
class CustomAdapter(AIAdapter):
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return PROVIDER_CUSTOM
|
||||
|
||||
def build_request(self, model_config: dict, prompt: str, ref_content: str | None = None) -> dict:
|
||||
extra = model_config.get("extra_params", {})
|
||||
user_content = prompt
|
||||
if ref_content:
|
||||
user_content = f"参考以下内容:\n{ref_content}\n\n任务:{prompt}"
|
||||
|
||||
messages = [{"role": "system", "content": "你是一个专业的文档内容生成助手。"}]
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
body = {
|
||||
"model": extra.get("model", "gpt-3.5-turbo"),
|
||||
"messages": messages,
|
||||
"max_tokens": extra.get("max_tokens", 2000),
|
||||
"temperature": extra.get("temperature", 0.7),
|
||||
}
|
||||
body.update({k: v for k, v in extra.items() if k not in ("model", "messages", "max_tokens", "temperature")})
|
||||
|
||||
return {
|
||||
"url": model_config["endpoint"],
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {decrypt_api_key(model_config['api_key'])}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"json": body,
|
||||
}
|
||||
|
||||
def parse_response(self, response_data: dict) -> str:
|
||||
if "choices" in response_data:
|
||||
return response_data["choices"][0]["message"]["content"]
|
||||
if "response" in response_data:
|
||||
return response_data["response"]
|
||||
if "content" in response_data:
|
||||
return response_data["content"]
|
||||
if "text" in response_data:
|
||||
return response_data["text"]
|
||||
return json.dumps(response_data)
|
||||
|
||||
|
||||
_adapters: dict[str, AIAdapter] = {
|
||||
PROVIDER_OPENAI: OpenAIAdapter(),
|
||||
PROVIDER_AZURE: AzureAdapter(),
|
||||
PROVIDER_CUSTOM: CustomAdapter(),
|
||||
}
|
||||
|
||||
|
||||
def get_adapter(provider: str) -> AIAdapter:
|
||||
adapter = _adapters.get(provider)
|
||||
if not adapter:
|
||||
raise ValueError(f"不支持的供应商: {provider}")
|
||||
return adapter
|
||||
|
||||
|
||||
async def call_ai_model(model_config: dict, prompt: str, ref_content: str | None = None) -> str:
|
||||
adapter = get_adapter(model_config["provider"])
|
||||
request = adapter.build_request(model_config, prompt, ref_content)
|
||||
|
||||
timeout = model_config.get("extra_params", {}).get("timeout", 120)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout,
|
||||
proxy=None,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = await client.post(
|
||||
request["url"],
|
||||
headers=request["headers"],
|
||||
json=request["json"],
|
||||
)
|
||||
response.raise_for_status()
|
||||
return adapter.parse_response(response.json())
|
||||
except httpx.HTTPStatusError as e:
|
||||
detail = e.response.text[:500] if e.response else str(e)
|
||||
raise RuntimeError(f"AI 服务返回错误 ({e.response.status_code}): {detail}")
|
||||
except httpx.TimeoutException:
|
||||
raise RuntimeError("AI 调用超时")
|
||||
except httpx.ConnectError as e:
|
||||
raise RuntimeError(f"无法连接 AI 服务: {e}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"AI 调用异常: {str(e)}")
|
||||
@@ -0,0 +1,168 @@
|
||||
import mammoth
|
||||
from io import BytesIO
|
||||
from bs4 import BeautifulSoup
|
||||
from docx import Document
|
||||
from docx.shared import Pt, Inches
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from app.services.file_storage import get_file_content
|
||||
|
||||
ALIGN_MAP = {
|
||||
WD_ALIGN_PARAGRAPH.CENTER: "center",
|
||||
WD_ALIGN_PARAGRAPH.RIGHT: "right",
|
||||
WD_ALIGN_PARAGRAPH.JUSTIFY: "justify",
|
||||
}
|
||||
|
||||
|
||||
async def docx_to_html(file_content: bytes) -> str:
|
||||
result = mammoth.convert_to_html(BytesIO(file_content))
|
||||
html = result.value
|
||||
|
||||
try:
|
||||
doc = Document(BytesIO(file_content))
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
paragraphs = doc.paragraphs
|
||||
html_blocks = soup.find_all(["p", "h1", "h2", "h3", "h4", "h5", "h6", "li"])
|
||||
|
||||
for i, para in enumerate(paragraphs):
|
||||
if i >= len(html_blocks):
|
||||
break
|
||||
if para.alignment and para.alignment in ALIGN_MAP:
|
||||
css = ALIGN_MAP[para.alignment]
|
||||
existing = html_blocks[i].get("style", "")
|
||||
styles = f"text-align:{css}"
|
||||
if existing:
|
||||
styles = existing.rstrip(";") + ";" + styles
|
||||
html_blocks[i]["style"] = styles
|
||||
|
||||
html = str(soup)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def html_to_docx_bytes(html_content: str) -> bytes:
|
||||
from html.parser import HTMLParser
|
||||
|
||||
doc = Document()
|
||||
|
||||
style = doc.styles["Normal"]
|
||||
font = style.font
|
||||
font.name = "Arial"
|
||||
font.size = Pt(11)
|
||||
|
||||
class RichHTMLParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.paragraphs: list[dict] = []
|
||||
self.current = {"runs": [], "align": None}
|
||||
self.in_paragraph = False
|
||||
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
|
||||
self.tag_stack: list[str] = []
|
||||
self.heading_level = 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
tag_lower = tag.lower()
|
||||
attrs_dict = dict(attrs)
|
||||
if tag_lower in ("p", "div", "li"):
|
||||
self.in_paragraph = True
|
||||
self.current = {"runs": [], "align": None}
|
||||
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
|
||||
style = attrs_dict.get("style", "")
|
||||
if "text-align:center" in style:
|
||||
self.current["align"] = "center"
|
||||
elif "text-align:right" in style:
|
||||
self.current["align"] = "right"
|
||||
elif "text-align:justify" in style:
|
||||
self.current["align"] = "justify"
|
||||
elif tag_lower in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
self.in_paragraph = True
|
||||
self.heading_level = int(tag_lower[1])
|
||||
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
|
||||
elif tag_lower in ("strong", "b"):
|
||||
self.current_run["bold"] = True
|
||||
elif tag_lower in ("em", "i"):
|
||||
self.current_run["italic"] = True
|
||||
elif tag_lower == "u":
|
||||
self.current_run["underline"] = True
|
||||
elif tag_lower in ("br",):
|
||||
if self.in_paragraph:
|
||||
self.current["runs"].append(dict(self.current_run))
|
||||
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
|
||||
self.tag_stack.append(tag_lower)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
tag_lower = tag.lower()
|
||||
if tag_lower in ("p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
if self.current_run["text"].strip():
|
||||
self.current["runs"].append(dict(self.current_run))
|
||||
if self.current["runs"]:
|
||||
p = dict(self.current)
|
||||
p["heading"] = self.heading_level
|
||||
self.paragraphs.append(p)
|
||||
self.current = {"runs": []}
|
||||
self.current_run = {"text": "", "bold": False, "italic": False, "underline": False}
|
||||
self.in_paragraph = False
|
||||
self.heading_level = 0
|
||||
if self.tag_stack:
|
||||
self.tag_stack.pop()
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.in_paragraph:
|
||||
self.current_run["text"] += data
|
||||
|
||||
parser = RichHTMLParser()
|
||||
parser.feed(html_content)
|
||||
|
||||
for para_data in parser.paragraphs:
|
||||
heading = para_data.get("heading", 0)
|
||||
if heading > 0:
|
||||
p = doc.add_heading(level=min(heading, 9))
|
||||
else:
|
||||
p = doc.add_paragraph()
|
||||
|
||||
align_val = para_data.get("align")
|
||||
if align_val == "center":
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
elif align_val == "right":
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
elif align_val == "justify":
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
|
||||
for run_data in para_data.get("runs", []):
|
||||
run = p.add_run(run_data["text"])
|
||||
if run_data.get("bold"):
|
||||
run.bold = True
|
||||
if run_data.get("italic"):
|
||||
run.italic = True
|
||||
if run_data.get("underline"):
|
||||
run.underline = True
|
||||
|
||||
output = BytesIO()
|
||||
doc.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def docx_to_pdf_bytes(file_content: bytes) -> bytes:
|
||||
doc = Document(BytesIO(file_content))
|
||||
|
||||
from io import BytesIO as Bio
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
|
||||
buffer = Bio()
|
||||
pdf_doc = SimpleDocTemplate(buffer, pagesize=A4)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
story = []
|
||||
|
||||
for para in doc.paragraphs:
|
||||
if para.text.strip():
|
||||
p = Paragraph(para.text, styles["Normal"])
|
||||
story.append(p)
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
pdf_doc.build(story)
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
from fastapi import UploadFile
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
TEMPLATES_DIR = "templates"
|
||||
REF_FILES_DIR = "ref_files"
|
||||
RESULTS_DIR = "results"
|
||||
|
||||
|
||||
def _ensure_dir(path: str) -> str:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_storage_dir(subdir: str) -> str:
|
||||
return _ensure_dir(os.path.join(settings.STORAGE_ROOT, subdir))
|
||||
|
||||
|
||||
async def save_upload(upload_file: UploadFile, subdir: str) -> str:
|
||||
dir_path = get_storage_dir(subdir)
|
||||
file_path = os.path.join(dir_path, upload_file.filename or "unnamed")
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
content = await upload_file.read()
|
||||
await f.write(content)
|
||||
return file_path
|
||||
|
||||
|
||||
async def get_file_content(file_path: str) -> bytes:
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
return await f.read()
|
||||
|
||||
|
||||
def delete_file(file_path: str) -> None:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
|
||||
def get_absolute_path(rel_path: str) -> str:
|
||||
return os.path.abspath(rel_path)
|
||||
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
import json
|
||||
from docx import Document
|
||||
from PyPDF2 import PdfReader
|
||||
from app.services.file_storage import get_file_content
|
||||
|
||||
|
||||
async def parse_reference_file(file_path: str) -> str:
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
content = await get_file_content(file_path)
|
||||
|
||||
if ext == ".txt":
|
||||
return content.decode("utf-8", errors="ignore")
|
||||
if ext == ".docx":
|
||||
from io import BytesIO
|
||||
doc = Document(BytesIO(content))
|
||||
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
||||
if ext == ".pdf":
|
||||
from io import BytesIO
|
||||
reader = PdfReader(BytesIO(content))
|
||||
return "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||
|
||||
raise ValueError(f"不支持的文件格式: {ext}")
|
||||
|
||||
|
||||
async def parse_reference_files(ref_file_path: str | None) -> str:
|
||||
if not ref_file_path:
|
||||
return ""
|
||||
|
||||
try:
|
||||
paths = json.loads(ref_file_path)
|
||||
if isinstance(paths, list):
|
||||
texts = []
|
||||
for path in paths:
|
||||
try:
|
||||
texts.append(await parse_reference_file(path))
|
||||
except Exception:
|
||||
texts.append(f"[无法解析文件: {path}]")
|
||||
return "\n\n".join(texts)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return await parse_reference_file(ref_file_path)
|
||||
except Exception:
|
||||
return f"[无法解析文件: {ref_file_path}]"
|
||||
@@ -0,0 +1,25 @@
|
||||
from celery import Celery
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
celery_app = Celery(
|
||||
"doc_forge_reds",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND,
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="Asia/Shanghai",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_acks_late=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
task_soft_time_limit=600,
|
||||
task_time_limit=900,
|
||||
)
|
||||
|
||||
celery_app.autodiscover_tasks(["app.tasks.generate"])
|
||||
@@ -0,0 +1,225 @@
|
||||
import asyncio
|
||||
import os
|
||||
from io import BytesIO
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from docx import Document
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.core.config import get_settings
|
||||
from app.models.generation_task import GenerationTask
|
||||
from app.models.generation_point import GenerationPoint
|
||||
from app.models.template import Template
|
||||
from app.services.ai_adapter import call_ai_model
|
||||
from app.services.ref_parser import parse_reference_files
|
||||
from app.services.file_storage import get_storage_dir, get_file_content, RESULTS_DIR
|
||||
|
||||
|
||||
def _create_db_session() -> async_sessionmaker[AsyncSession]:
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
def _strip_html(html: str) -> str:
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class Stripper(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.text = ""
|
||||
def handle_data(self, data):
|
||||
self.text += data
|
||||
|
||||
s = Stripper()
|
||||
s.feed(html)
|
||||
return s.text
|
||||
|
||||
|
||||
def _get_selected_text(html_content: str, position: dict) -> str:
|
||||
start = position.get("start", 0)
|
||||
end = position.get("end", 0)
|
||||
|
||||
plain_text = _strip_html(html_content)
|
||||
if 0 <= start < end <= len(plain_text):
|
||||
return plain_text[start:end].strip()
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _replace_text_in_docx(doc: Document, old_text: str, new_text: str) -> bool:
|
||||
if not old_text:
|
||||
return False
|
||||
|
||||
for paragraph in doc.paragraphs:
|
||||
if old_text in paragraph.text:
|
||||
inline = paragraph.runs
|
||||
for run in inline:
|
||||
if old_text in run.text:
|
||||
run.text = run.text.replace(old_text, new_text)
|
||||
return True
|
||||
|
||||
full_text = "".join(r.text for r in inline)
|
||||
if old_text in full_text:
|
||||
remaining = old_text
|
||||
for run in inline:
|
||||
if not remaining:
|
||||
break
|
||||
if remaining.startswith(run.text):
|
||||
remaining = remaining[len(run.text):]
|
||||
elif run.text in remaining:
|
||||
idx = remaining.find(run.text)
|
||||
if idx >= 0:
|
||||
remaining = remaining[:idx] + remaining[idx + len(run.text):]
|
||||
if remaining.startswith(run.text):
|
||||
remaining = remaining[len(run.text):]
|
||||
|
||||
if not remaining:
|
||||
chunk_parts = new_text
|
||||
for run in inline:
|
||||
if chunk_parts:
|
||||
chunk_parts = chunk_parts[len(run.text):]
|
||||
|
||||
first_run = inline[0]
|
||||
first_run.text = new_text
|
||||
for run in inline[1:]:
|
||||
run.text = ""
|
||||
return True
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for paragraph in cell.paragraphs:
|
||||
if old_text in paragraph.text:
|
||||
for run in paragraph.runs:
|
||||
if old_text in run.text:
|
||||
run.text = run.text.replace(old_text, new_text)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _generate_document(task_id: str) -> None:
|
||||
session_factory = _create_db_session()
|
||||
|
||||
async with session_factory() as db:
|
||||
task_result = await db.execute(select(GenerationTask).where(GenerationTask.id == task_id))
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
return
|
||||
|
||||
task.status = "processing"
|
||||
await db.commit()
|
||||
|
||||
template_result = await db.execute(select(Template).where(Template.id == task.template_id))
|
||||
template = template_result.scalar_one_or_none()
|
||||
if not template:
|
||||
task.status = "failed"
|
||||
task.error_msg = "模板不存在"
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
if not os.path.exists(template.file_path):
|
||||
task.status = "failed"
|
||||
task.error_msg = f"原始文件不存在: {template.file_path}"
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
docx_content = await get_file_content(template.file_path)
|
||||
doc = Document(BytesIO(docx_content))
|
||||
|
||||
html_content = template.html_content or ""
|
||||
|
||||
points_result = await db.execute(
|
||||
select(GenerationPoint)
|
||||
.where(GenerationPoint.template_id == task.template_id)
|
||||
.order_by(GenerationPoint.order.asc())
|
||||
)
|
||||
points = points_result.scalars().all()
|
||||
|
||||
try:
|
||||
# 先收集所有 AI 调用参数
|
||||
point_data = []
|
||||
for point in points:
|
||||
selected_text = point.selected_text or _get_selected_text(html_content, point.position)
|
||||
if not selected_text:
|
||||
continue
|
||||
|
||||
ref_content = ""
|
||||
if point.ref_file_path:
|
||||
ref_content = await parse_reference_files(point.ref_file_path)
|
||||
|
||||
model_config = {"provider": "custom", "endpoint": "", "api_key": "", "extra_params": {}}
|
||||
if point.model_id:
|
||||
from app.models.ai_model import AIModel
|
||||
model_result = await db.execute(select(AIModel).where(AIModel.id == point.model_id))
|
||||
model = model_result.scalar_one_or_none()
|
||||
if model:
|
||||
model_config = {
|
||||
"provider": model.provider,
|
||||
"endpoint": model.endpoint,
|
||||
"api_key": model.api_key,
|
||||
"extra_params": model.extra_params,
|
||||
}
|
||||
|
||||
point_data.append({
|
||||
"point": point,
|
||||
"selected_text": selected_text,
|
||||
"model_config": model_config,
|
||||
"ref_content": ref_content,
|
||||
})
|
||||
|
||||
# 并发调用所有 AI 模型
|
||||
async def _call_one(pd):
|
||||
try:
|
||||
return await call_ai_model(pd["model_config"], pd["point"].prompt, pd["ref_content"])
|
||||
except Exception as e:
|
||||
return f"[生成失败: {e}]"
|
||||
|
||||
coros = [_call_one(pd) for pd in point_data]
|
||||
ai_results = await asyncio.gather(*coros)
|
||||
|
||||
# 按顺序替换文本
|
||||
for pd, ai_result in zip(point_data, ai_results):
|
||||
_replace_text_in_docx(doc, pd["selected_text"], str(ai_result))
|
||||
|
||||
await db.commit()
|
||||
|
||||
result_dir = get_storage_dir(RESULTS_DIR)
|
||||
result_path = os.path.join(result_dir, f"{task_id}.docx")
|
||||
doc.save(result_path)
|
||||
|
||||
task.status = "done"
|
||||
task.result_file_path = result_path
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
task.status = "failed"
|
||||
task.error_msg = str(e)
|
||||
task.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
await session_factory.engine.dispose()
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="generate_document")
|
||||
def generate_document(self, task_id: str) -> dict:
|
||||
import asyncio
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(_generate_document(task_id))
|
||||
return {"status": "done", "task_id": task_id}
|
||||
except Exception as e:
|
||||
return {"status": "failed", "task_id": task_id, "error": str(e)}
|
||||
finally:
|
||||
loop.close()
|
||||
@@ -0,0 +1,16 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
sqlalchemy[asyncio]==2.0.30
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
pydantic-settings==2.3.4
|
||||
cryptography==42.0.8
|
||||
python-multipart==0.0.9
|
||||
python-docx==1.1.2
|
||||
mammoth==1.7.2
|
||||
PyPDF2==3.0.1
|
||||
httpx==0.27.0
|
||||
celery[redis]==5.4.0
|
||||
redis==5.0.7
|
||||
aiofiles==24.1.0
|
||||
reportlab==4.2.2
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo ">>> 启动 Doc Forge Reborn <<<"
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "错误: 需要安装 Docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "请先复制 .env.example 为 .env 并填写必要配置"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "1. 构建镜像..."
|
||||
docker compose build
|
||||
|
||||
echo "2. 启动服务..."
|
||||
docker compose up -d
|
||||
|
||||
echo "3. 等待数据库就绪..."
|
||||
sleep 5
|
||||
|
||||
echo "4. 执行数据库迁移..."
|
||||
docker compose exec backend alembic upgrade head
|
||||
|
||||
echo ""
|
||||
echo ">>> 启动完成 <<<"
|
||||
echo "前端地址: http://localhost:3000"
|
||||
echo "后端文档: http://localhost:8000/docs"
|
||||
echo ""
|
||||
echo "查看日志: docker compose logs -f"
|
||||
echo "停止服务: docker compose down"
|
||||
@@ -0,0 +1,101 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: docforge-postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-docforge}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-docforge}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-docforge}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-docforge}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: docforge-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: docforge-backend
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-docforge}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-docforge}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-docforge}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND: redis://redis:6379/1
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
STORAGE_ROOT: /app/storage
|
||||
CORS_ORIGINS: '["http://localhost:3000","http://localhost:80","http://localhost"]'
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- storage_data:/app/storage
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
celery-worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.celery
|
||||
container_name: docforge-celery
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-docforge}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-docforge}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-docforge}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND: redis://redis:6379/1
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
STORAGE_ROOT: /app/storage
|
||||
volumes:
|
||||
- storage_data:/app/storage
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
container_name: docforge-nginx
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
storage_data:
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# API 设计文档
|
||||
所有接口前缀为 `/api/v1`,返回 JSON 格式数据。认证暂未启用(单用户环境)。
|
||||
## 1. AI 模型管理 (`/models`)
|
||||
### 1.1 创建模型
|
||||
`POST /models`
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"name": "GPT-4",
|
||||
"provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1/chat/completions",
|
||||
"api_key": "sk-...",
|
||||
"extra_params": {"max_tokens": 1000, "temperature": 0.7},
|
||||
"is_enabled": true,
|
||||
"remark": "主模型"
|
||||
}
|
||||
```
|
||||
**响应**:返回创建的模型对象(API Key 已加密)。
|
||||
### 1.2 获取模型列表
|
||||
`GET /models?enabled=true&page=1&limit=20`
|
||||
**响应**:分页列表。
|
||||
### 1.3 获取模型详情
|
||||
`GET /models/{id}`
|
||||
### 1.4 更新模型
|
||||
`PUT /models/{id}`(字段同创建)
|
||||
### 1.5 删除模型
|
||||
`DELETE /models/{id}`
|
||||
### 1.6 启用/禁用
|
||||
`PATCH /models/{id}/toggle`
|
||||
请求体`{"is_enabled": false}`
|
||||
## 2. 模板管理 (`/templates`)
|
||||
### 2.1 上传模板
|
||||
`POST /templates` (multipart/form-data)
|
||||
字段`file` (.docx), `name` (可选)
|
||||
**响应**:模板对象(含生成的 HTML 内容)。
|
||||
### 2.2 获取模板列表
|
||||
`GET /templates` (分页)
|
||||
### 2.3 获取模板 HTML 内容
|
||||
`GET /templates/{id}/html`
|
||||
返回`{"html_content": "<html>..."}`
|
||||
### 2.4 更新模板 HTML(编辑后保存)
|
||||
`PUT /templates/{id}/html`
|
||||
请求体`{"html_content": "<html>..."}`
|
||||
后端将同步更新对应的 .docx 文件。
|
||||
### 2.5 下载最终文档
|
||||
`GET /templates/{id}/download`
|
||||
返回文件流(.docx)。
|
||||
## 3. 生成点管理 (`/generation-points`)
|
||||
### 3.1 创建生成点
|
||||
`POST /generation-points`
|
||||
```json
|
||||
{
|
||||
"template_id": "uuid",
|
||||
"position": {"start": 100, "end": 200},
|
||||
"prompt": "请根据参考文件生成一段总结",
|
||||
"model_id": "uuid (可选)",
|
||||
"ref_file": "file (multipart, 可选)"
|
||||
}
|
||||
```
|
||||
**响应**:生成点对象。
|
||||
### 3.2 获取模板的所有生成点
|
||||
`GET /generation-points?template_id={id}`
|
||||
### 3.3 更新生成点
|
||||
`PUT /generation-points/{id}`(字段同创建)
|
||||
### 3.4 删除生成点
|
||||
`DELETE /generation-points/{id}`
|
||||
## 4. 生成任务 (`/tasks`)
|
||||
### 4.1 触发生成任务
|
||||
`POST /templates/{template_id}/generate`
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"task_id": "uuid",
|
||||
"status": "pending"
|
||||
}
|
||||
```
|
||||
### 4.2 查询任务状态
|
||||
`GET /tasks/{task_id}`
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"status": "done",
|
||||
"result_file_path": "/path/to/result.docx",
|
||||
"error_msg": null,
|
||||
"created_at": "...",
|
||||
"finished_at": "..."
|
||||
}
|
||||
```
|
||||
### 4.3 下载生成结果
|
||||
`GET /tasks/{task_id}/download`
|
||||
返回 .docx 文件流。
|
||||
## 错误码规范
|
||||
- `200`: 成功
|
||||
- `400`: 请求参数错误
|
||||
- `404`: 资源不存在
|
||||
- `500`: 服务器内部错误
|
||||
错误响应格式:
|
||||
```json
|
||||
{
|
||||
"detail": "错误描述"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# 任务执行摘要
|
||||
|
||||
## 会话 ID: 1
|
||||
- [2026-07-06 16:00]
|
||||
- **执行原因**: 用户要求按任务拆解清单逐步实施项目开发,先完成阶段一和阶段二
|
||||
- **执行过程**:
|
||||
1. 创建项目目录结构(backend/, web/, storage/)。
|
||||
2. 编写 docker-compose.yml(Postgres 18.3 + Redis 7)、.env.example、requirements.txt。
|
||||
3. 实现 backend/app/core/ 核心模块:config(pydantic-settings)、database(SQLAlchemy async engine + session)、security(Fernet 加密)。
|
||||
4. 创建 5 个 ORM 模型:AIModel、Template、GenerationPoint、GenerationTask、SystemConfig + Base/TimestampMixin/UUIDMixin。
|
||||
5. 配置 Alembic(alembic.ini, env.py, script.py.mako)。
|
||||
6. 编写 FastAPI 入口 main.py(CORS + health 端点)。
|
||||
7. 实现文件存储服务 file_storage.py(上传/下载/删除)。
|
||||
8. 初始化前端项目(Vite + React 18 + TypeScript + Ant Design + React Router),侧边栏布局 + 5 个占位页面。
|
||||
9. 编写 4 组 Pydantic Schemas(ai_model, template, generation_point, generation_task)。
|
||||
10. 实现文档处理服务 document_processor.py(Mammoth docx→HTML、python-docx HTML→docx、reportlab docx→PDF)。
|
||||
11. 实现 AI 模型管理 API(CRUD + 启用/禁用切换,6 个端点)。
|
||||
12. 实现模板管理 API(上传/获取HTML/更新HTML/下载docx+pdf,6 个端点)。
|
||||
13. 实现生成点管理 API(CRUD + 参考文件上传 + 批量排序更新,5 个端点)。
|
||||
14. 注册路由(api/router.py),更新 main.py,启动验证通过(/health 返回 OK)。
|
||||
- **执行结果**:
|
||||
- 阶段一(基础设施):45 个文件,后端 5 张表模型导入正常,前端 TypeScript 编译零错误。
|
||||
- 阶段二(核心业务 API):17 个 API 端点全部注册,后端启动正常,`/health` 返回 200 OK。
|
||||
- Git 提交:2 次提交(b5fa343, aa84521)。
|
||||
|
||||
## 会话 ID: 2
|
||||
- [2026-07-06 16:15]
|
||||
- **执行原因**: 完成阶段三(异步任务与 AI 集成)和阶段四(前端开发)
|
||||
- **执行过程**:
|
||||
1. 实现 AI 调用适配器 `ai_adapter.py`:工厂模式支持 openai/azure/custom 三种供应商,httpx 异步调用。
|
||||
2. 实现参考文件解析器 `ref_parser.py`:提取 txt/docx/pdf 文本内容。
|
||||
3. 编写 Celery 配置 `celery_app.py` + 异步生成任务 `generate.py`(模板加载→生成点排序→参考解析→AI调用→HTML插入→文档保存→状态更新)。
|
||||
4. 实现任务管理 API:触发生成/查询状态/下载结果(docx+pdf)/取消任务/单点测试。
|
||||
5. 注册全部 23 个 API 端点,后端启动验证通过。
|
||||
6. 前端 API 层:axios 封装 + 4 组 API 调用(models/templates/generationPoints/tasks)。
|
||||
7. TypeScript 类型定义(AIModel/Template/GenerationPoint/GenerationTask)。
|
||||
8. 模型管理页面:表格 + 新增/编辑弹窗 + 启用切换 + 删除。
|
||||
9. 模板列表页面:表格 + docx 上传 + 在线编辑/下载/删除。
|
||||
10. 模板编辑器(核心):TinyMCE 自托管 + 选区监听浮动标注 + 生成点弹窗 + 右侧面板(测试/编辑/删除)+ 触发生成任务 + 进度轮询 + 结果下载。
|
||||
11. 任务历史页面:状态标签 + 下载 + 取消。
|
||||
12. 系统设置页面:默认模型/并发/超时配置。
|
||||
13. Vite 配置 API 代理(3000→8000) + TinyMCE 自托管。
|
||||
14. 修复 docker-compose(postgres: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 个 Dockerfile:backend (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. 添加 .dockerignore(backend + 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 进行端到端测试。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 任务拆解清单(详细版)
|
||||
|
||||
本文档将整个开发过程拆解为可执行的工作包,包含优先级、工时估算、依赖关系和里程碑。所有任务均覆盖核心功能、测试、部署及新增需求(单点测试、拖拽排序、独立模型选择、PDF导出)。
|
||||
|
||||
## 优先级说明
|
||||
- **P0**:核心功能,必须完成才能可用。
|
||||
- **P1**:重要功能,提升体验。
|
||||
- **P2**:优化和增强。
|
||||
|
||||
---
|
||||
|
||||
## 阶段一:基础设施与核心服务 (第1~2周)
|
||||
|
||||
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|
||||
|----|------|--------|---------|--------|------|--------|
|
||||
| 1.1 | 需求分析与架构评审 | - 编写详细需求文档(含用户故事)<br>- 数据库模型设计评审(ER图、字段说明)<br>- 技术选型最终确认(含PDF导出库调研) | 24 | P0 | - | 架构基线完成 |
|
||||
| 1.2 | 开发环境搭建 | - 配置后端虚拟环境,安装 FastAPI、SQLAlchemy、Celery、PyPDF2等<br>- 配置前端项目(Vite + React + TS + Ant Design)<br>- Docker Compose 定义基础服务(Postgres、Redis) | 16 | P0 | 1.1 | 可运行空框架 |
|
||||
| 1.3 | 数据库与 ORM | - 使用 Alembic 创建初始迁移,建表(models, templates, generation_points, tasks, system_config)<br>- 编写 SQLAlchemy 模型(含`order`字段用于生成点排序)<br>- 实现数据库会话依赖注入 | 24 | P0 | 1.2 | 数据库就绪 |
|
||||
| 1.4 | 文件存储服务 | - 实现文件上传、下载、删除工具类<br>- 配置存储根目录和路径生成规则(区分模板、参考文件、结果) | 16 | P0 | 1.3 | 文件操作可用 |
|
||||
|
||||
## 阶段二:核心业务逻辑 (第3~5周)
|
||||
|
||||
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|
||||
|----|------|--------|---------|--------|------|--------|
|
||||
| 2.1 | 文档处理服务(含PDF导出) | - 集成 Aspose.Words (或 Mammoth + python-docx) 实现 docx ↔ HTML 转换<br>- 实现 `docx_to_html` 和 `html_to_docx` 函数<br>- 实现 `docx_to_pdf` 函数(使用 python-docx + reportlab 或 Aspose)<br>- 单元测试转换效果(包含表格、图片、样式) | 48 | P0 | 1.4 | 文档转换与导出达标 |
|
||||
| 2.2 | AI 模型管理 API | - 实现 CRUD 接口<br>- API Key 加密存储(Fernet)<br>- 启用/禁用切换<br>- 列表查询过滤(启用/全部) | 24 | P0 | 1.3 | 模型管理功能完备 |
|
||||
| 2.3 | 模板管理 API | - 上传接口(接收文件,存储并转换 HTML)<br>- 获取 HTML 内容(含模板基本信息)<br>- 更新 HTML(并同步转回 docx)<br>- 下载接口(支持 docx 和 pdf 格式参数) | 32 | P0 | 2.1, 1.4 | 模板 CRUD 完成 |
|
||||
| 2.4 | 生成点管理 API | - 创建、列表、更新、删除生成点<br>- 关联模板和模型校验<br>- 参考文件上传处理<br>- 支持 `order` 字段(用于排序) | 24 | P0 | 2.2, 2.3 | 生成点可标注 |
|
||||
| 2.5 | 生成点顺序管理 | - 提供批量更新接口(接收生成点ID列表顺序)<br>- 前端拖拽排序时调用此接口 | 8 | P1 | 2.4 | 排序功能可用 |
|
||||
|
||||
## 阶段三:异步任务与 AI 集成 (第6~7周)
|
||||
|
||||
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|
||||
|----|------|--------|---------|--------|------|--------|
|
||||
| 3.1 | AI 调用适配器 | - 设计工厂模式,支持 openai, azure, custom<br>- 实现各供应商的请求构建和响应解析<br>- 编写异步调用函数(httpx)<br>- 支持超时、重试配置 | 32 | P0 | 2.2 | 可成功调用不同模型 |
|
||||
| 3.2 | 参考文件解析器 | - 实现提取 .txt, .docx, .pdf 文本内容的功能<br>- 使用 python-docx, PyPDF2 等库 | 16 | P0 | 1.4 | 提取文本成功 |
|
||||
| 3.3 | Celery 任务定义 | - **3.3.1** Celery 配置与连接(4h)<br>- **3.3.2** 任务函数骨架(加载模板、获取生成点,按 order 排序)(8h)<br>- **3.3.3** 参考文件解析集成(4h)<br>- **3.3.4** AI 调用与结果插入(基于偏移量或 XPath)(12h)<br>- **3.3.5** 文档保存(docx)与状态更新(8h)<br>- **3.3.6** 异常处理与重试逻辑(12h)<br>**合计** | 48 | P0 | 3.1, 3.2, 2.3, 2.4, 2.5 | 可端到端生成文档 |
|
||||
| 3.4 | 任务管理 API | - 触发生成任务接口(创建 task 记录,启动 Celery)<br>- 查询任务状态(含进度百分比)<br>- 下载结果接口(支持 docx 和 pdf)<br>- 取消任务接口(可选) | 20 | P0 | 3.3 | 任务管理可用 |
|
||||
| 3.5 | 单个生成点测试功能 | - 提供 API 允许用户测试单个生成点(不保存文档)<br>- 返回 AI 生成结果预览(可返回纯文本或HTML)<br>- 前端在标注弹窗中增加“测试”按钮,展示结果 | 16 | P1 | 3.1, 3.2 | 单点测试可用 |
|
||||
|
||||
## 阶段四:前端开发 (第5~9周,与后端并行)
|
||||
|
||||
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|
||||
|----|------|--------|---------|--------|------|--------|
|
||||
| 4.1 | 页面路由与布局 | - 使用 React Router 定义路由(/templates, /editor/:id, /models, /tasks, /settings)<br>- 整体布局(侧边栏 + 内容区) | 12 | P0 | 1.2 | 框架搭建 |
|
||||
| 4.2 | 模型管理页面 | - 列表展示(表格 + 分页)<br>- 新建/编辑弹窗表单(含供应商、API Key、扩展参数JSON编辑器)<br>- 启用/禁用开关<br>- 删除确认<br>- 设置全局默认模型(单选按钮) | 24 | P0 | 2.2 | 模型管理交互完整 |
|
||||
| 4.3 | 模板列表与上传 | - 列表页面(卡片或表格,含名称、创建时间、操作按钮)<br>- 上传文件组件(支持拖拽,仅 .docx)<br>- 点击“编辑”跳转到编辑器页 | 16 | P0 | 2.3 | 可上传和查看模板 |
|
||||
| 4.4 | 模板编辑器(核心) | **拆解为以下子任务**:<br>- **4.4.1** 集成 TinyMCE,加载 HTML 内容,保存时调用更新接口(8h)<br>- **4.4.2** 实现选区监听与高亮标注(监听 mouseup,显示浮动按钮“设为AI生成点”)(12h)<br>- **4.4.3** 生成点弹窗表单:提示词、参考文件上传(拖拽)、模型下拉(独立选择),并集成“测试”按钮(调用3.5)(16h)<br>- **4.4.4** 生成点列表(右侧面板),显示每个点的摘要,支持删除、编辑(修改弹窗)(8h)<br>- **4.4.5** 标注区域与实际选区偏移量同步(确保插入位置准确)(4h)<br>- **4.4.6** 支持拖拽排序生成点(使用 react-beautiful-dnd 等),更新顺序(4h)<br>**合计** | 52 | P0 | 4.3, 2.4, 4.2, 3.5, 2.5 | 可编辑并标注 |
|
||||
| 4.5 | 生成任务执行与监控 | - 页面内“生成文档”按钮(可放在编辑器底部或工具栏)<br>- 弹出确认框,显示所有生成点列表(可勾选跳过个别)<br>- 提交后显示任务进度条(轮询状态,含进度百分比)<br>- 完成后自动显示下载按钮(支持 docx 和 pdf 格式切换) | 28 | P0 | 3.4 | 完整生成流程 |
|
||||
| 4.6 | 任务历史页面 | - 表格列出所有任务(模板名称、状态、开始/完成时间)<br>- 状态为“已完成”的可以下载(格式选择)<br>- 状态为“进行中”的显示进度,可取消(可选) | 16 | P1 | 3.4 | 任务可追溯 |
|
||||
| 4.7 | 系统设置页面 | - 展示可编辑的系统配置(全局默认模型、最大并发数、超时时间等)<br>- 调用后端接口读写 system_config | 12 | P1 | 2.2 | 设置可用 |
|
||||
|
||||
## 阶段五:测试与优化 (第10~11周)
|
||||
|
||||
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|
||||
|----|------|--------|---------|--------|------|--------|
|
||||
| 5.1 | 集成测试 | - 端到端测试(上传 → 标注(含拖拽排序)→ 单点测试 → 生成 → 下载)<br>- 测试不同供应商模型调用<br>- 测试参考文件多种格式(txt, docx, pdf)<br>- 测试导出 PDF 功能<br>- 异常场景(网络超时、文件损坏、AI 返回错误) | 48 | P0 | 所有前序 | 功能稳定 |
|
||||
| 5.2 | 性能调优 | - 优化大文档转换速度(异步处理)<br>- 数据库查询添加索引(template_id, status)<br>- 调整 Celery 并发参数<br>- 优化前端渲染(虚拟列表等) | 16 | P1 | 5.1 | 响应达标 |
|
||||
| 5.3 | 安全加固 | - 检查 API Key 加密流程<br>- 文件上传类型和大小限制(限制50MB)<br>- 添加 CORS 配置<br>- 输入校验(防止 XSS) | 8 | P0 | 5.1 | 安全合规 |
|
||||
| 5.4 | 文档编写 | - 用户手册(操作指南,含截图)<br>- 部署文档(Docker 详细步骤)<br>- API 文档(由 FastAPI 自动生成,补充说明) | 24 | P1 | - | 交付文档完整 |
|
||||
|
||||
## 阶段六:部署上线 (第12周)
|
||||
|
||||
| ID | 任务 | 子任务 | 工时(h) | 优先级 | 依赖 | 里程碑 |
|
||||
|----|------|--------|---------|--------|------|--------|
|
||||
| 6.1 | Docker 化所有服务 | - 编写 Dockerfile(后端、前端、Celery)<br>- 编写 docker-compose.yml 整合所有服务(postgres, redis, backend, celery-worker, nginx) | 16 | P0 | 所有 | 可容器化运行 |
|
||||
| 6.2 | 生产环境配置 | - 配置环境变量(数据库、Redis、密钥等)<br>- 配置 Nginx 反向代理(前端静态 + 后端 API 转发)<br>- 配置 SSL(可选) | 8 | P0 | 6.1 | 生产就绪 |
|
||||
| 6.3 | 部署测试 | - 在测试服务器上部署,验证全部功能(含 PDF 导出) | 8 | P0 | 6.2 | 上线成功 |
|
||||
|
||||
## 里程碑总览
|
||||
|
||||
| 里程碑 | 预计完成时间 | 关键交付 |
|
||||
|--------|--------------|----------|
|
||||
| M1: 架构与数据库就绪 | 第2周末 | 需求文档、数据库设计、环境搭建、文件存储 |
|
||||
| M2: 核心 API 完成 | 第5周末 | 模板、模型、生成点 CRUD 完成,文档转换与导出(含PDF) |
|
||||
| M3: 异步生成能力 | 第7周末 | Celery 任务可端到端生成文档,单点测试可用 |
|
||||
| M4: 前端完整交互 | 第9周末 | 所有页面可用(含拖拽排序、任务历史、系统设置),生成流程顺畅 |
|
||||
| M5: 测试与稳定 | 第11周末 | 通过集成测试,性能优化,安全加固 |
|
||||
| M6: 正式上线 | 第12周末 | Docker 部署,生产环境运行 |
|
||||
|
||||
## 总工时估算
|
||||
|
||||
- 开发:约 **468 人时**(按 8h/天 ≈ 58.5 人天)
|
||||
- 含新增功能(单点测试、PDF导出、拖拽排序、系统设置)和细化拆分
|
||||
- 测试与部署额外计入,总项目周期约 **12 周**(建议 4~5 人并行开发)
|
||||
|
||||
---
|
||||
@@ -0,0 +1,63 @@
|
||||
# 技术方案概述
|
||||
## 1. 总体架构
|
||||
采用前后端分离架构,后端提供 RESTful API,前端负责交互和展示。异步任务通过 Celery 处理,确保长时间 AI 调用不阻塞主线程。
|
||||
### 架构图
|
||||
```
|
||||
用户 -> Nginx (前端静态) -> React 应用
|
||||
|
|
||||
+-> FastAPI 后端 (API)
|
||||
|
|
||||
+-> PostgreSQL (业务数据)
|
||||
+-> Redis (消息队列 + 缓存)
|
||||
+-> Celery Worker (异步任务)
|
||||
+-> 文件存储 (模板/参考文件/结果)
|
||||
```
|
||||
## 2. 技术选型
|
||||
| 层级 | 组件 | 理由 |
|
||||
|------|------|------|
|
||||
| 前端框架 | React + TypeScript | 生态成熟,类型安全,组件化利于维护 |
|
||||
| UI 库 | Ant Design | 后台管理组件丰富,开发效率高 |
|
||||
| 富文本编辑器 | TinyMCE | 支持选区操作,可扩展,兼容性好 |
|
||||
| 后端框架 | FastAPI | 异步高性能,自动生成文档,易于集成 AI |
|
||||
| ORM | SQLAlchemy | 功能强大,支持异步 (2.0) |
|
||||
| 任务队列 | Celery + Redis | 久经考验,支持重试、状态追踪 |
|
||||
| 文档处理 | Aspose.Words (优先) | 高保真 Word ↔ HTML 转换,样式保留最佳 |
|
||||
| 备选文档方案 | Mammoth + python-docx | 开源免费,但复杂样式可能丢失 |
|
||||
| AI 调用 | httpx (异步) | 支持异步请求,适配多种 API 格式 |
|
||||
| 容器化 | Docker Compose | 简化部署,环境一致性 |
|
||||
## 3. 核心模块设计
|
||||
### 3.1 AI 模型管理模块
|
||||
- 支持新建、编辑、删除、启用/禁用模型。
|
||||
- 模型信息包括:名称、供应商、接口地址、API Key(加密存储)、扩展参数、备注。
|
||||
- 提供下拉选择供生成点关联。
|
||||
### 3.2 模板管理模块
|
||||
- 上传 .docx → 存储原始文件,转换为 HTML 存于数据库。
|
||||
- 在线编辑:前端编辑器修改 HTML,后端同步转回 .docx。
|
||||
- 导出最终 .docx 文件。
|
||||
### 3.3 生成点管理模块
|
||||
- 用户在编辑器中框选文本,标注为 AI 生成点。
|
||||
- 填写提示词,上传参考文件,选择模型。
|
||||
- 保存选区位置(起始/结束偏移量或 XPath)。
|
||||
### 3.4 异步生成任务
|
||||
1. 用户触发生成,系统创建任务记录,启动 Celery 任务。
|
||||
2. 任务流程:
|
||||
- 加载模板和所有生成点。
|
||||
- 对每个生成点,提取参考文件文本,构造 prompt。
|
||||
- 调用对应的 AI 模型(通过适配器)。
|
||||
- 将生成结果插入文档对应位置。
|
||||
- 保存最终文档。
|
||||
3. 前端轮询任务状态,完成后提供下载。
|
||||
## 4. 安全设计
|
||||
- **API Key 加密**:使用 `cryptography.fernet` 对称加密,密钥取自环境变量。
|
||||
- **文件隔离**:若未来引入多用户,可通过用户目录隔离文件。
|
||||
- **输入校验**:所有 API 使用 Pydantic 校验,防止注入攻击。
|
||||
- **CORS**:配置仅允许前端域名访问。
|
||||
## 5. 性能与扩展
|
||||
- **异步处理**:AI 调用和文档转换均异步,提升吞吐量。
|
||||
- **连接池**:数据库和 Redis 使用连接池,避免资源耗尽。
|
||||
- **水平扩展**:Celery worker 可多实例部署,后端 API 可水平扩展。
|
||||
- **适配器模式**:AI 调用采用工厂模式,新增供应商无需修改核心逻辑。
|
||||
## 6. 部署方案
|
||||
- 使用 Docker Compose 编排:PostgreSQL、Redis、FastAPI、Celery Worker、前端 Nginx。
|
||||
- 环境变量统一管理,通过 `.env` 配置。
|
||||
- 生产环境建议使用反向代理(如 Nginx)挂载 SSL 证书。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 数据库设计
|
||||
## ER 图(核心关系)
|
||||
- `templates` (1) ——> (N) `generation_points`
|
||||
- `generation_points` (N) ——> (1) `models`
|
||||
- `templates` (1) ——> (N) `generation_tasks`
|
||||
## 表结构详细
|
||||
### 1. `models`(AI 模型配置)
|
||||
| 字段名 | 类型 | 约束 | 说明 |
|
||||
| -------------- | ------------- | ------------------ | -------------------------------------- |
|
||||
| id | UUID | PRIMARY KEY | 主键 |
|
||||
| name | VARCHAR(100) | NOT NULL | 模型名称 |
|
||||
| provider | VARCHAR(50) | NOT NULL | 供应商:openai, azure, custom |
|
||||
| endpoint | VARCHAR(255) | NOT NULL | 接口地址 |
|
||||
| api_key | TEXT | NOT NULL | 加密存储(Fernet 对称加密) |
|
||||
| extra_params | JSONB | DEFAULT '{}' | 扩展参数,如 max_tokens, temperature |
|
||||
| is_enabled | BOOLEAN | DEFAULT TRUE | 是否启用 |
|
||||
| remark | TEXT | | 备注 |
|
||||
| created_at | TIMESTAMP | DEFAULT NOW() | |
|
||||
| updated_at | TIMESTAMP | DEFAULT NOW() | |
|
||||
### 2. `templates`(模板文档)
|
||||
| 字段名 | 类型 | 约束 | 说明 |
|
||||
| -------------- | ------------- | ------------------ | ------------------------------------------ |
|
||||
| id | UUID | PRIMARY KEY | |
|
||||
| name | VARCHAR(200) | NOT NULL | 模板名称 |
|
||||
| file_path | VARCHAR(500) | NOT NULL | 原始 .docx 文件存储路径 |
|
||||
| html_content | TEXT | | 转换后的 HTML 内容(供前端编辑) |
|
||||
| created_at | TIMESTAMP | DEFAULT NOW() | |
|
||||
| updated_at | TIMESTAMP | DEFAULT NOW() | |
|
||||
### 3. `generation_points`(AI 生成点)
|
||||
| 字段名 | 类型 | 约束 | 说明 |
|
||||
| --------------- | ------------- | ------------------ | ------------------------------------------------- |
|
||||
| id | UUID | PRIMARY KEY | |
|
||||
| template_id | UUID | FOREIGN KEY | 关联模板 |
|
||||
| position | JSONB | NOT NULL | 在 HTML 中的选区信息,如 {start: 100, end: 200} 或 XPath |
|
||||
| prompt | TEXT | NOT NULL | 用户编写的提示词 |
|
||||
| model_id | UUID | FOREIGN KEY | 指定使用的模型,若为空则使用全局默认模型 |
|
||||
| ref_file_path | VARCHAR(500) | | 参考文件存储路径(可选) |
|
||||
| created_at | TIMESTAMP | DEFAULT NOW() | |
|
||||
| updated_at | TIMESTAMP | DEFAULT NOW() | |
|
||||
### 4. `generation_tasks`(生成任务)
|
||||
| 字段名 | 类型 | 约束 | 说明 |
|
||||
| --------------- | ------------- | ------------------ | ----------------------------------------------- |
|
||||
| id | UUID | PRIMARY KEY | |
|
||||
| template_id | UUID | FOREIGN KEY | 关联模板 |
|
||||
| status | VARCHAR(20) | NOT NULL | pending / processing / done / failed |
|
||||
| result_file_path| VARCHAR(500) | | 生成后的 .docx 文件路径 |
|
||||
| error_msg | TEXT | | 任务失败时的错误信息 |
|
||||
| celery_task_id | VARCHAR(100) | | Celery 任务 ID,便于追踪 |
|
||||
| created_at | TIMESTAMP | DEFAULT NOW() | |
|
||||
| finished_at | TIMESTAMP | | 完成时间 |
|
||||
### 5. `system_config`(系统配置,可选)
|
||||
存储全局默认模型 ID 等键值对。
|
||||
| 字段名 | 类型 | 约束 | 说明 |
|
||||
| ----------- | ------------- | -------- | -------------------- |
|
||||
| key | VARCHAR(50) | PRIMARY | 配置键 |
|
||||
| value | TEXT | | 配置值(JSON 格式) |
|
||||
| description | VARCHAR(200) | | 描述 |
|
||||
## 索引建议
|
||||
- `templates`:在 `created_at` 上建索引,便于按时间排序。
|
||||
- `generation_points`:在 `template_id` 上建外键索引,提高关联查询速度。
|
||||
- `generation_tasks`:在 `template_id,status` 上建索引,优化列表查询。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 部署指南 (Docker Compose)
|
||||
## 前置条件
|
||||
- Linux 服务器(或 Windows WSL2)
|
||||
- Docker 和 Docker Compose 已安装
|
||||
- 域名(可选)和 SSL 证书(可选)
|
||||
## 步骤
|
||||
### 1. 克隆代码
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd doc-forge-reborn
|
||||
```
|
||||
### 2. 配置环境变量
|
||||
复制 `.env.example` 为 `.env`,并填写以下关键变量:
|
||||
```env
|
||||
# Database
|
||||
POSTGRES_USER=docforge
|
||||
POSTGRES_PASSWORD=secure_password
|
||||
POSTGRES_DB=docforge
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
# Backend
|
||||
SECRET_KEY=your-secret-key-for-fernet-encryption
|
||||
DEFAULT_MODEL_ID=uuid-of-default-model
|
||||
# AI API Keys (也可在后台模型管理中配置)
|
||||
OPENAI_API_KEY=sk-...
|
||||
AZURE_API_KEY=...
|
||||
```
|
||||
### 3. 构建并启动所有服务
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
### 4. 执行数据库迁移(首次启动后)
|
||||
```bash
|
||||
docker-compose exec backend alembic upgrade head
|
||||
```
|
||||
### 5. 验证服务状态
|
||||
- 前端`http://localhost:3000`
|
||||
- 后端 API 文档`http://localhost:8000/docs`
|
||||
- Celery Worker 日志`docker-compose logs celery-worker`
|
||||
## 服务端口映射
|
||||
- 前端`3000` (可通过 `nginx` 暴露 80)
|
||||
- 后端 API`8000`
|
||||
- PostgreSQL`5432` (仅内部)
|
||||
- Redis`6379` (仅内部)
|
||||
## 生产环境优化建议
|
||||
- 使用外部 PostgreSQL(如 AWS RDS)提升可靠性。
|
||||
- 将 Redis 配置为持久化模式。
|
||||
- 挂载 SSL 证书,配置 HTTPS。
|
||||
- 设置 Celery 并发数根据 CPU 核心数调整。
|
||||
- 定期备份数据库和文件存储目录。
|
||||
## 停止服务
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
## 升级流程
|
||||
1. `git pull` 拉取最新代码
|
||||
2. `docker-compose build` 重新构建镜像
|
||||
3. `docker-compose up -d` 重启服务
|
||||
4. 运行数据库迁移(如有)
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git
|
||||
*.local
|
||||
public/tinymce/
|
||||
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
public/tinymce
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run postinstall && npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
Generated
+2773
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"postinstall": "cp -r node_modules/tinymce public/tinymce 2>/dev/null || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@tinymce/tinymce-react": "^6.3.0",
|
||||
"antd": "^6.5.0",
|
||||
"axios": "^1.18.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"tinymce": "^8.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,26 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import AppLayout from "./components/AppLayout";
|
||||
import TemplateList from "./pages/TemplateList";
|
||||
import TemplateEditor from "./pages/TemplateEditor";
|
||||
import ModelList from "./pages/ModelList";
|
||||
import TaskList from "./pages/TaskList";
|
||||
import TaskDetail from "./pages/TaskDetail";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<TemplateList />} />
|
||||
<Route path="/templates" element={<TemplateList />} />
|
||||
<Route path="/editor/:id" element={<TemplateEditor />} />
|
||||
<Route path="/models" element={<ModelList />} />
|
||||
<Route path="/tasks" element={<TaskList />} />
|
||||
<Route path="/tasks/:id" element={<TaskDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,57 @@
|
||||
import request from "./request";
|
||||
import type { GenerationPoint } from "../types";
|
||||
|
||||
export async function listGenerationPoints(template_id: string) {
|
||||
const { data } = await request.get<GenerationPoint[]>("/generation-points", {
|
||||
params: { template_id },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createGenerationPoint(body: {
|
||||
template_id: string;
|
||||
position: string;
|
||||
prompt: string;
|
||||
model_id?: string;
|
||||
order?: number;
|
||||
selected_text?: string;
|
||||
need_ref_file?: boolean;
|
||||
remark?: string;
|
||||
ref_file?: File;
|
||||
}) {
|
||||
const formData = new FormData();
|
||||
formData.append("template_id", body.template_id);
|
||||
formData.append("position", body.position);
|
||||
formData.append("prompt", body.prompt);
|
||||
if (body.model_id) formData.append("model_id", body.model_id);
|
||||
if (body.order !== undefined) formData.append("order", String(body.order));
|
||||
if (body.selected_text) formData.append("selected_text", body.selected_text);
|
||||
if (body.need_ref_file) formData.append("need_ref_file", "true");
|
||||
if (body.remark) formData.append("remark", body.remark);
|
||||
if (body.ref_file) formData.append("ref_file", body.ref_file);
|
||||
const { data } = await request.post<GenerationPoint>("/generation-points", formData);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateGenerationPoint(
|
||||
id: string,
|
||||
body: Partial<GenerationPoint>
|
||||
) {
|
||||
const { data } = await request.put<GenerationPoint>(`/generation-points/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteGenerationPoint(id: string) {
|
||||
await request.delete(`/generation-points/${id}`);
|
||||
}
|
||||
|
||||
export async function batchUpdateOrder(points: { id: string; order: number }[]) {
|
||||
await request.post("/generation-points/batch-order", { points });
|
||||
}
|
||||
|
||||
export async function testGenerationPoint(pointId: string) {
|
||||
const { data } = await request.post<{ result: string }>(
|
||||
`/generation-points/${pointId}/test`
|
||||
);
|
||||
return data.result;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import request from "./request";
|
||||
import type { AIModel } from "../types";
|
||||
|
||||
export async function listModels(params?: { enabled?: boolean }) {
|
||||
const { data } = await request.get<AIModel[]>("/models", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getModel(id: string) {
|
||||
const { data } = await request.get<AIModel>(`/models/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createModel(body: Partial<AIModel> & { api_key: string }) {
|
||||
const { data } = await request.post<AIModel>("/models", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateModel(id: string, body: Partial<AIModel>) {
|
||||
const { data } = await request.put<AIModel>(`/models/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteModel(id: string) {
|
||||
await request.delete(`/models/${id}`);
|
||||
}
|
||||
|
||||
export async function toggleModel(id: string, is_enabled: boolean) {
|
||||
const { data } = await request.patch<AIModel>(`/models/${id}/toggle`, { is_enabled });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function testModel(id: string) {
|
||||
const { data } = await request.post<{ success: boolean; result?: string; error?: string }>(`/models/${id}/test`);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from "axios";
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: "/api/v1",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const message = error.response?.data?.detail || error.message || "请求失败";
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
);
|
||||
|
||||
export default request;
|
||||
@@ -0,0 +1,10 @@
|
||||
import request from "./request";
|
||||
|
||||
export async function getAllSettings() {
|
||||
const { data } = await request.get<Record<string, string>>("/settings");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSetting(key: string, value: string, description?: string) {
|
||||
await request.put(`/settings/${key}`, { value, description });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import request from "./request";
|
||||
import type { GenerationTask } from "../types";
|
||||
|
||||
export async function triggerGeneration(templateId: string) {
|
||||
const { data } = await request.post<{ task_id: string; status: string }>(
|
||||
`/templates/${templateId}/generate`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getTaskStatus(taskId: string) {
|
||||
const { data } = await request.get<GenerationTask>(`/tasks/${taskId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listTasks() {
|
||||
const { data } = await request.get<GenerationTask[]>("/tasks");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function cancelTask(taskId: string) {
|
||||
await request.post(`/tasks/${taskId}/cancel`);
|
||||
}
|
||||
|
||||
export function getTaskDownloadUrl(taskId: string, format: "docx" | "pdf" = "docx") {
|
||||
return `/api/v1/tasks/${taskId}/download?format=${format}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import request from "./request";
|
||||
import type { Template, TemplateListItem } from "../types";
|
||||
|
||||
export async function listTemplates() {
|
||||
const { data } = await request.get<TemplateListItem[]>("/templates");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getTemplate(id: string) {
|
||||
const { data } = await request.get<Template>(`/templates/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function uploadTemplate(file: File, name?: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (name) formData.append("name", name);
|
||||
const { data } = await request.post<Template>("/templates", formData);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getTemplateHtml(id: string) {
|
||||
const { data } = await request.get<{ html_content: string }>(`/templates/${id}/html`);
|
||||
return data.html_content;
|
||||
}
|
||||
|
||||
export async function updateTemplateHtml(id: string, html_content: string) {
|
||||
await request.put(`/templates/${id}/html`, { html_content });
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string) {
|
||||
await request.delete(`/templates/${id}`);
|
||||
}
|
||||
|
||||
export function getDownloadUrl(id: string, format: "docx" | "pdf" = "docx") {
|
||||
return `/api/v1/templates/${id}/download?format=${format}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Layout, Menu } from "antd";
|
||||
import {
|
||||
FileTextOutlined,
|
||||
RobotOutlined,
|
||||
UnorderedListOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
const { Sider, Content, Header } = Layout;
|
||||
|
||||
const menuItems = [
|
||||
{ key: "/templates", icon: <FileTextOutlined />, label: "模板管理" },
|
||||
{ key: "/models", icon: <RobotOutlined />, label: "模型管理" },
|
||||
{ key: "/tasks", icon: <UnorderedListOutlined />, label: "任务历史" },
|
||||
{ key: "/settings", icon: <SettingOutlined />, label: "系统设置" },
|
||||
];
|
||||
|
||||
export default function AppLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const selectedKey = "/" + location.pathname.split("/")[1];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider width={220} theme="dark">
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
borderBottom: "1px solid rgba(255,255,255,0.1)",
|
||||
}}
|
||||
>
|
||||
Doc Forge Reborn
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Content style={{ padding: 24, background: "#f5f5f5", overflow: "auto" }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal, Button, Space, message, Card, Tag, Upload, List, Popconfirm,
|
||||
} from "antd";
|
||||
import {
|
||||
UploadOutlined, DeleteOutlined, FileTextOutlined,
|
||||
ThunderboltOutlined, PaperClipOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { GenerationPoint, AIModel } from "../types";
|
||||
import * as taskApi from "../api/tasks";
|
||||
import request from "../api/request";
|
||||
|
||||
interface GenerateModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
templateId: string;
|
||||
points: GenerationPoint[];
|
||||
models: AIModel[];
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
uid: string;
|
||||
name: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export default function GenerateModal({
|
||||
open, onClose, templateId, points, models,
|
||||
}: GenerateModalProps) {
|
||||
const [filesMap, setFilesMap] = useState<Record<string, FileItem[]>>({});
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const handleUpload = async (pointId: string, fileList: File[]) => {
|
||||
if (fileList.length === 0) return;
|
||||
const formData = new FormData();
|
||||
fileList.forEach((f) => formData.append("ref_files", f));
|
||||
await request.post(`/generation-points/${pointId}/upload-ref`, formData);
|
||||
const newFiles = fileList.map((f) => ({
|
||||
uid: f.name + Date.now(),
|
||||
name: f.name,
|
||||
}));
|
||||
setFilesMap((prev) => ({
|
||||
...prev,
|
||||
[pointId]: [...(prev[pointId] || []), ...newFiles],
|
||||
}));
|
||||
message.success(`已上传 ${fileList.length} 个文件`);
|
||||
};
|
||||
|
||||
const handleRemoveFile = (pointId: string, fileName: string) => {
|
||||
setFilesMap((prev) => ({
|
||||
...prev,
|
||||
[pointId]: (prev[pointId] || []).filter((f) => f.name !== fileName),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const { task_id } = await taskApi.triggerGeneration(templateId);
|
||||
message.success("已创建生成任务,请前往任务历史查看");
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) message.error(err.message);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getModelName = (modelId: string | null) => {
|
||||
if (!modelId) return "默认模型";
|
||||
const m = models.find((x) => x.id === modelId);
|
||||
return m?.name || modelId;
|
||||
};
|
||||
|
||||
const pointsNeedRef = points.filter((p) => p.need_ref_file);
|
||||
const pointsNoRef = points.filter((p) => !p.need_ref_file);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="确认生成文档"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={700}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={handleGenerate}
|
||||
loading={generating}
|
||||
>
|
||||
开始生成
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<p style={{ marginBottom: 16, color: "#666" }}>
|
||||
共 {points.length} 个 AI 生成点,请确认提示词并上传参考文件后点击生成。
|
||||
</p>
|
||||
|
||||
{pointsNeedRef.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Tag color="orange">需要上传文件 ({pointsNeedRef.length})</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{points.map((point, idx) => {
|
||||
const uploadedFiles = filesMap[point.id] || [];
|
||||
const modelName = getModelName(point.model_id);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={point.id}
|
||||
size="small"
|
||||
style={{ marginBottom: 12 }}
|
||||
title={
|
||||
<Space size={4}>
|
||||
<span style={{ fontSize: 12, color: "#999" }}>#{idx + 1}</span>
|
||||
<span style={{ fontSize: 13 }}>{point.prompt.slice(0, 30)}{point.prompt.length > 30 ? "..." : ""}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Tag color="blue" style={{ fontSize: 11 }}>{modelName}</Tag>
|
||||
}
|
||||
>
|
||||
{point.remark && (
|
||||
<p style={{ fontSize: 12, color: "#999", marginBottom: 8 }}>
|
||||
备注:{point.remark}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{point.need_ref_file && (
|
||||
<div>
|
||||
<Upload
|
||||
multiple
|
||||
accept=".txt,.docx,.pdf"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
handleUpload(point.id, [file]);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<UploadOutlined />}>
|
||||
上传参考文件
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
{uploadedFiles.length > 0 && (
|
||||
<List
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
dataSource={uploadedFiles}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
extra={
|
||||
<Popconfirm
|
||||
title="删除此文件?"
|
||||
onConfirm={() => handleRemoveFile(point.id, item.name)}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
}
|
||||
>
|
||||
<FileTextOutlined style={{ marginRight: 6 }} />
|
||||
<span style={{ fontSize: 12 }}>{item.name}</span>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Table, Button, Modal, Form, Input, Select, Switch,
|
||||
Space, message, Popconfirm, Tag,
|
||||
} from "antd";
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ExperimentOutlined } from "@ant-design/icons";
|
||||
import type { AIModel } from "../types";
|
||||
import * as modelApi from "../api/models";
|
||||
|
||||
const PROVIDERS = [
|
||||
{ label: "OpenAI", value: "openai" },
|
||||
{ label: "Azure", value: "azure" },
|
||||
{ label: "自定义", value: "custom" },
|
||||
];
|
||||
|
||||
export default function ModelList() {
|
||||
const [models, setModels] = useState<AIModel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<AIModel | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await modelApi.listModels();
|
||||
setModels(data);
|
||||
} catch {
|
||||
message.error("获取模型列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels();
|
||||
}, [fetchModels]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingModel(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (model: AIModel) => {
|
||||
setEditingModel(model);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return;
|
||||
// 延迟确保 Form DOM 已渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (editingModel) {
|
||||
const extra = editingModel.extra_params || {};
|
||||
form.setFieldsValue({
|
||||
name: editingModel.name,
|
||||
provider: editingModel.provider,
|
||||
model_name: extra.model || "",
|
||||
endpoint: editingModel.endpoint,
|
||||
api_key: "",
|
||||
extra_params: JSON.stringify(extra, null, 2),
|
||||
is_enabled: editingModel.is_enabled,
|
||||
remark: editingModel.remark,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ provider: "openai", is_enabled: true, extra_params: "{}" });
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [modalOpen, editingModel]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
let extraParams = {};
|
||||
try {
|
||||
extraParams = JSON.parse(values.extra_params || "{}");
|
||||
} catch {
|
||||
message.error("扩展参数 JSON 格式错误");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
extra_params: { ...extraParams, ...(values.model_name ? { model: values.model_name } : {}) },
|
||||
};
|
||||
delete payload.model_name;
|
||||
|
||||
if (editingModel) {
|
||||
if (!payload.api_key) delete payload.api_key;
|
||||
await modelApi.updateModel(editingModel.id, payload);
|
||||
message.success("更新成功");
|
||||
} else {
|
||||
await modelApi.createModel(payload);
|
||||
message.success("创建成功");
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchModels();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) message.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await modelApi.deleteModel(id);
|
||||
message.success("删除成功");
|
||||
fetchModels();
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string, enabled: boolean) => {
|
||||
await modelApi.toggleModel(id, enabled);
|
||||
fetchModels();
|
||||
};
|
||||
|
||||
const handleTest = async (id: string, name: string) => {
|
||||
message.loading({ content: `正在测试 ${name}...`, key: "test" });
|
||||
try {
|
||||
const res = await modelApi.testModel(id);
|
||||
if (res.success) {
|
||||
message.success({ content: `${name} 测试通过`, key: "test" });
|
||||
Modal.info({ title: `测试结果 - ${name}`, content: res.result, width: 600 });
|
||||
} else {
|
||||
message.error({ content: `测试失败: ${res.error}`, key: "test", duration: 5 });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
message.error({ content: err instanceof Error ? err.message : "测试失败", key: "test", duration: 5 });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: "名称", dataIndex: "name", key: "name", width: 150 },
|
||||
{
|
||||
title: "供应商",
|
||||
dataIndex: "provider",
|
||||
key: "provider",
|
||||
width: 80,
|
||||
render: (v: string) => {
|
||||
const label = PROVIDERS.find((p) => p.value === v)?.label || v;
|
||||
return <Tag>{label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "模型名",
|
||||
key: "model_name",
|
||||
width: 140,
|
||||
render: (_: unknown, r: AIModel) => <Tag>{(r.extra_params as Record<string,unknown>)?.model as string || "-"}</Tag>,
|
||||
},
|
||||
{ title: "接口地址", dataIndex: "endpoint", key: "endpoint", ellipsis: true },
|
||||
{
|
||||
title: "启用",
|
||||
dataIndex: "is_enabled",
|
||||
key: "is_enabled",
|
||||
width: 80,
|
||||
render: (v: boolean, record: AIModel) => (
|
||||
<Switch
|
||||
checked={v}
|
||||
size="small"
|
||||
onChange={(checked) => handleToggle(record.id, checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "备注",
|
||||
dataIndex: "remark",
|
||||
key: "remark",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => v || "-",
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 160,
|
||||
render: (_: unknown, record: AIModel) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ExperimentOutlined />}
|
||||
onClick={() => handleTest(record.id, record.name)}
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此模型?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
|
||||
<h2>模型管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新建模型
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={models}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
<Modal
|
||||
title={editingModel ? "编辑模型" : "新建模型"}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setModalOpen(false); form.resetFields(); }}
|
||||
onOk={handleSubmit}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="模型名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="供应商" rules={[{ required: true }]}>
|
||||
<Select options={PROVIDERS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="model_name" label="模型名" rules={[{ required: true }]}>
|
||||
<Input placeholder="例如 deepseek-chat / gpt-4 / gpt-3.5-turbo" />
|
||||
</Form.Item>
|
||||
<Form.Item name="endpoint" label="接口地址" rules={[{ required: true }]}>
|
||||
<Input placeholder="https://api.openai.com/v1/chat/completions" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
rules={editingModel ? [] : [{ required: true, message: "请输入 API Key" }]}
|
||||
>
|
||||
<Input.Password placeholder={editingModel ? "留空则不修改" : "sk-..."} />
|
||||
</Form.Item>
|
||||
<Form.Item name="extra_params" label="扩展参数 (JSON)">
|
||||
<Input.TextArea rows={4} placeholder='{"temperature": 0.7, "max_tokens": 2000}' />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Form, Select, InputNumber, Button, message, Spin } from "antd";
|
||||
import { SaveOutlined } from "@ant-design/icons";
|
||||
import * as settingsApi from "../api/settings";
|
||||
import * as modelApi from "../api/models";
|
||||
|
||||
export default function Settings() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [models, setModels] = useState<{ label: string; value: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [settings, modelList] = await Promise.all([
|
||||
settingsApi.getAllSettings(),
|
||||
modelApi.listModels({ enabled: true }),
|
||||
]);
|
||||
form.setFieldsValue({
|
||||
default_model: settings.default_model || undefined,
|
||||
max_concurrency: parseInt(settings.max_concurrency || "3", 10),
|
||||
timeout: parseInt(settings.timeout || "120", 10),
|
||||
});
|
||||
setModels(modelList.map((m) => ({ label: m.name, value: m.id })));
|
||||
} catch {
|
||||
message.error("加载设置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await Promise.all([
|
||||
settingsApi.updateSetting("default_model", values.default_model || ""),
|
||||
settingsApi.updateSetting("max_concurrency", String(values.max_concurrency)),
|
||||
settingsApi.updateSetting("timeout", String(values.timeout)),
|
||||
]);
|
||||
message.success("设置保存成功");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) message.error(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Spin style={{ display: "block", marginTop: 100 }} />;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
<h2 style={{ marginBottom: 16 }}>系统设置</h2>
|
||||
<Card>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="default_model" label="全局默认 AI 模型">
|
||||
<Select
|
||||
placeholder="留空则生成点必须指定模型"
|
||||
allowClear
|
||||
options={models}
|
||||
notFoundContent="暂无可用模型,请先在模型管理中添加"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="max_concurrency" label="最大并发数" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={10} />
|
||||
</Form.Item>
|
||||
<Form.Item name="timeout" label="AI 调用超时(秒)" rules={[{ required: true }]}>
|
||||
<InputNumber min={10} max={600} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Card, Tag, Button, Space, Spin, Descriptions, List, Empty,
|
||||
} from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined, DownloadOutlined, FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import request from "../api/request";
|
||||
import type { GenerationPoint, AIModel } from "../types";
|
||||
import * as modelApi from "../api/models";
|
||||
import * as taskApi from "../api/tasks";
|
||||
|
||||
interface TaskDetailData {
|
||||
id: string;
|
||||
template_id: string;
|
||||
template_name: string;
|
||||
status: string;
|
||||
result_file_path: string | null;
|
||||
error_msg: string | null;
|
||||
created_at: string;
|
||||
finished_at: string | null;
|
||||
points: GenerationPoint[];
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; label: string }> = {
|
||||
pending: { color: "default", label: "等待中" },
|
||||
processing: { color: "processing", label: "生成中" },
|
||||
done: { color: "success", label: "已完成" },
|
||||
failed: { color: "error", label: "失败" },
|
||||
};
|
||||
|
||||
export default function TaskDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [task, setTask] = useState<TaskDetailData | null>(null);
|
||||
const [models, setModels] = useState<AIModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchTask = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await request.get<TaskDetailData>(`/tasks/${id}`, { params: { detail: true } });
|
||||
setTask(data);
|
||||
} catch {
|
||||
setTask(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetchTask(),
|
||||
modelApi.listModels({ enabled: true }).then(setModels).catch(() => {}),
|
||||
]);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!task || task.status === "done" || task.status === "failed") return;
|
||||
const timer = setInterval(fetchTask, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [task?.status]);
|
||||
|
||||
const getModelName = (modelId: string | null) => {
|
||||
if (!modelId) return "默认模型";
|
||||
return models.find((m) => m.id === modelId)?.name || modelId;
|
||||
};
|
||||
|
||||
if (loading) return <Spin style={{ display: "block", marginTop: 100 }} />;
|
||||
if (!task) return <Empty description="任务不存在" />;
|
||||
|
||||
const status = STATUS_MAP[task.status] || { color: "default", label: task.status };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/tasks")}>返回</Button>
|
||||
<h3 style={{ margin: 0 }}>任务详情</h3>
|
||||
</Space>
|
||||
{task.status === "done" && (
|
||||
<Space>
|
||||
<Button icon={<DownloadOutlined />} onClick={() => window.open(taskApi.getTaskDownloadUrl(task.id))}>
|
||||
DOCX
|
||||
</Button>
|
||||
<Button onClick={() => window.open(taskApi.getTaskDownloadUrl(task.id, "pdf"))}>
|
||||
PDF
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Descriptions column={3} size="small">
|
||||
<Descriptions.Item label="模板名称">{task.template_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态"><Tag color={status.color}>{status.label}</Tag></Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{new Date(task.created_at).toLocaleString()}</Descriptions.Item>
|
||||
{task.finished_at && (
|
||||
<Descriptions.Item label="完成时间">{new Date(task.finished_at).toLocaleString()}</Descriptions.Item>
|
||||
)}
|
||||
{task.error_msg && (
|
||||
<Descriptions.Item label="错误信息" span={3}>
|
||||
<span style={{ color: "red" }}>{task.error_msg}</span>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<h4 style={{ marginBottom: 12 }}>AI 生成点 ({task.points.length})</h4>
|
||||
{task.points.map((point, idx) => {
|
||||
const modelName = getModelName(point.model_id);
|
||||
const hasFiles = point.ref_file_path && point.ref_file_path !== "[]";
|
||||
let fileNames: string[] = [];
|
||||
if (hasFiles) {
|
||||
try { fileNames = JSON.parse(point.ref_file_path || "[]").map((f: string) => f.split("/").pop() || f); } catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={point.id}
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
title={
|
||||
<Space size={4}>
|
||||
<span style={{ fontSize: 12, color: "#999" }}>#{idx + 1}</span>
|
||||
<span style={{ fontSize: 13 }}>{point.prompt.slice(0, 40)}{point.prompt.length > 40 ? "..." : ""}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={<Tag color="blue">{modelName}</Tag>}
|
||||
>
|
||||
{point.remark && <p style={{ fontSize: 12, color: "#999", marginBottom: 8 }}>备注:{point.remark}</p>}
|
||||
{point.need_ref_file && (
|
||||
<div>
|
||||
{hasFiles && fileNames.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={fileNames}
|
||||
renderItem={(name: string) => (
|
||||
<List.Item><FileTextOutlined style={{ marginRight: 6 }} />{name}</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: "#999" }}>未上传参考文件</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Table, Button, Space, Tag, message, Popconfirm } from "antd";
|
||||
import { DownloadOutlined, StopOutlined, ReloadOutlined, EyeOutlined } from "@ant-design/icons";
|
||||
import type { GenerationTask } from "../types";
|
||||
import * as taskApi from "../api/tasks";
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; label: string }> = {
|
||||
pending: { color: "default", label: "等待中" },
|
||||
processing: { color: "processing", label: "生成中" },
|
||||
done: { color: "success", label: "已完成" },
|
||||
failed: { color: "error", label: "失败" },
|
||||
};
|
||||
|
||||
export default function TaskList() {
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<GenerationTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await taskApi.listTasks();
|
||||
setTasks(data);
|
||||
} catch {
|
||||
message.error("获取任务列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
await taskApi.cancelTask(id);
|
||||
message.success("已取消");
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "模板 ID",
|
||||
dataIndex: "template_id",
|
||||
key: "template_id",
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v.slice(0, 8) + "...",
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const cfg = STATUS_MAP[v] || { color: "default", label: v };
|
||||
return <Tag color={cfg.color}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "错误信息",
|
||||
dataIndex: "error_msg",
|
||||
key: "error_msg",
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => v || "-",
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "完成时间",
|
||||
dataIndex: "finished_at",
|
||||
key: "finished_at",
|
||||
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 200,
|
||||
render: (_: unknown, record: GenerationTask) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate(`/tasks/${record.id}`)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
{record.status === "done" && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => window.open(taskApi.getTaskDownloadUrl(record.id))}
|
||||
>
|
||||
DOCX
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => window.open(taskApi.getTaskDownloadUrl(record.id, "pdf"))}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === "pending" || record.status === "processing") && (
|
||||
<Popconfirm title="确定取消此任务?" onConfirm={() => handleCancel(record.id)}>
|
||||
<Button size="small" danger icon={<StopOutlined />}>
|
||||
取消
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
|
||||
<h2>任务历史</h2>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchTasks}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
onRow={(record) => ({
|
||||
style: { cursor: "pointer" },
|
||||
onClick: () => navigate(`/tasks/${record.id}`),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Layout, Button, Modal, Form, Input, Select,
|
||||
Space, message, Spin, Popconfirm, Card, Switch,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
|
||||
ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined,
|
||||
ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
||||
MenuOutlined, PaperClipOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Editor } from "@tinymce/tinymce-react";
|
||||
import type { Editor as TinyMCEEditor } from "tinymce";
|
||||
import type { GenerationPoint, AIModel } from "../types";
|
||||
import * as templateApi from "../api/templates";
|
||||
import * as pointApi from "../api/generationPoints";
|
||||
import * as modelApi from "../api/models";
|
||||
import request from "../api/request";
|
||||
import GenerateModal from "../components/GenerateModal";
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
export default function TemplateEditor() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const editorRef = useRef<TinyMCEEditor | null>(null);
|
||||
const selectionRef = useRef<{ start: number; end: number; text: string } | null>(null);
|
||||
|
||||
const [htmlContent, setHtmlContent] = useState("");
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
const [points, setPoints] = useState<GenerationPoint[]>([]);
|
||||
const [models, setModels] = useState<AIModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pointModalOpen, setPointModalOpen] = useState(false);
|
||||
const [editingPoint, setEditingPoint] = useState<GenerationPoint | null>(null);
|
||||
const [showSelectionBtn, setShowSelectionBtn] = useState(false);
|
||||
const [selBtnPos, setSelBtnPos] = useState({ x: 0, y: 0 });
|
||||
const [pointForm] = Form.useForm();
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||
const [generateModalOpen, setGenerateModalOpen] = useState(false);
|
||||
|
||||
const dragItem = useRef<number | null>(null);
|
||||
const dragOverItem = useRef<number | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [template, pointsData, modelsData] = await Promise.all([
|
||||
templateApi.getTemplate(id),
|
||||
pointApi.listGenerationPoints(id),
|
||||
modelApi.listModels({ enabled: true }),
|
||||
]);
|
||||
setTemplateName(template.name);
|
||||
setHtmlContent(template.html_content || "");
|
||||
setPoints(pointsData);
|
||||
setModels(modelsData);
|
||||
} catch {
|
||||
message.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const html = editorRef.current?.getContent() || htmlContent;
|
||||
await templateApi.updateTemplateHtml(id, html);
|
||||
setHtmlContent(html);
|
||||
message.success("保存成功");
|
||||
} catch {
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorInit = (_evt: unknown, editor: TinyMCEEditor) => {
|
||||
editorRef.current = editor;
|
||||
editor.on("selectionchange", () => {
|
||||
const sel = editor.selection.getContent({ format: "text" });
|
||||
if (sel && sel.trim().length > 0) {
|
||||
const content = editor.getContent();
|
||||
const rng = editor.selection.getRng();
|
||||
if (rng) {
|
||||
const preRange = rng.cloneRange();
|
||||
preRange.selectNodeContents(editor.getBody());
|
||||
preRange.setEnd(rng.startContainer, rng.startOffset);
|
||||
const start = preRange.toString().length;
|
||||
const end = start + sel.length;
|
||||
selectionRef.current = { start, end, text: sel.trim() };
|
||||
}
|
||||
const ed = editor.getContainer();
|
||||
const rect = ed.getBoundingClientRect();
|
||||
setSelBtnPos({ x: rect.left + 100, y: rect.top + 10 });
|
||||
setShowSelectionBtn(true);
|
||||
} else {
|
||||
setShowSelectionBtn(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreatePoint = () => {
|
||||
setEditingPoint(null);
|
||||
setPointModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditPoint = (point: GenerationPoint) => {
|
||||
setEditingPoint(point);
|
||||
setPointModalOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pointModalOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (editingPoint) {
|
||||
pointForm.setFieldsValue({
|
||||
prompt: editingPoint.prompt,
|
||||
model_id: editingPoint.model_id,
|
||||
need_ref_file: editingPoint.need_ref_file,
|
||||
remark: editingPoint.remark,
|
||||
});
|
||||
} else {
|
||||
pointForm.resetFields();
|
||||
if (selectionRef.current) {
|
||||
pointForm.setFieldsValue({
|
||||
prompt: `根据上下文生成关于「${selectionRef.current.text.slice(0, 20)}...」的内容`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [pointModalOpen]);
|
||||
|
||||
const handlePointSubmit = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const values = await pointForm.validateFields();
|
||||
const position = selectionRef.current
|
||||
? { start: selectionRef.current.start, end: selectionRef.current.end }
|
||||
: editingPoint?.position;
|
||||
|
||||
if (editingPoint) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (values.prompt) updateData.prompt = values.prompt;
|
||||
if (values.model_id) updateData.model_id = values.model_id;
|
||||
updateData.need_ref_file = values.need_ref_file || false;
|
||||
if (values.remark !== undefined) updateData.remark = values.remark;
|
||||
await pointApi.updateGenerationPoint(editingPoint.id, updateData);
|
||||
message.success("更新成功");
|
||||
} else if (position) {
|
||||
await pointApi.createGenerationPoint({
|
||||
template_id: id,
|
||||
position: JSON.stringify(position),
|
||||
prompt: values.prompt,
|
||||
model_id: values.model_id,
|
||||
order: points.length,
|
||||
selected_text: selectionRef.current?.text,
|
||||
need_ref_file: values.need_ref_file || false,
|
||||
remark: values.remark || "",
|
||||
});
|
||||
message.success("创建成功");
|
||||
selectionRef.current = null;
|
||||
setShowSelectionBtn(false);
|
||||
} else {
|
||||
message.warning("请先在编辑器中选中文本");
|
||||
return;
|
||||
}
|
||||
|
||||
setPointModalOpen(false);
|
||||
const updated = await pointApi.listGenerationPoints(id);
|
||||
setPoints(updated);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) message.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePoint = async (pointId: string) => {
|
||||
try {
|
||||
await pointApi.deleteGenerationPoint(pointId);
|
||||
message.success("删除成功");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) message.error(err.message);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await pointApi.listGenerationPoints(id!);
|
||||
setPoints(updated);
|
||||
} catch {
|
||||
setPoints((prev) => prev.filter((p) => p.id !== pointId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (_e: React.DragEvent, index: number) => {
|
||||
dragItem.current = index;
|
||||
};
|
||||
|
||||
const handleDragEnter = (_e: React.DragEvent, index: number) => {
|
||||
dragOverItem.current = index;
|
||||
};
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
if (dragItem.current === null || dragOverItem.current === null) return;
|
||||
if (dragItem.current === dragOverItem.current) return;
|
||||
|
||||
const newPoints = [...points];
|
||||
const [moved] = newPoints.splice(dragItem.current, 1);
|
||||
newPoints.splice(dragOverItem.current!, 0, moved);
|
||||
|
||||
const reordered = newPoints.map((p, i) => ({ ...p, order: i }));
|
||||
setPoints(reordered);
|
||||
|
||||
try {
|
||||
await pointApi.batchUpdateOrder(
|
||||
reordered.map((p) => ({ id: p.id, order: p.order }))
|
||||
);
|
||||
} catch {
|
||||
message.error("排序更新失败");
|
||||
}
|
||||
|
||||
dragItem.current = null;
|
||||
dragOverItem.current = null;
|
||||
};
|
||||
|
||||
const handleTestPoint = async (pointId: string) => {
|
||||
const point = points.find((p) => p.id === pointId);
|
||||
if (!point) return;
|
||||
|
||||
if (point.need_ref_file) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.multiple = true;
|
||||
fileInput.accept = ".txt,.docx,.pdf";
|
||||
fileInput.onchange = async (e: Event) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (!files || files.length === 0) return;
|
||||
try {
|
||||
message.loading({ content: "测试中...", key: "test" });
|
||||
const formData = new FormData();
|
||||
Array.from(files).forEach((f) => formData.append("ref_files", f));
|
||||
const { default: request } = await import("../api/request");
|
||||
const { data } = await request.post<{ result: string }>(
|
||||
`/generation-points/${pointId}/test`,
|
||||
formData
|
||||
);
|
||||
message.success({ content: "测试完成", key: "test" });
|
||||
Modal.info({ title: "AI 生成结果", content: data.result, width: 600 });
|
||||
} catch {
|
||||
message.error({ content: "测试失败", key: "test" });
|
||||
}
|
||||
};
|
||||
fileInput.click();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
message.loading({ content: "测试中...", key: "test" });
|
||||
const result = await pointApi.testGenerationPoint(pointId);
|
||||
message.success({ content: "测试完成", key: "test" });
|
||||
Modal.info({ title: "AI 生成结果", content: result, width: 600 });
|
||||
} catch {
|
||||
message.error({ content: "测试失败", key: "test" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (!id) return;
|
||||
if (points.length === 0) {
|
||||
message.warning("请先添加至少一个生成点");
|
||||
return;
|
||||
}
|
||||
setGenerateModalOpen(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: 100 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: "calc(100vh - 120px)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/templates")}>
|
||||
返回
|
||||
</Button>
|
||||
<h3 style={{ margin: 0 }}>{templateName}</h3>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
icon={panelCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setPanelCollapsed(!panelCollapsed)}
|
||||
/>
|
||||
<Button icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
生成文档
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Layout style={{ height: "calc(100% - 50px)", background: "#fff" }}>
|
||||
<Content style={{ position: "relative", overflow: "hidden" }}>
|
||||
{showSelectionBtn && (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
zIndex: 1310,
|
||||
top: 0,
|
||||
right: 8,
|
||||
padding: "4px 8px",
|
||||
background: "#fff",
|
||||
borderRadius: "0 0 4px 4px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
}}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreatePoint}
|
||||
>
|
||||
设为 AI 生成点
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Editor
|
||||
key={id}
|
||||
tinymceScriptSrc="/tinymce/tinymce.min.js"
|
||||
initialValue={htmlContent}
|
||||
onInit={handleEditorInit}
|
||||
init={{
|
||||
height: "100%",
|
||||
menubar: true,
|
||||
license_key: "gpl",
|
||||
plugins: [
|
||||
"advlist", "autolink", "lists", "link", "image",
|
||||
"charmap", "preview", "anchor", "searchreplace",
|
||||
"visualblocks", "code", "fullscreen", "insertdatetime",
|
||||
"media", "table", "help", "wordcount",
|
||||
],
|
||||
toolbar:
|
||||
"undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | removeformat | help",
|
||||
content_style: "body { font-family: Arial, sans-serif; font-size: 14px; }",
|
||||
valid_elements: "*[*]",
|
||||
extended_valid_elements: "p[style],h1[style],h2[style],h3[style],h4[style],h5[style],h6[style],div[style],span[style],li[style]",
|
||||
}}
|
||||
/>
|
||||
</Content>
|
||||
{!panelCollapsed && (
|
||||
<Sider width={320} theme="light" style={{ padding: 16, overflow: "auto", borderLeft: "1px solid #f0f0f0" }}>
|
||||
<h4 style={{ marginBottom: 12 }}>生成点列表 ({points.length})</h4>
|
||||
{points.map((point, index) => {
|
||||
const model = models.find((m) => m.id === point.model_id);
|
||||
return (
|
||||
<div
|
||||
key={point.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnter={(e) => handleDragEnter(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
style={{ marginBottom: 8, cursor: "grab" }}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space size={4}>
|
||||
<MenuOutlined style={{ color: "#bbb", fontSize: 12, cursor: "grab" }} />
|
||||
<span style={{ fontSize: 12, color: "#999" }}>#{index + 1}</span>
|
||||
{model && (
|
||||
<span style={{ fontSize: 12, color: "#1677ff" }}>
|
||||
{model.name}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
{point.need_ref_file && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<PaperClipOutlined />}
|
||||
onClick={async () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = ".txt,.docx,.pdf";
|
||||
input.onchange = async (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (!files?.length) return;
|
||||
const fd = new FormData();
|
||||
Array.from(files).forEach((f) => fd.append("ref_files", f));
|
||||
await request.post(`/generation-points/${point.id}/upload-ref`, fd);
|
||||
message.success("上传成功");
|
||||
const updated = await pointApi.listGenerationPoints(id!);
|
||||
setPoints(updated);
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ExperimentOutlined />}
|
||||
onClick={() => handleTestPoint(point.id)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditPoint(point)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除?"
|
||||
onConfirm={() => handleDeletePoint(point.id)}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<p style={{ fontSize: 12, color: "#666", margin: 0, wordBreak: "break-all" }}>
|
||||
{point.prompt.slice(0, 80)}
|
||||
{point.prompt.length > 80 && "..."}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{points.length === 0 && (
|
||||
<p style={{ color: "#999", fontSize: 13 }}>
|
||||
选中编辑器中的文本,点击"设为 AI 生成点"
|
||||
</p>
|
||||
)}
|
||||
</Sider>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
<Modal
|
||||
title={editingPoint ? "编辑生成点" : "新建生成点"}
|
||||
open={pointModalOpen}
|
||||
onCancel={() => { setPointModalOpen(false); pointForm.resetFields(); }}
|
||||
onOk={handlePointSubmit}
|
||||
width={560}
|
||||
>
|
||||
<Form form={pointForm} layout="vertical">
|
||||
{selectionRef.current && !editingPoint && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: 8,
|
||||
background: "#f6f8fa",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
已选中: 「{selectionRef.current.text.slice(0, 50)}
|
||||
{selectionRef.current.text.length > 50 ? "..." : ""}」
|
||||
</div>
|
||||
)}
|
||||
<Form.Item name="prompt" label="提示词" rules={[{ required: true }]}>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder="请输入 AI 生成提示词,例如:请根据参考文件内容,生成一段项目背景介绍..."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="model_id" label="AI 模型">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="留空则使用全局默认模型"
|
||||
options={models.map((m) => ({ label: m.name, value: m.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="need_ref_file" label="需要参考文件" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="补充说明(可选)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<GenerateModal
|
||||
open={generateModalOpen}
|
||||
onClose={async () => {
|
||||
setGenerateModalOpen(false);
|
||||
try {
|
||||
const updated = await pointApi.listGenerationPoints(id!);
|
||||
setPoints(updated);
|
||||
} catch {}
|
||||
}}
|
||||
templateId={id || ""}
|
||||
points={points}
|
||||
models={models}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Table, Button, Upload, Space, message, Popconfirm } from "antd";
|
||||
import { EditOutlined, DeleteOutlined, DownloadOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { TemplateListItem } from "../types";
|
||||
import * as templateApi from "../api/templates";
|
||||
|
||||
export default function TemplateList() {
|
||||
const navigate = useNavigate();
|
||||
const [templates, setTemplates] = useState<TemplateListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await templateApi.listTemplates();
|
||||
setTemplates(data);
|
||||
} catch {
|
||||
message.error("获取模板列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, [fetchTemplates, refresh]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await templateApi.deleteTemplate(id);
|
||||
message.success("删除成功");
|
||||
setRefresh((r) => r + 1);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: "模板名称", dataIndex: "name", key: "name" },
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 240,
|
||||
render: (_: unknown, record: TemplateListItem) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/editor/${record.id}`)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => window.open(templateApi.getDownloadUrl(record.id))}
|
||||
>
|
||||
DOCX
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => window.open(templateApi.getDownloadUrl(record.id, "pdf"))}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
<Popconfirm title="确定删除此模板?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
|
||||
<h2>模板管理</h2>
|
||||
<Upload
|
||||
accept=".docx"
|
||||
showUploadList={false}
|
||||
customRequest={async ({ file, onSuccess, onError }: any) => {
|
||||
try {
|
||||
await templateApi.uploadTemplate(file as File);
|
||||
message.success("上传成功");
|
||||
setRefresh((r) => r + 1);
|
||||
onSuccess?.("ok");
|
||||
} catch {
|
||||
message.error("上传失败");
|
||||
onError?.(new Error("上传失败"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
上传模板
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={templates}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface AIModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
endpoint: string;
|
||||
api_key: string;
|
||||
extra_params: Record<string, unknown>;
|
||||
is_enabled: boolean;
|
||||
remark: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
file_path: string;
|
||||
html_content: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TemplateListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GenerationPoint {
|
||||
id: string;
|
||||
template_id: string;
|
||||
position: { start: number; end: number };
|
||||
prompt: string;
|
||||
model_id: string | null;
|
||||
ref_file_path: string | null;
|
||||
need_ref_file: boolean;
|
||||
remark: string | null;
|
||||
order: number;
|
||||
selected_text: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GenerationTask {
|
||||
id: string;
|
||||
template_id: string;
|
||||
template_name?: string;
|
||||
status: "pending" | "processing" | "done" | "failed";
|
||||
result_file_path: string | null;
|
||||
error_msg: string | null;
|
||||
created_at: string;
|
||||
finished_at: string | null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libreoffice libreoffice-writer fonts-noto-cjk fonts-dejavu fontconfig \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
COPY converter.py /app/converter.py
|
||||
COPY demo /app/demo
|
||||
|
||||
ENTRYPOINT ["python", "/app/converter.py"]
|
||||
@@ -0,0 +1,193 @@
|
||||
# Word ↔ HTML High-Fidelity Converter
|
||||
|
||||
一个基于开源工具的 `.docx` 与 `.html` 双向转换程序,目标是尽量保持 Word 文档在浏览器和再次转回 Word 后的视觉效果一致。
|
||||
|
||||
> 现实说明:DOCX 和 HTML/CSS 的排版模型不同,严格意义上的“完全无损双向转换”不能只靠普通 HTML 实现。本项目采用 LibreOffice Writer 的转换引擎做高保真转换,再做 HTML/CSS 后处理;同时提供 PDF 渲染级视觉对比报告,便于验收。
|
||||
|
||||
## 功能
|
||||
|
||||
- DOCX → HTML
|
||||
- 保留字体、字号、颜色、粗体、斜体、下划线、删除线
|
||||
- 保留段落对齐、缩进、行距、页边距、分页符
|
||||
- 保留表格结构、边框、合并单元格、部分背景色样式
|
||||
- 保留图片、大小、位置、页眉页脚文本
|
||||
- 将 `<style>` 中的规则尽量内联到元素 `style` 中,降低浏览器打开时样式丢失概率
|
||||
|
||||
- HTML → DOCX
|
||||
- 使用 LibreOffice 的 Writer HTML 导入器,解析 HTML/CSS 并导出 DOCX
|
||||
- 对本程序生成的 HTML 回转 DOCX,视觉一致性更好
|
||||
|
||||
- 验收校验
|
||||
- DOCX → PDF 渲染
|
||||
- 将原始 DOCX 与回转 DOCX 的 PDF 页面转图片后做像素差异报告
|
||||
|
||||
## 依赖
|
||||
|
||||
### 1. 安装 LibreOffice
|
||||
|
||||
Linux 示例:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libreoffice libreoffice-writer fonts-noto-cjk fontconfig
|
||||
```
|
||||
|
||||
macOS:安装 LibreOffice 后,通常路径为:
|
||||
|
||||
```bash
|
||||
/Applications/LibreOffice.app/Contents/MacOS/soffice
|
||||
```
|
||||
|
||||
Windows:安装 LibreOffice 后,通常路径为:
|
||||
|
||||
```powershell
|
||||
C:\Program Files\LibreOffice\program\soffice.com
|
||||
```
|
||||
|
||||
如程序找不到 LibreOffice,可以设置:
|
||||
|
||||
```bash
|
||||
export SOFFICE_BIN=/path/to/soffice
|
||||
```
|
||||
|
||||
### 2. 安装 Python 依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 快速使用
|
||||
|
||||
### DOCX 转 HTML
|
||||
|
||||
```bash
|
||||
python converter.py docx2html demo/demo.docx output/demo.html
|
||||
```
|
||||
|
||||
### HTML 转 DOCX
|
||||
|
||||
```bash
|
||||
python converter.py html2docx output/demo2.html output/demo2.back.docx
|
||||
```
|
||||
|
||||
### 一键往返转换
|
||||
|
||||
```bash
|
||||
python converter.py roundtrip demo/demo.docx output/roundtrip
|
||||
```
|
||||
|
||||
### 一键往返并生成视觉差异报告
|
||||
|
||||
```bash
|
||||
python converter.py roundtrip demo/demo.docx output/roundtrip --verify
|
||||
```
|
||||
|
||||
生成文件示例:
|
||||
|
||||
```text
|
||||
output/roundtrip/demo.html
|
||||
output/roundtrip/demo.roundtrip.docx
|
||||
output/roundtrip/demo.visual-report.json
|
||||
```
|
||||
|
||||
视觉报告字段说明:
|
||||
|
||||
```json
|
||||
{
|
||||
"exact_page_count": true,
|
||||
"page_reports": [
|
||||
{
|
||||
"page": 1,
|
||||
"same_size": true,
|
||||
"mean_abs_diff_0_255": 4.2738
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `exact_page_count=true`:页数一致
|
||||
- `same_size=true`:渲染页面尺寸一致
|
||||
- `mean_abs_diff_0_255`:平均像素差异,越低越接近;复杂文档可设自己的验收阈值
|
||||
|
||||
## 生成测试 DOCX
|
||||
|
||||
项目里已经包含 `demo/demo.docx`。如果要重新生成:
|
||||
|
||||
```bash
|
||||
python demo/make_demo_docx.py
|
||||
```
|
||||
|
||||
## Docker 使用
|
||||
|
||||
构建镜像:
|
||||
|
||||
```bash
|
||||
docker build -t word-html-converter .
|
||||
```
|
||||
|
||||
DOCX 转 HTML:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD:/work" word-html-converter \
|
||||
docx2html /work/demo/demo.docx /work/output/demo.html
|
||||
```
|
||||
|
||||
HTML 转 DOCX:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD:/work" word-html-converter \
|
||||
html2docx /work/output/demo.html /work/output/demo.back.docx
|
||||
```
|
||||
|
||||
## 关键设计说明
|
||||
|
||||
### 为什么不用 Mammoth 或 Pandoc 作为主引擎?
|
||||
|
||||
- Mammoth 更适合把 DOCX 转成语义清晰的 HTML,它明确不是为了逐像素复制 Word 样式。
|
||||
- Pandoc 很适合文档格式互转,但更偏结构化内容转换,不适合要求高度还原 Word 页面排版的场景。
|
||||
- LibreOffice Writer 的 DOCX/HTML 导入导出更接近真实办公软件排版结果,所以本项目把它作为主转换内核。
|
||||
|
||||
### 为什么 HTML 转 DOCX 要强制 `HTML (StarWriter)` 输入过滤器?
|
||||
|
||||
LibreOffice 默认可能把 HTML 当作 Web 文档打开,导致导出 DOCX 时出现“no export filter”或排版丢失。本项目使用:
|
||||
|
||||
```bash
|
||||
--infilter="HTML (StarWriter)"
|
||||
```
|
||||
|
||||
让 HTML 作为 Writer 文档导入,再导出 Office Open XML DOCX。
|
||||
|
||||
### 可选归档模式
|
||||
|
||||
DOCX 转 HTML 时可以加:
|
||||
|
||||
```bash
|
||||
python converter.py docx2html input.docx output.html --embed-source
|
||||
```
|
||||
|
||||
这样会把原始 DOCX 以 base64 形式嵌入 HTML。HTML 转 DOCX 时可加:
|
||||
|
||||
```bash
|
||||
python converter.py html2docx output.html restored.docx --prefer-embedded-source
|
||||
```
|
||||
|
||||
这适合“HTML 只用于预览/存档,希望完全恢复原始 DOCX”的场景。注意:如果用户在 HTML 中编辑了内容,使用该模式会恢复原 DOCX,不会合并 HTML 编辑内容。
|
||||
|
||||
## 已知边界
|
||||
|
||||
以下内容在开源转换链路中很难保证完全一致,需要单独测试:
|
||||
|
||||
- 复杂浮动图片、环绕方式、文本框、艺术字、SmartArt
|
||||
- Word 域、目录、脚注尾注、批注、修订痕迹
|
||||
- 复杂多级编号、样式继承、主题字体
|
||||
- 页面级精确排版,如不同 Word/LibreOffice 版本的字体度量差异
|
||||
- 浏览器编辑 HTML 后再转 DOCX,不能保证所有 CSS 都能被 Writer 完整识别
|
||||
|
||||
## 推荐验收标准
|
||||
|
||||
建议不要用“字节级相同”验收 DOCX,因为二次生成的 DOCX 内部 XML 顺序、关系 ID、压缩结果通常会变。建议用:
|
||||
|
||||
1. 页数一致;
|
||||
2. 关键表格行列、合并单元格、图片数量一致;
|
||||
3. PDF 渲染视觉差异低于业务阈值;
|
||||
4. 典型复杂样例人工抽检。
|
||||
Executable
+596
@@ -0,0 +1,596 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
word-html-converter
|
||||
Open-source DOCX <-> HTML conversion wrapper focused on high visual fidelity.
|
||||
|
||||
Core engine: LibreOffice headless conversion.
|
||||
HTML post-processing: inline CSS, keep @page rules, normalize metadata.
|
||||
|
||||
Commands:
|
||||
python converter.py docx2html input.docx output.html
|
||||
python converter.py html2docx input.html output.docx
|
||||
python converter.py roundtrip input.docx output_dir
|
||||
python converter.py verify original.docx converted.docx report.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import warnings
|
||||
import tempfile
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
|
||||
import tinycss2
|
||||
|
||||
|
||||
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||
|
||||
APP_NAME = "word-html-converter"
|
||||
DEFAULT_DOCX_TO_HTML_FILTERS = [
|
||||
# Writer/Web HTML filter preserves more page/header/footer information than the clean XHTML filter.
|
||||
"html",
|
||||
# Fallback clean XHTML Writer filter.
|
||||
"html:XHTML Writer File:UTF8",
|
||||
]
|
||||
DEFAULT_HTML_TO_DOCX_FILTERS = [
|
||||
'docx:"Office Open XML Text"',
|
||||
"docx",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertResult:
|
||||
source: str
|
||||
output: str
|
||||
command: List[str]
|
||||
stdout: str
|
||||
stderr: str
|
||||
filter_name: str
|
||||
|
||||
|
||||
class ConversionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def find_soffice(explicit: Optional[str] = None) -> str:
|
||||
"""Find LibreOffice/soffice executable on Linux/macOS/Windows."""
|
||||
candidates: List[str] = []
|
||||
if explicit:
|
||||
candidates.append(explicit)
|
||||
if os.environ.get("SOFFICE_BIN"):
|
||||
candidates.append(os.environ["SOFFICE_BIN"])
|
||||
|
||||
candidates.extend([
|
||||
"soffice",
|
||||
"libreoffice",
|
||||
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
|
||||
r"C:\Program Files\LibreOffice\program\soffice.com",
|
||||
r"C:\Program Files\LibreOffice\program\soffice.exe",
|
||||
r"C:\Program Files (x86)\LibreOffice\program\soffice.com",
|
||||
r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
|
||||
])
|
||||
for c in candidates:
|
||||
if not c:
|
||||
continue
|
||||
if Path(c).exists():
|
||||
return str(Path(c))
|
||||
resolved = shutil.which(c)
|
||||
if resolved:
|
||||
return resolved
|
||||
raise ConversionError(
|
||||
"LibreOffice executable not found. Install LibreOffice, or set SOFFICE_BIN=/path/to/soffice."
|
||||
)
|
||||
|
||||
|
||||
def file_uri(path: Path) -> str:
|
||||
return path.resolve().as_uri()
|
||||
|
||||
|
||||
def run_soffice_convert(
|
||||
source: Path,
|
||||
outdir: Path,
|
||||
convert_to: str,
|
||||
soffice_bin: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
input_filter: Optional[str] = None,
|
||||
) -> Tuple[subprocess.CompletedProcess[str], List[str]]:
|
||||
source = source.resolve()
|
||||
outdir = outdir.resolve()
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
soffice = find_soffice(soffice_bin)
|
||||
|
||||
# Use an isolated LO user profile so conversion works even if desktop LO is open.
|
||||
profile_dir = Path(tempfile.mkdtemp(prefix="lo-profile-"))
|
||||
profile_uri = file_uri(profile_dir)
|
||||
cmd = [
|
||||
soffice,
|
||||
f"-env:UserInstallation={profile_uri}",
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--nofirststartwizard",
|
||||
"--nodefault",
|
||||
"--nolockcheck",
|
||||
"--norestore",
|
||||
]
|
||||
if input_filter:
|
||||
cmd.append(f"--infilter={input_filter}")
|
||||
cmd.extend([
|
||||
"--convert-to",
|
||||
convert_to,
|
||||
"--outdir",
|
||||
str(outdir),
|
||||
str(source),
|
||||
])
|
||||
try:
|
||||
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
||||
finally:
|
||||
shutil.rmtree(profile_dir, ignore_errors=True)
|
||||
return proc, cmd
|
||||
|
||||
|
||||
def find_converted_file(before: set[Path], outdir: Path, source_stem: str, expected_ext: str) -> Optional[Path]:
|
||||
expected = outdir / f"{source_stem}{expected_ext}"
|
||||
if expected.exists():
|
||||
return expected
|
||||
after = set(outdir.iterdir())
|
||||
created = [p for p in after - before if p.is_file() and p.suffix.lower() == expected_ext.lower()]
|
||||
if created:
|
||||
return max(created, key=lambda p: p.stat().st_mtime)
|
||||
candidates = list(outdir.glob(f"{source_stem}*{expected_ext}"))
|
||||
if candidates:
|
||||
return max(candidates, key=lambda p: p.stat().st_mtime)
|
||||
return None
|
||||
|
||||
|
||||
def convert_with_fallbacks(
|
||||
source: Path,
|
||||
output: Path,
|
||||
filters: Iterable[str],
|
||||
expected_ext: str,
|
||||
soffice_bin: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
input_filter: Optional[str] = None,
|
||||
) -> ConvertResult:
|
||||
source = source.resolve()
|
||||
output = output.resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
last_error = ""
|
||||
with tempfile.TemporaryDirectory(prefix="convert-work-") as tmp:
|
||||
tmpdir = Path(tmp)
|
||||
for filter_name in filters:
|
||||
before = set(tmpdir.iterdir())
|
||||
proc, cmd = run_soffice_convert(source, tmpdir, filter_name, soffice_bin, timeout, input_filter=input_filter)
|
||||
converted = find_converted_file(before, tmpdir, source.stem, expected_ext)
|
||||
if proc.returncode == 0 and converted and converted.exists():
|
||||
if output.exists():
|
||||
output.unlink()
|
||||
shutil.move(str(converted), str(output))
|
||||
# Move sidecar image/assets generated by HTML export.
|
||||
for item in tmpdir.iterdir():
|
||||
if item.is_file() and item.name != output.name:
|
||||
dest = output.parent / item.name
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
shutil.move(str(item), str(dest))
|
||||
return ConvertResult(
|
||||
source=str(source),
|
||||
output=str(output),
|
||||
command=cmd,
|
||||
stdout=proc.stdout,
|
||||
stderr=proc.stderr,
|
||||
filter_name=filter_name,
|
||||
)
|
||||
last_error = (
|
||||
f"Filter failed: {filter_name}\n"
|
||||
f"Return code: {proc.returncode}\nSTDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}"
|
||||
)
|
||||
raise ConversionError(last_error or "No LibreOffice conversion filter succeeded.")
|
||||
|
||||
|
||||
def style_to_dict(style: str) -> Dict[str, str]:
|
||||
result: Dict[str, str] = {}
|
||||
if not style:
|
||||
return result
|
||||
declarations = tinycss2.parse_declaration_list(style, skip_comments=True, skip_whitespace=True)
|
||||
for d in declarations:
|
||||
if getattr(d, "type", None) == "declaration" and not d.name.startswith("--"):
|
||||
value = tinycss2.serialize(d.value).strip()
|
||||
if d.important:
|
||||
value = f"{value} !important"
|
||||
result[d.name.lower()] = value
|
||||
return result
|
||||
|
||||
|
||||
def dict_to_style(d: Dict[str, str]) -> str:
|
||||
return "; ".join(f"{k}: {v}" for k, v in d.items() if v) + (";" if d else "")
|
||||
|
||||
|
||||
def merge_inline_style(tag, declarations: Dict[str, str]) -> None:
|
||||
current = style_to_dict(tag.get("style", ""))
|
||||
current.update(declarations)
|
||||
tag["style"] = dict_to_style(current)
|
||||
|
||||
|
||||
def parse_css_rules(css: str) -> Tuple[List[Tuple[str, Dict[str, str]]], str]:
|
||||
"""Return normal CSS rules plus preserved at-rules such as @page/@media."""
|
||||
normal_rules: List[Tuple[str, Dict[str, str]]] = []
|
||||
preserved_at_rules: List[str] = []
|
||||
rules = tinycss2.parse_stylesheet(css, skip_comments=True, skip_whitespace=True)
|
||||
for rule in rules:
|
||||
if rule.type == "qualified-rule":
|
||||
selector = tinycss2.serialize(rule.prelude).strip()
|
||||
declarations: Dict[str, str] = {}
|
||||
for d in tinycss2.parse_declaration_list(rule.content, skip_comments=True, skip_whitespace=True):
|
||||
if getattr(d, "type", None) == "declaration":
|
||||
value = tinycss2.serialize(d.value).strip()
|
||||
if d.important:
|
||||
value = f"{value} !important"
|
||||
declarations[d.name.lower()] = value
|
||||
if selector and declarations:
|
||||
normal_rules.append((selector, declarations))
|
||||
elif rule.type == "at-rule":
|
||||
# @page is important for print/page-like preview; @media may contain print rules.
|
||||
preserved_at_rules.append(tinycss2.serialize([rule]).strip())
|
||||
return normal_rules, "\n".join(preserved_at_rules)
|
||||
|
||||
|
||||
def inline_css(html: str, keep_style_tag: bool = True) -> str:
|
||||
"""Inline style rules from <style> tags. This is intentionally conservative.
|
||||
|
||||
It handles most simple selectors generated by LibreOffice. Unsupported complex selectors
|
||||
are skipped rather than breaking conversion.
|
||||
"""
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
style_tags = soup.find_all("style")
|
||||
preserved_css: List[str] = []
|
||||
|
||||
for st in style_tags:
|
||||
css = st.string or st.get_text() or ""
|
||||
normal_rules, at_rules = parse_css_rules(css)
|
||||
if at_rules:
|
||||
preserved_css.append(at_rules)
|
||||
for selector, declarations in normal_rules:
|
||||
selectors = [s.strip() for s in selector.split(",") if s.strip()]
|
||||
for sel in selectors:
|
||||
# Browser-only pseudo-selectors do not make sense for DOCX reconstruction.
|
||||
if ":" in sel and not re.search(r":(first-child|last-child|nth-child)", sel):
|
||||
continue
|
||||
try:
|
||||
matches = soup.select(sel)
|
||||
except Exception:
|
||||
continue
|
||||
for tag in matches:
|
||||
merge_inline_style(tag, declarations)
|
||||
if not keep_style_tag:
|
||||
st.decompose()
|
||||
|
||||
if keep_style_tag:
|
||||
head = soup.head or soup.new_tag("head")
|
||||
if not soup.head and soup.html:
|
||||
soup.html.insert(0, head)
|
||||
if preserved_css:
|
||||
new_style = soup.new_tag("style")
|
||||
new_style.string = "\n".join(preserved_css)
|
||||
head.append(new_style)
|
||||
|
||||
return str(soup)
|
||||
|
||||
|
||||
def ensure_html_metadata(html_path: Path, source_docx: Optional[Path] = None) -> None:
|
||||
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
if soup.html is None:
|
||||
new_html = soup.new_tag("html")
|
||||
new_html.extend(soup.contents)
|
||||
soup.append(new_html)
|
||||
if soup.head is None:
|
||||
head = soup.new_tag("head")
|
||||
soup.html.insert(0, head)
|
||||
if not soup.head.find("meta", attrs={"charset": True}):
|
||||
meta = soup.new_tag("meta", charset="utf-8")
|
||||
soup.head.insert(0, meta)
|
||||
if not soup.head.find("meta", attrs={"name": "generator"}):
|
||||
meta = soup.new_tag("meta")
|
||||
meta["name"] = "generator"
|
||||
meta["content"] = f"{APP_NAME}; LibreOffice headless"
|
||||
soup.head.append(meta)
|
||||
if source_docx and not soup.head.find("meta", attrs={"name": "docx-source-sha256"}):
|
||||
meta = soup.new_tag("meta")
|
||||
meta["name"] = "docx-source-sha256"
|
||||
meta["content"] = sha256_file(source_docx)
|
||||
soup.head.append(meta)
|
||||
html_path.write_text(str(soup), encoding="utf-8")
|
||||
|
||||
|
||||
def embed_source_docx(html_path: Path, source_docx: Path) -> None:
|
||||
"""Optional: embed original docx for audit/archival round-trip fallback.
|
||||
|
||||
This does NOT merge arbitrary HTML edits back into the original docx. It is useful for
|
||||
proving byte-exact preservation when the HTML is only used for preview/storage.
|
||||
"""
|
||||
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
if soup.body is None:
|
||||
body = soup.new_tag("body")
|
||||
if soup.html:
|
||||
soup.html.append(body)
|
||||
else:
|
||||
soup.append(body)
|
||||
old = soup.find(id="__source_docx_base64__")
|
||||
if old:
|
||||
old.decompose()
|
||||
script = soup.new_tag("script")
|
||||
script["type"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document+base64"
|
||||
script["id"] = "__source_docx_base64__"
|
||||
script.string = base64.b64encode(source_docx.read_bytes()).decode("ascii")
|
||||
soup.body.append(script)
|
||||
html_path.write_text(str(soup), encoding="utf-8")
|
||||
|
||||
|
||||
def restore_embedded_docx_if_present(html_path: Path, output_docx: Path) -> bool:
|
||||
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
script = soup.find(id="__source_docx_base64__")
|
||||
if not script or not script.string:
|
||||
return False
|
||||
output_docx.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_docx.write_bytes(base64.b64decode(script.string.strip()))
|
||||
return True
|
||||
|
||||
|
||||
def docx_to_html(
|
||||
source_docx: Path,
|
||||
output_html: Path,
|
||||
soffice_bin: Optional[str] = None,
|
||||
inline: bool = True,
|
||||
embed_source: bool = False,
|
||||
timeout: int = 120,
|
||||
) -> ConvertResult:
|
||||
if source_docx.suffix.lower() != ".docx":
|
||||
raise ConversionError("Input file must be .docx")
|
||||
result = convert_with_fallbacks(
|
||||
source=source_docx,
|
||||
output=output_html,
|
||||
filters=DEFAULT_DOCX_TO_HTML_FILTERS,
|
||||
expected_ext=".html",
|
||||
soffice_bin=soffice_bin,
|
||||
timeout=timeout,
|
||||
)
|
||||
ensure_html_metadata(output_html, source_docx)
|
||||
if inline:
|
||||
html = output_html.read_text(encoding="utf-8", errors="replace")
|
||||
output_html.write_text(inline_css(html, keep_style_tag=True), encoding="utf-8")
|
||||
if embed_source:
|
||||
embed_source_docx(output_html, source_docx)
|
||||
return result
|
||||
|
||||
|
||||
def html_to_docx(
|
||||
source_html: Path,
|
||||
output_docx: Path,
|
||||
soffice_bin: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
prefer_embedded_source: bool = False,
|
||||
) -> ConvertResult:
|
||||
if source_html.suffix.lower() not in {".html", ".htm", ".xhtml"}:
|
||||
raise ConversionError("Input file must be .html/.htm/.xhtml")
|
||||
if prefer_embedded_source and restore_embedded_docx_if_present(source_html, output_docx):
|
||||
return ConvertResult(
|
||||
source=str(source_html.resolve()),
|
||||
output=str(output_docx.resolve()),
|
||||
command=["restore-embedded-docx"],
|
||||
stdout="Restored embedded source DOCX.",
|
||||
stderr="",
|
||||
filter_name="embedded-docx",
|
||||
)
|
||||
return convert_with_fallbacks(
|
||||
source=source_html,
|
||||
output=output_docx,
|
||||
filters=DEFAULT_HTML_TO_DOCX_FILTERS,
|
||||
expected_ext=".docx",
|
||||
soffice_bin=soffice_bin,
|
||||
timeout=timeout,
|
||||
input_filter="HTML (StarWriter)",
|
||||
)
|
||||
|
||||
|
||||
def convert_to_pdf(source: Path, output_pdf: Path, soffice_bin: Optional[str] = None, timeout: int = 120) -> ConvertResult:
|
||||
return convert_with_fallbacks(
|
||||
source=source,
|
||||
output=output_pdf,
|
||||
filters=['pdf:"writer_pdf_Export"', "pdf"],
|
||||
expected_ext=".pdf",
|
||||
soffice_bin=soffice_bin,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def verify_docx_visual_similarity(
|
||||
original_docx: Path,
|
||||
converted_docx: Path,
|
||||
report_json: Path,
|
||||
soffice_bin: Optional[str] = None,
|
||||
dpi: int = 120,
|
||||
timeout: int = 120,
|
||||
) -> Dict[str, object]:
|
||||
"""Render two DOCX files to PDF, then compare page images.
|
||||
|
||||
Requires PyMuPDF and Pillow. The score is pragmatic, not a formal proof.
|
||||
exact_page_count=true and low mean_abs_diff are good signs.
|
||||
"""
|
||||
try:
|
||||
import fitz # type: ignore
|
||||
from PIL import ImageChops, ImageStat # type: ignore
|
||||
except Exception as exc:
|
||||
raise ConversionError("verify requires pymupdf and pillow: pip install pymupdf pillow") from exc
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="verify-") as tmp:
|
||||
tmpdir = Path(tmp)
|
||||
pdf1 = tmpdir / "original.pdf"
|
||||
pdf2 = tmpdir / "converted.pdf"
|
||||
convert_to_pdf(original_docx, pdf1, soffice_bin, timeout)
|
||||
convert_to_pdf(converted_docx, pdf2, soffice_bin, timeout)
|
||||
|
||||
doc1 = fitz.open(str(pdf1))
|
||||
doc2 = fitz.open(str(pdf2))
|
||||
pages = min(len(doc1), len(doc2))
|
||||
page_reports: List[Dict[str, object]] = []
|
||||
zoom = dpi / 72.0
|
||||
matrix = fitz.Matrix(zoom, zoom)
|
||||
for i in range(pages):
|
||||
p1 = doc1.load_page(i).get_pixmap(matrix=matrix, alpha=False)
|
||||
p2 = doc2.load_page(i).get_pixmap(matrix=matrix, alpha=False)
|
||||
img1 = p1.pil_image()
|
||||
img2 = p2.pil_image()
|
||||
same_size = img1.size == img2.size
|
||||
if not same_size:
|
||||
# Compare common area, record size mismatch.
|
||||
w = min(img1.width, img2.width)
|
||||
h = min(img1.height, img2.height)
|
||||
img1 = img1.crop((0, 0, w, h))
|
||||
img2 = img2.crop((0, 0, w, h))
|
||||
diff = ImageChops.difference(img1, img2)
|
||||
stat = ImageStat.Stat(diff)
|
||||
mean_abs_diff = sum(stat.mean) / len(stat.mean)
|
||||
page_reports.append({
|
||||
"page": i + 1,
|
||||
"same_size": same_size,
|
||||
"mean_abs_diff_0_255": round(mean_abs_diff, 4),
|
||||
})
|
||||
|
||||
report: Dict[str, object] = {
|
||||
"original": str(original_docx.resolve()),
|
||||
"converted": str(converted_docx.resolve()),
|
||||
"original_sha256": sha256_file(original_docx),
|
||||
"converted_sha256": sha256_file(converted_docx),
|
||||
"original_pages": len(doc1),
|
||||
"converted_pages": len(doc2),
|
||||
"exact_page_count": len(doc1) == len(doc2),
|
||||
"dpi": dpi,
|
||||
"pages_compared": pages,
|
||||
"page_reports": page_reports,
|
||||
}
|
||||
report_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
def cmd_docx2html(args: argparse.Namespace) -> None:
|
||||
r = docx_to_html(
|
||||
Path(args.input),
|
||||
Path(args.output),
|
||||
soffice_bin=args.soffice,
|
||||
inline=not args.no_inline_css,
|
||||
embed_source=args.embed_source,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
print(json.dumps(asdict(r), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def cmd_html2docx(args: argparse.Namespace) -> None:
|
||||
r = html_to_docx(
|
||||
Path(args.input),
|
||||
Path(args.output),
|
||||
soffice_bin=args.soffice,
|
||||
timeout=args.timeout,
|
||||
prefer_embedded_source=args.prefer_embedded_source,
|
||||
)
|
||||
print(json.dumps(asdict(r), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def cmd_roundtrip(args: argparse.Namespace) -> None:
|
||||
src = Path(args.input).resolve()
|
||||
outdir = Path(args.output_dir).resolve()
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
html = outdir / f"{src.stem}.html"
|
||||
docx_back = outdir / f"{src.stem}.roundtrip.docx"
|
||||
report = outdir / f"{src.stem}.visual-report.json"
|
||||
r1 = docx_to_html(src, html, args.soffice, inline=not args.no_inline_css, embed_source=args.embed_source, timeout=args.timeout)
|
||||
r2 = html_to_docx(html, docx_back, args.soffice, timeout=args.timeout, prefer_embedded_source=args.prefer_embedded_source)
|
||||
payload = {"docx2html": asdict(r1), "html2docx": asdict(r2)}
|
||||
if args.verify:
|
||||
payload["verify"] = verify_docx_visual_similarity(src, docx_back, report, args.soffice, args.dpi, args.timeout)
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def cmd_verify(args: argparse.Namespace) -> None:
|
||||
report = verify_docx_visual_similarity(
|
||||
Path(args.original),
|
||||
Path(args.converted),
|
||||
Path(args.report),
|
||||
soffice_bin=args.soffice,
|
||||
dpi=args.dpi,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description="Bidirectional high-fidelity DOCX <-> HTML converter using open-source tools.")
|
||||
p.add_argument("--soffice", default=None, help="Path to LibreOffice soffice. Can also use SOFFICE_BIN env var.")
|
||||
p.add_argument("--timeout", type=int, default=120, help="Conversion timeout seconds. Default: 120")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
a = sub.add_parser("docx2html", help="Convert DOCX to HTML")
|
||||
a.add_argument("input")
|
||||
a.add_argument("output")
|
||||
a.add_argument("--no-inline-css", action="store_true", help="Do not inline CSS from <style> tags")
|
||||
a.add_argument("--embed-source", action="store_true", help="Embed original DOCX in HTML as base64 for archival fallback")
|
||||
a.set_defaults(func=cmd_docx2html)
|
||||
|
||||
a = sub.add_parser("html2docx", help="Convert HTML to DOCX")
|
||||
a.add_argument("input")
|
||||
a.add_argument("output")
|
||||
a.add_argument("--prefer-embedded-source", action="store_true", help="If HTML contains embedded original DOCX, restore it instead of converting")
|
||||
a.set_defaults(func=cmd_html2docx)
|
||||
|
||||
a = sub.add_parser("roundtrip", help="DOCX -> HTML -> DOCX")
|
||||
a.add_argument("input")
|
||||
a.add_argument("output_dir")
|
||||
a.add_argument("--no-inline-css", action="store_true")
|
||||
a.add_argument("--embed-source", action="store_true")
|
||||
a.add_argument("--prefer-embedded-source", action="store_true")
|
||||
a.add_argument("--verify", action="store_true", help="Generate PDF-render visual diff report")
|
||||
a.add_argument("--dpi", type=int, default=120)
|
||||
a.set_defaults(func=cmd_roundtrip)
|
||||
|
||||
a = sub.add_parser("verify", help="Render two DOCX files to PDF and compare page images")
|
||||
a.add_argument("original")
|
||||
a.add_argument("converted")
|
||||
a.add_argument("report")
|
||||
a.add_argument("--dpi", type=int, default=120)
|
||||
a.set_defaults(func=cmd_verify)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
args.func(args)
|
||||
return 0
|
||||
except ConversionError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
from pathlib import Path
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
out_dir = Path(__file__).resolve().parent
|
||||
img_path = out_dir / "demo-image.png"
|
||||
image = Image.new("RGB", (420, 160), "white")
|
||||
d = ImageDraw.Draw(image)
|
||||
d.rectangle([10, 10, 410, 150], outline="black", width=2)
|
||||
d.text((30, 60), "DOCX embedded image", fill="black")
|
||||
image.save(img_path)
|
||||
|
||||
doc = Document()
|
||||
section = doc.sections[0]
|
||||
section.top_margin = Cm(2)
|
||||
section.bottom_margin = Cm(2)
|
||||
section.left_margin = Cm(2)
|
||||
section.right_margin = Cm(2)
|
||||
section.header.paragraphs[0].text = "页眉:Word/HTML 双向转换测试"
|
||||
section.footer.paragraphs[0].text = "页脚:保留页眉页脚测试"
|
||||
|
||||
style = doc.styles["Normal"]
|
||||
style.font.name = "Microsoft YaHei"
|
||||
style.font.size = Pt(12)
|
||||
|
||||
h = doc.add_heading("Word 转 HTML 测试文件", level=1)
|
||||
h.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.first_line_indent = Cm(0.74)
|
||||
p.paragraph_format.line_spacing = 1.5
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
r = p.add_run("这是一段测试文本:")
|
||||
r.font.size = Pt(12)
|
||||
r.font.color.rgb = RGBColor(0, 0, 0)
|
||||
r = p.add_run("加粗")
|
||||
r.bold = True
|
||||
r = p.add_run("、斜体")
|
||||
r.italic = True
|
||||
r = p.add_run("、下划线")
|
||||
r.underline = True
|
||||
r = p.add_run("、删除线")
|
||||
r.font.strike = True
|
||||
r = p.add_run("、红色文字。")
|
||||
r.font.color.rgb = RGBColor(192, 0, 0)
|
||||
|
||||
for text in ["一级项目 1", "一级项目 2"]:
|
||||
doc.add_paragraph(text, style="List Bullet")
|
||||
for text in ["编号项目 1", "编号项目 2"]:
|
||||
doc.add_paragraph(text, style="List Number")
|
||||
|
||||
table = doc.add_table(rows=3, cols=3)
|
||||
table.style = "Table Grid"
|
||||
for i, row in enumerate(table.rows):
|
||||
for j, cell in enumerate(row.cells):
|
||||
cell.text = f"R{i+1}C{j+1}"
|
||||
# Merge first row cells 1-2
|
||||
merged = table.cell(0, 0).merge(table.cell(0, 1))
|
||||
merged.text = "合并单元格"
|
||||
|
||||
p = doc.add_paragraph("下方是一张嵌入图片:")
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
doc.add_picture(str(img_path), width=Cm(8))
|
||||
|
||||
doc.add_page_break()
|
||||
doc.add_paragraph("第二页内容,用于测试分页。")
|
||||
|
||||
doc.save(out_dir / "demo.docx")
|
||||
print(out_dir / "demo.docx")
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
beautifulsoup4>=4.12
|
||||
lxml>=5.0
|
||||
tinycss2>=1.2
|
||||
python-docx>=1.1
|
||||
pillow>=10.0
|
||||
pymupdf>=1.24
|
||||
Reference in New Issue
Block a user