From 6f019610f8cb36f1fb4ec11a642354c1a1ebb19a Mon Sep 17 00:00:00 2001 From: zwt13703 Date: Sun, 5 Jul 2026 23:23:47 +0800 Subject: [PATCH] updates --- .gitignore | 325 ++++ AGENTS.md | 58 + CLAUDE.md | 67 + README.md | 192 ++ backend/config.py | 59 + backend/database.py | 59 + backend/main.py | 57 + backend/models/__init__.py | 6 + backend/models/ai_model.py | 16 + backend/models/document.py | 16 + backend/models/generation_log.py | 14 + backend/models/paragraph.py | 24 + backend/models/reference_file.py | 17 + backend/models/template.py | 13 + backend/models/template_block.py | 30 + backend/requirements.txt | 19 + backend/routers/__init__.py | 0 backend/routers/export.py | 150 ++ backend/routers/generate.py | 462 +++++ backend/routers/models.py | 162 ++ backend/routers/templates.py | 573 ++++++ backend/schemas/__init__.py | 0 backend/schemas/schemas.py | 132 ++ backend/services/__init__.py | 0 backend/services/ai_service.py | 307 ++++ backend/services/document_export.py | 463 +++++ backend/services/file_summary.py | 110 ++ backend/services/generation_runtime.py | 197 ++ backend/services/minio_client.py | 74 + backend/services/security.py | 35 + backend/services/template_parser.py | 282 +++ docker-compose.yml | 80 + docs/tasks/task_detail_2026_07_02.md | 239 +++ docs/tasks/task_detail_2026_07_03.md | 164 ++ docs/tasks/task_detail_2026_07_05.md | 143 ++ docs/原型v3-HTML/index.html | 56 + docs/原型v3-HTML/执行生成.html | 727 ++++++++ docs/原型v3-HTML/模型管理.html | 656 +++++++ docs/原型v3-HTML/模板管理.html | 792 +++++++++ docs/原型v3-HTML/生成记录.html | 692 +++++++ docs/原型v3-HTML/预览编辑.html | 648 +++++++ docs/提示词库/提示词模板.md | 158 ++ docs/规范与约束/开发规范.md | 105 ++ docs/需求与设计/00-项目概述.md | 40 + docs/需求与设计/01-需求规格说明书.md | 182 ++ docs/需求与设计/02-模板格式规范.md | 133 ++ docs/需求与设计/03-任务拆解清单.md | 112 ++ docs/需求与设计/04-后续迭代任务拆解清单.md | 116 ++ .../05-模板在线编辑重构任务拆解清单.md | 138 ++ .../需求与设计/05-模板在线编辑重构增量SQL.sql | 28 + init.sql | 119 ++ web/index.html | 4 + web/package.json | 26 + web/pnpm-lock.yaml | 1384 ++++++++++++++ web/src/App.vue | 176 ++ web/src/api/generate.ts | 19 + web/src/api/index.ts | 14 + web/src/api/model.ts | 10 + web/src/api/template.ts | 9 + web/src/components/DocPreview.vue | 6 + web/src/components/FileUploader.vue | 6 + web/src/components/ModelModal.vue | 10 + web/src/components/ParagraphConfig.vue | 5 + web/src/components/ParagraphList.vue | 6 + web/src/components/ReferenceFileSelector.vue | 303 ++++ web/src/components/TestModal.vue | 13 + web/src/env.d.ts | 2 + web/src/main.ts | 12 + web/src/router/index.ts | 15 + web/src/stores/document.ts | 17 + web/src/stores/model.ts | 18 + web/src/stores/template.ts | 45 + web/src/types/index.ts | 69 + web/src/views/AttachmentHistoryPage.vue | 256 +++ web/src/views/GeneratePage.vue | 430 +++++ web/src/views/HistoryPage.vue | 79 + web/src/views/ModelManage.vue | 299 ++++ web/src/views/PreviewEdit.vue | 491 +++++ web/src/views/TemplateEditor.vue | 1584 +++++++++++++++++ web/src/views/TemplateList.vue | 234 +++ web/tsconfig.json | 11 + web/tsconfig.node.json | 4 + web/vite.config.ts | 18 + 83 files changed, 14822 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 backend/config.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/ai_model.py create mode 100644 backend/models/document.py create mode 100644 backend/models/generation_log.py create mode 100644 backend/models/paragraph.py create mode 100644 backend/models/reference_file.py create mode 100644 backend/models/template.py create mode 100644 backend/models/template_block.py create mode 100644 backend/requirements.txt create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/export.py create mode 100644 backend/routers/generate.py create mode 100644 backend/routers/models.py create mode 100644 backend/routers/templates.py create mode 100644 backend/schemas/__init__.py create mode 100644 backend/schemas/schemas.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/ai_service.py create mode 100644 backend/services/document_export.py create mode 100644 backend/services/file_summary.py create mode 100644 backend/services/generation_runtime.py create mode 100644 backend/services/minio_client.py create mode 100644 backend/services/security.py create mode 100644 backend/services/template_parser.py create mode 100644 docker-compose.yml create mode 100644 docs/tasks/task_detail_2026_07_02.md create mode 100644 docs/tasks/task_detail_2026_07_03.md create mode 100644 docs/tasks/task_detail_2026_07_05.md create mode 100644 docs/原型v3-HTML/index.html create mode 100644 docs/原型v3-HTML/执行生成.html create mode 100644 docs/原型v3-HTML/模型管理.html create mode 100644 docs/原型v3-HTML/模板管理.html create mode 100644 docs/原型v3-HTML/生成记录.html create mode 100644 docs/原型v3-HTML/预览编辑.html create mode 100644 docs/提示词库/提示词模板.md create mode 100644 docs/规范与约束/开发规范.md create mode 100644 docs/需求与设计/00-项目概述.md create mode 100644 docs/需求与设计/01-需求规格说明书.md create mode 100644 docs/需求与设计/02-模板格式规范.md create mode 100644 docs/需求与设计/03-任务拆解清单.md create mode 100644 docs/需求与设计/04-后续迭代任务拆解清单.md create mode 100644 docs/需求与设计/05-模板在线编辑重构任务拆解清单.md create mode 100644 docs/需求与设计/05-模板在线编辑重构增量SQL.sql create mode 100644 init.sql create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/src/App.vue create mode 100644 web/src/api/generate.ts create mode 100644 web/src/api/index.ts create mode 100644 web/src/api/model.ts create mode 100644 web/src/api/template.ts create mode 100644 web/src/components/DocPreview.vue create mode 100644 web/src/components/FileUploader.vue create mode 100644 web/src/components/ModelModal.vue create mode 100644 web/src/components/ParagraphConfig.vue create mode 100644 web/src/components/ParagraphList.vue create mode 100644 web/src/components/ReferenceFileSelector.vue create mode 100644 web/src/components/TestModal.vue create mode 100644 web/src/env.d.ts create mode 100644 web/src/main.ts create mode 100644 web/src/router/index.ts create mode 100644 web/src/stores/document.ts create mode 100644 web/src/stores/model.ts create mode 100644 web/src/stores/template.ts create mode 100644 web/src/types/index.ts create mode 100644 web/src/views/AttachmentHistoryPage.vue create mode 100644 web/src/views/GeneratePage.vue create mode 100644 web/src/views/HistoryPage.vue create mode 100644 web/src/views/ModelManage.vue create mode 100644 web/src/views/PreviewEdit.vue create mode 100644 web/src/views/TemplateEditor.vue create mode 100644 web/src/views/TemplateList.vue create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b213593 --- /dev/null +++ b/.gitignore @@ -0,0 +1,325 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +.idea +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# ---> Vue +# gitignore template for Vue.js projects +# +# Recommended template: Node.gitignore + +# TODO: where does this rule come from? +docs/_book + +# TODO: where does this rule come from? +test/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ed1e887 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md — doc-forge + +你正在参与一个外包项目,客户需要一套完整的 AI 文档模板生成系统。 + +## 你的角色 +全栈开发 AI 助手,负责生成 Vue 3 + Ant Design Vue 前端代码和 Python FastAPI 后端代码。 + +## 基础设施 + +### MySQL +- 数据库名:`doc_forge`,字符集 `utf8mb4` +- 异步驱动:`asyncmy` +- 连接池:pool_size=10, max_overflow=20 +- 本地开发:`docker-compose up mysql` +- DDL 见 `init.sql` + +### MinIO(对象存储) +- 三个 bucket:`doc-forge-templates`(模板)/ `doc-forge-uploads`(参考文件)/ `doc-forge-outputs`(导出文档) +- 文件路径规则:`{bucket}/{YYYYMMDD}/{uuid}.{ext}` +- 预签名 URL 用于前端下载,过期 1 小时 +- 本地开发:`docker-compose up minio`,Console http://localhost:9001 +- SDK:`from minio import Minio`,客户端在 `services/minio_client.py` + +### Docker +- `docker-compose up -d mysql minio` 启动开发依赖 +- `docker-compose up backend web` 启动全栈 + +## 通讯协议 +- 所有 API 响应格式:`{ code: 0, data: {...}, message: "ok" }` +- 错误响应:`{ code: -1, message: "错误描述" }` +- 分页响应:`{ code: 0, data: { items: [], total: N, page: 1, page_size: 20 } }` + +## 必须遵守的规则 + +1. **AI 输出格式** — AI 必须返回 JSON,不得返回纯文本。前端解析 `content` 数组,按 type 分段渲染。 +2. **Word 导出** — 严禁重新生成文档。从 MinIO 拉取原始模板,只替换对应位置的文本节点。 +3. **段落边界** — 只认 Word 标题样式(Heading)。不要尝试用正则或关键词判断段落。 +4. **API Key 安全** — 所有 API Key 用 `cryptography.fernet.Fernet` 加密存储,前端只展示脱敏字符串。 +5. **并发控制** — 段落生成使用 `asyncio.gather` + `Semaphore`,单文档最大并发 5。 +6. **文件存储** — 所有用户文件存 MinIO,后端本地只做临时缓存。 +7. docs/规范与约束/开发规范.md +8. docs/需求与设计/02-模板格式规范.md + +## 段落配置字段 +每个 paragraph 包含: +- `edit_mode`: 'manual' | 'ai' +- `model_id`: int | null(null 表示使用系统默认模型) +- `need_prompt`: boolean + `prompt_text`: string +- `need_file`: boolean + `file_note`: string(备注提示上传什么文件) +- `output_format`: 'text' | 'table' | 'mixed' | 'chart' + +## 容易踩的坑 +- python-docx 中文字体名在 `run.fonts.eastAsia`,不是 `run.fonts.name` +- Ant Design Vue 4.x 的 modal 使用 `v-model:open`,不是 `v-model:visible` +- SSE 事件流要用 `sse-starlette` 的 `EventSourceResponse` +- asyncio 中不能混用同步的 openpyxl,Excel 解析放在线程池执行 (`run_in_executor`) +- MinIO SDK 是同步的,用 `run_in_executor` 包装,不要直接 in asyncio +- asyncmy 连接 MySQL 需要 `charset=utf8mb4`,不然中文会乱码 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d1c8a9f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# doc-forge — AI 文档模板生成系统 + +## 项目概述 +上传 Word 模板 → AI 自动解析段落(按标题样式切割)→ 用户标注段落配置 → 上传参考文件 → AI 多段落并行生成 → 预览编辑 → 导出 Word(保留原始样式)。 + +## 技术栈 +| 层 | 技术 | 说明 | +|----|------|------| +| 前端 | Vue 3.4 + TypeScript + Ant Design Vue 4.x + Pinia + Vite 5 | web/ | +| 后端 | Python 3.11+ + FastAPI + SQLAlchemy 2.0 async | backend/app/ | +| 数据库 | MySQL 8.0(asyncmy 驱动) | docker-compose mysql | +| 对象存储 | MinIO | 存模板文件、参考文件、生成文档 | +| Word 处理 | python-docx | 解析/导出 | +| Excel 处理 | openpyxl | 解析参考文件 | + +## 目录结构 +``` +doc-forge/ +├── web/ 前端 +│ └── src/ +│ ├── views/ TemplateList, TemplateEditor, ModelManage, GeneratePage, HistoryPage, PreviewEdit +│ ├── components/ ParagraphList, ParagraphConfig, DocPreview, FileUploader, ModelModal, TestModal +│ ├── api/ Axios(template, model, generate) +│ ├── stores/ Pinia(template, model, document) +│ └── router/ 6 条路由 +├── backend/ +│ ├── app/ +│ │ ├── models/ ORM(template, paragraph, ai_model, document, generation_log) +│ │ ├── routers/ API(templates, models, generate, export) +│ │ ├── schemas/ Pydantic 校验 +│ │ ├── services/ 业务逻辑(parser, ai_service, generator, exporter, minio_client) +│ │ └── main.py +│ ├── config.py 配置(MySQL + MinIO + AI) +│ └── database.py 异步引擎 +├── docs/ 项目文档 +├── docker-compose.yml MySQL + MinIO + backend + web +├── init.sql 数据库建表 DDL +├── CLAUDE.md +└── AGENTS.md +``` + +## 关键约定 + +### 段落解析规则 +- 段落边界由 Word 标题样式(Heading 1~6)确定 +- 标题与下一标题之间的正文、表格归属到该标题段落 +- 表格独立存储为 `is_table=True`,归属于前一个标题 + +### AI 输出格式 +AI 必须返回结构化 JSON: +```json +{"content": [{"type": "text", "text": "..."}, {"type": "table", "headers": [], "rows": []}]} +``` + +### 文件存储 +- 所有文件存 MinIO,不存本地磁盘 +- bucket 分三类:templates / uploads / outputs +- MinIO 开发环境在 docker-compose 中启动 + +### AI 模型调用 +- OpenAI 格式:GPT-4o, DeepSeek-V3, 通义千问 +- Anthropic 格式:Claude 3.5 Sonnet +- 超时 60s,最多重试 3 次,单文档最大并发 5 + +### Word 导出 +- 从 MinIO 拉取原始模板 → 在内存中修改 → 上传回 MinIO +- 样式完全保留(字体/颜色/行距/页边距/页眉页脚) diff --git a/README.md b/README.md new file mode 100644 index 0000000..30493e0 --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# doc-forge + +上传 Word 模板 → AI 逐段落生成 → 预览编辑 → 导出尽量保留原始样式的 Word 文档。 + +## 技术栈 + +- **前端**: Vue 3.4 + Vite 5 + TypeScript + Ant Design Vue 4.x + Pinia + Axios +- **后端**: Python 3.11+ + FastAPI + SQLAlchemy 2.0 async +- **数据库**: MySQL 8.0(asyncmy 驱动) +- **对象存储**: MinIO(模板 / 参考文件 / 导出文档) +- **文档处理**: python-docx、openpyxl + +## 当前可用能力 + +- 上传 `.docx` 模板并按 Heading 1~6 解析段落 +- 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式 +- 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容 +- 执行整份文档生成:已支持按模型配置发起真实调用,异常时自动回退为模拟结果 +- 生成过程中支持 SSE 进度推送与取消生成 +- 查看生成记录与预览页真实结果 +- 导出 Word:基于原模板替换标题下内容并生成可下载文件 + +## 运行方式 + +推荐开发方式:`Docker 启动依赖 + 本地启动前后端` + +### 1. 启动 MySQL 和 MinIO + +在项目根目录执行: + +```bash +docker compose up -d mysql minio +``` + +启动后可访问: + +- MySQL: `localhost:3306` +- MinIO API: `http://localhost:9000` +- MinIO Console: `http://localhost:9001` + +默认账号: + +- MinIO 用户名: `docforge` +- MinIO 密码: `docforge123` + +### 2. 启动后端 + +```bash +cd /Users/zhouwentao/Workspaces/Yangliu/doc-forge/backend +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +后端地址: + +- API 根地址: `http://localhost:8000` +- 健康检查: `http://localhost:8000/health` + +后端默认读取 [backend/.env](/Users/zhouwentao/Workspaces/Yangliu/doc-forge/backend/.env): + +```env +DEBUG=True + +DB_HOST=localhost +DB_PORT=3306 +DB_USER=docforge +DB_PASSWORD=docforge123 +DB_NAME=doc_forge + +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=docforge +MINIO_SECRET_KEY=docforge123 +MINIO_USE_SSL=False + +ENCRYPTION_KEY=change-this-to-a-32-byte-key-in-production!! +``` + +### 3. 启动前端 + +```bash +cd /Users/zhouwentao/Workspaces/Yangliu/doc-forge/web +pnpm install +pnpm dev +``` + +前端地址: + +- `http://localhost:5173` + +### 4. 初步使用流程 + +1. 打开“模板管理”,上传一个 `.docx` 模板。 +2. 进入模板编辑页,为段落配置 AI / 手动、提示词、模型等。 +3. 打开“模型管理”,添加至少一个模型配置。 +4. 打开“执行生成”,选择模板并发起生成。 +5. 到“生成记录”查看历史,点击“预览”查看实际生成内容。 +6. 在预览页点击“导出 Word”下载导出文件。 + +## Docker 全套启动 + +如果想直接用 Docker 跑全套,可以在项目根目录准备 `.env`: + +```env +ENCRYPTION_KEY=change-this-to-a-32-byte-key-in-production!! +``` + +然后执行: + +```bash +docker compose up -d +``` + +暴露端口: + +- 前端: `5173` +- 后端: `8000` +- MinIO: `9000` +- MinIO Console: `9001` +- MySQL: `3306` + +## 常见问题 + +### `.idea` 不小心提交了怎么办? + +不影响项目运行,但建议尽快移除并加入忽略: + +```bash +echo ".idea/" >> .gitignore +git rm -r --cached .idea +git add .gitignore +git commit -m "移除 IDE 配置文件" +``` + +### 为什么我本地 `python main.py` 报缺少模块? + +说明当前 Python 环境还没安装依赖,先执行: + +```bash +pip install -r requirements.txt +``` + +推荐用虚拟环境: + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +### 为什么导出 PDF 还不可用? + +当前阶段已经支持基础 Word 导出,PDF 导出还未接入 LibreOffice 转换流程。 + +## 目录结构 + +```text +doc-forge/ +├── web/ Vue 3 前端 +│ └── src/ +│ ├── views/ 页面 +│ ├── components/ 通用组件 +│ ├── api/ Axios 请求层 +│ ├── stores/ Pinia 状态管理 +│ └── router/ 路由配置 +├── backend/ Python FastAPI 后端 +│ ├── models/ ORM 数据模型 +│ ├── routers/ API 路由 +│ ├── schemas/ Pydantic 校验 +│ ├── services/ 业务逻辑层 +│ ├── config.py 配置 +│ └── database.py 异步数据库引擎 +├── docker-compose.yml MySQL + MinIO + 后端 + 前端 +├── init.sql 数据库初始化 SQL +└── docs/ 项目文档 +``` + +## 核心流程 + +```text +上传模板 → 解析段落 → 标注配置 → 保存模板 + → 执行生成(上传文件 → AI 生成) + → 预览编辑 → 导出 Word +``` + +## 环境要求 + +- Python 3.11+ +- Node.js 18+ +- pnpm 8+ +- Docker + Docker Compose +- LibreOffice(可选,用于未来的 PDF 导出) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..f2d8022 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,59 @@ +from pydantic_settings import BaseSettings +from pathlib import Path +import os + + +class Settings(BaseSettings): + # 应用 + APP_NAME: str = "AI 文档模板生成系统" + APP_VERSION: str = "1.0.0" + DEBUG: bool = True + + # === 数据库 MySQL === + DB_HOST: str = "localhost" + DB_PORT: int = 3306 + DB_USER: str = "docforge" + DB_PASSWORD: str = "docforge123" + DB_NAME: str = "doc_forge" + @property + def DATABASE_URL(self) -> str: + return f"mysql+asyncmy://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4" + + # === MinIO 文件存储 === + MINIO_ENDPOINT: str = "localhost:9000" + MINIO_ACCESS_KEY: str = "docforge" + MINIO_SECRET_KEY: str = "docforge123" + MINIO_BUCKET_TEMPLATES: str = "doc-forge-templates" + MINIO_BUCKET_UPLOADS: str = "doc-forge-uploads" + MINIO_BUCKET_OUTPUTS: str = "doc-forge-outputs" + MINIO_USE_SSL: bool = False + + # 本地缓存目录(MinIO 文件的本地临时缓存) + LOCAL_CACHE_DIR: str = "local_cache" + + # 文件上传限制 + MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024 # 50MB + ALLOWED_EXTENSIONS: list = [".docx", ".doc", ".xlsx", ".xls", ".xlsm", ".csv", ".pdf", ".txt", ".md", ".json"] + + # 加密(用于 API Key 加密) + ENCRYPTION_KEY: str = "change-this-to-a-32-byte-key-in-production!!" + + # AI 模型默认配置 + AI_REQUEST_TIMEOUT: int = 60 + AI_MAX_RETRIES: int = 3 + AI_MAX_CONCURRENT: int = 5 + AI_GLOBAL_CONCURRENT: int = 10 + + # 服务端口 + HOST: str = "0.0.0.0" + PORT: int = 8000 + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() + +# 创建本地缓存目录 +os.makedirs(settings.LOCAL_CACHE_DIR, exist_ok=True) diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..ffc9fdb --- /dev/null +++ b/backend/database.py @@ -0,0 +1,59 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy import inspect, text +from sqlalchemy.orm import DeclarativeBase +from config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG, pool_size=10, max_overflow=20) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with async_session() as session: + try: + yield session + finally: + await session.close() + + +async def init_db(): + from models.template import Template + from models.paragraph import Paragraph + from models.template_block import TemplateBlock + from models.ai_model import AiModel + from models.document import Document + from models.generation_log import GenerationLog + from models.reference_file import ReferenceFile + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + dialect_name = conn.dialect.name + columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("ai_models")]) + if "supports_streaming" not in columns: + if dialect_name == "sqlite": + await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming BOOLEAN DEFAULT 0")) + else: + await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming TINYINT(1) DEFAULT 0")) + if "enable_reasoning" not in columns: + if dialect_name == "sqlite": + await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning BOOLEAN DEFAULT 0")) + else: + await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning TINYINT(1) DEFAULT 0")) + document_columns = await conn.run_sync( + lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("documents")] + ) + if "request_payload_json" not in document_columns: + await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT")) + paragraph_columns = await conn.run_sync( + lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("paragraphs")] + ) + if "anchor_title" not in paragraph_columns: + await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN anchor_title VARCHAR(500) DEFAULT ''")) + await conn.execute(text("UPDATE paragraphs SET anchor_title = title WHERE anchor_title = '' OR anchor_title IS NULL")) + if "write_mode" not in paragraph_columns: + await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN write_mode VARCHAR(30) DEFAULT 'replace_section'")) + block_tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names()) + if "template_blocks" not in block_tables: + await conn.run_sync(lambda sync_conn: TemplateBlock.__table__.create(sync_conn)) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..49aa0c1 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,57 @@ +import uvicorn +from contextlib import asynccontextmanager +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from database import init_db, engine +from config import settings +from routers import templates, models, generate, export +from services.minio_client import init_buckets + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + await init_buckets() # 初始化 MinIO 存储桶 + yield + await engine.dispose() + + +app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION, lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(templates.router, prefix="/api/v1/templates", tags=["模板管理"]) +app.include_router(models.router, prefix="/api/v1/models", tags=["模型管理"]) +app.include_router(generate.router, prefix="/api/v1/generate", tags=["生成管理"]) +app.include_router(export.router, prefix="/api/v1/export", tags=["导出管理"]) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(_: Request, exc: HTTPException): + return JSONResponse(status_code=exc.status_code, content={"code": -1, "message": exc.detail}) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(_: Request, exc: Exception): + return JSONResponse(status_code=500, content={"code": -1, "message": str(exc) or "服务器内部错误"}) + + +@app.get("/") +async def root(): + return {"code": 0, "data": {"name": settings.APP_NAME, "version": settings.APP_VERSION}, "message": "ok"} + + +@app.get("/health") +async def health(): + return {"code": 0, "data": {"status": "ok"}, "message": "ok"} + + +if __name__ == "__main__": + uvicorn.run("main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e7c3651 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,6 @@ +from models.template import Template +from models.paragraph import Paragraph +from models.template_block import TemplateBlock +from models.ai_model import AiModel +from models.document import Document +from models.generation_log import GenerationLog diff --git a/backend/models/ai_model.py b/backend/models/ai_model.py new file mode 100644 index 0000000..fcf2136 --- /dev/null +++ b/backend/models/ai_model.py @@ -0,0 +1,16 @@ +from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, func +from database import Base + +class AiModel(Base): + __tablename__ = "ai_models" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False, comment="模型名称") + provider = Column(String(100), default="", comment="供应厂商") + api_format = Column(String(20), default="openai", comment="anthropic/openai") + api_endpoint = Column(String(500), default="", comment="API接口地址") + api_key_encrypted = Column(Text, default="", comment="加密后的API Key") + supports_streaming = Column(Boolean, default=False, comment="是否支持流式传输") + enable_reasoning = Column(Boolean, default=False, comment="是否开启思考模式") + status = Column(String(20), default="enabled", comment="enabled/disabled") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/models/document.py b/backend/models/document.py new file mode 100644 index 0000000..c4a522d --- /dev/null +++ b/backend/models/document.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func +from database import Base + +class Document(Base): + __tablename__ = "documents" + id = Column(Integer, primary_key=True, autoincrement=True) + template_id = Column(Integer, ForeignKey("templates.id"), nullable=False) + name = Column(String(255), default="", comment="文档名称") + para_count_done = Column(Integer, default=0, comment="已完成段落数") + para_count_total = Column(Integer, default=0, comment="总段落数") + status = Column(String(20), default="pending", comment="pending/generating/completed/failed/cancelled") + file_path = Column(String(500), default="", comment="生成的文件路径") + error = Column(Text, default="", comment="错误信息") + request_payload_json = Column(Text, nullable=True, comment="提交任务时的文件与段落配置快照") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/models/generation_log.py b/backend/models/generation_log.py new file mode 100644 index 0000000..c4f2848 --- /dev/null +++ b/backend/models/generation_log.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, Float, ForeignKey, func +from database import Base + +class GenerationLog(Base): + __tablename__ = "generation_logs" + id = Column(Integer, primary_key=True, autoincrement=True) + document_id = Column(Integer, ForeignKey("documents.id"), nullable=False) + paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=False) + model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True) + status = Column(String(20), default="pending", comment="pending/generating/success/failed") + content = Column(Text, default="", comment="生成的内容") + duration = Column(Float, default=0, comment="耗时秒数") + error_msg = Column(Text, default="", comment="错误信息") + created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/models/paragraph.py b/backend/models/paragraph.py new file mode 100644 index 0000000..9e144ca --- /dev/null +++ b/backend/models/paragraph.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func +from database import Base + +class Paragraph(Base): + __tablename__ = "paragraphs" + id = Column(Integer, primary_key=True, autoincrement=True) + template_id = Column(Integer, ForeignKey("templates.id"), nullable=False) + sort_index = Column(Integer, default=0, comment="排序") + anchor_title = Column(String(500), default="", comment="原始标题锚点") + title = Column(String(500), default="", comment="段落标题") + content = Column(Text, default="", comment="正文内容/上下文") + style_json = Column(Text, default="{}", comment="段落样式定义JSON") + is_table = Column(Boolean, default=False, comment="是否为表格") + table_json = Column(Text, default="{}", comment="表格结构JSON") + edit_mode = Column(String(20), default="manual", comment="manual/ai") + write_mode = Column(String(30), default="replace_section", comment="replace_section/append_after_heading/replace_heading_only") + model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型") + need_prompt = Column(Boolean, default=True, comment="是否需要提示词") + prompt_text = Column(Text, default="", comment="预设提示词") + need_file = Column(Boolean, default=False, comment="是否需要上传参考文件") + file_note = Column(Text, default="", comment="备注说明(传什么文件)") + output_format = Column(String(20), default="text", comment="text/table/mixed/chart") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/models/reference_file.py b/backend/models/reference_file.py new file mode 100644 index 0000000..f040494 --- /dev/null +++ b/backend/models/reference_file.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base + + +class ReferenceFile(Base): + __tablename__ = "reference_files" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + file_name: Mapped[str] = mapped_column(String(255), default="", comment="原始文件名") + file_path: Mapped[str] = mapped_column(String(500), default="", comment="MinIO 对象路径") + file_size: Mapped[int] = mapped_column(Integer, default=0, comment="文件大小") + content_type: Mapped[str] = mapped_column(String(120), default="", comment="文件类型") + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/models/template.py b/backend/models/template.py new file mode 100644 index 0000000..bdbb591 --- /dev/null +++ b/backend/models/template.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, func +from database import Base + +class Template(Base): + __tablename__ = "templates" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False, comment="模板名称") + description = Column(Text, default="", comment="描述") + file_path = Column(String(500), nullable=False, comment="原始模板文件路径") + paragraph_count = Column(Integer, default=0, comment="段落数") + status = Column(String(20), default="draft", comment="draft/ready") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/models/template_block.py b/backend/models/template_block.py new file mode 100644 index 0000000..6bb7960 --- /dev/null +++ b/backend/models/template_block.py @@ -0,0 +1,30 @@ +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, func + +from database import Base + + +class TemplateBlock(Base): + __tablename__ = "template_blocks" + + id = Column(Integer, primary_key=True, autoincrement=True) + template_id = Column(Integer, ForeignKey("templates.id"), nullable=False) + source_paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True) + parent_block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True) + sort_index = Column(Integer, default=0, comment="排序") + block_type = Column(String(30), default="text", comment="heading/text/table/ai_slot/variable") + anchor_ref = Column(String(500), default="", comment="原始锚点引用") + title = Column(String(500), default="", comment="块标题") + content_json = Column(Text, default="{}", comment="块内容 JSON") + style_json = Column(Text, default="{}", comment="块样式 JSON") + edit_mode = Column(String(20), default="manual", comment="manual/ai") + placeholder_key = Column(String(120), default="", comment="AI 占位键") + variable_key = Column(String(120), default="", comment="变量键") + default_value = Column(Text, default="", comment="默认值") + model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型") + need_prompt = Column(Boolean, default=True, comment="是否需要提示词") + prompt_text = Column(Text, default="", comment="预设提示词") + need_file = Column(Boolean, default=False, comment="是否需要上传参考文件") + file_note = Column(Text, default="", comment="参考文件说明") + output_format = Column(String(20), default="text", comment="text/table/mixed/chart") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0d968b5 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,19 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +sqlalchemy>=2.0.25 +asyncmy>=0.2.9 # MySQL async driver +aiomysql>=0.2.0 # MySQL async fallback +cryptography>=42.0.0 +python-docx>=1.1.0 +openpyxl>=3.1.0 +pandas>=2.1.0 +xlrd>=2.0.1 +httpx>=0.27.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +python-multipart>=0.0.6 +aiofiles>=23.2.0 +sse-starlette>=2.0.0 +minio>=7.2.0 # MinIO 对象存储 SDK +alembic>=1.13.0 +pypdf>=5.0.0 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/export.py b/backend/routers/export.py new file mode 100644 index 0000000..e4c2c66 --- /dev/null +++ b/backend/routers/export.py @@ -0,0 +1,150 @@ +import asyncio +import json +import os +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import PlainTextResponse, RedirectResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import settings +from database import get_db +from models.document import Document +from models.generation_log import GenerationLog +from models.paragraph import Paragraph +from models.template import Template +from models.template_block import TemplateBlock +from services.document_export import export_document_bytes +from services.minio_client import ( + download_object_bytes, + get_presigned_url, + split_bucket_path, + upload_bytes, +) + +router = APIRouter() + + +def _block_text_content(block: TemplateBlock) -> str: + try: + payload = json.loads(block.content_json or "{}") + except Exception: + payload = {} + return payload.get("text") or block.default_value or "" + + +def _block_table_content(block: TemplateBlock) -> dict: + try: + payload = json.loads(block.content_json or "{}") + except Exception: + payload = {} + return payload.get("table") or {} + + +def _build_block_export_content(block: TemplateBlock) -> dict: + if block.block_type == "table": + table_data = _block_table_content(block) + matrix = table_data.get("data") or [] + headers = matrix[0] if matrix else [] + rows = matrix[1:] if len(matrix) > 1 else [] + return {"content": [{"type": "table", "headers": headers, "rows": rows}]} + return {"content": [{"type": "text", "text": _block_text_content(block)}]} + + +def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]: + modes: list[str] = [] + anchor_counter: dict[str, int] = {} + for block in blocks: + if block.block_type == "heading": + modes.append("replace_heading_only") + continue + anchor = (block.anchor_ref or block.title or "").strip() + seen = anchor_counter.get(anchor, 0) + modes.append("replace_section" if seen == 0 else "append_after_heading") + anchor_counter[anchor] = seen + 1 + return modes + + +@router.get("/{document_id}/docx") +async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)): + document = await db.get(Document, document_id) + if document is None: + raise HTTPException(status_code=404, detail="生成记录不存在") + + template = await db.get(Template, document.template_id) + if template is None: + raise HTTPException(status_code=404, detail="模板不存在") + + template_bucket, template_object = split_bucket_path(template.file_path) + template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object) + + block_result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == template.id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) + ) + blocks = block_result.scalars().all() + + log_result = await db.execute( + select(GenerationLog).where(GenerationLog.document_id == document_id) + ) + generation_logs = log_result.scalars().all() + log_map = {item.paragraph_id: json.loads(item.content) if item.content else {"content": []} for item in generation_logs} + + logs = [] + if blocks: + write_modes = _resolve_block_write_modes(blocks) + for block, write_mode in zip(blocks, write_modes): + generated_content = log_map.get(block.source_paragraph_id) if block.source_paragraph_id else None + content = generated_content if (block.edit_mode == "ai" or block.block_type == "ai_slot") and generated_content else _build_block_export_content(block) + logs.append( + { + "anchor_title": block.anchor_ref or block.title, + "title": block.title, + "write_mode": write_mode, + "content": content, + } + ) + else: + result = await db.execute( + select(GenerationLog, Paragraph) + .join(Paragraph, Paragraph.id == GenerationLog.paragraph_id) + .where(GenerationLog.document_id == document_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + for log, paragraph in result.all(): + logs.append( + { + "anchor_title": paragraph.anchor_title or paragraph.title, + "title": paragraph.title, + "write_mode": paragraph.write_mode, + "content": json.loads(log.content) if log.content else {"content": []}, + } + ) + + exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs) + object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx" + await asyncio.to_thread( + upload_bytes, + settings.MINIO_BUCKET_OUTPUTS, + object_name, + exported_bytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + document.file_path = f"{settings.MINIO_BUCKET_OUTPUTS}/{object_name}" + await db.commit() + return RedirectResponse( + url=get_presigned_url(settings.MINIO_BUCKET_OUTPUTS, object_name), + status_code=307, + ) + + +@router.get("/{document_id}/pdf") +async def export_pdf(document_id: int): + return PlainTextResponse( + f"文档 {document_id} 的 PDF 导出功能正在开发中,当前版本请先使用预览页查看结果。", + media_type="text/plain; charset=utf-8", + ) diff --git a/backend/routers/generate.py b/backend/routers/generate.py new file mode 100644 index 0000000..58e680b --- /dev/null +++ b/backend/routers/generate.py @@ -0,0 +1,462 @@ +import asyncio +import json +import os +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sse_starlette.sse import EventSourceResponse + +from config import settings +from database import get_db +from models.ai_model import AiModel +from models.document import Document +from models.generation_log import GenerationLog +from models.paragraph import Paragraph +from models.reference_file import ReferenceFile +from models.template import Template +from schemas.schemas import GenerateFullRequest, GenerateTestRequest, ReferenceFileUpdate, Response +from services.ai_service import call_ai, stream_ai_preview +from services.file_summary import summarize_minio_files +from services.generation_runtime import ( + build_mock_content, + generation_progress, + request_cancel, + run_generation, + update_progress, +) +from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url + +router = APIRouter() + + +def _serialize_document(document: Document) -> dict: + request_payload = {} + if document.request_payload_json: + try: + request_payload = json.loads(document.request_payload_json) + except Exception: + request_payload = {} + return { + "id": document.id, + "template_id": document.template_id, + "name": document.name, + "para_count_done": document.para_count_done, + "para_count_total": document.para_count_total, + "status": document.status, + "file_path": document.file_path, + "error": document.error, + "request_payload": request_payload, + "created_at": document.created_at, + "updated_at": document.updated_at, + } + + +def _serialize_reference_file(file: ReferenceFile) -> dict: + return { + "id": file.id, + "file_name": file.file_name, + "file_path": file.file_path, + "file_size": file.file_size, + "content_type": file.content_type, + "created_at": file.created_at.isoformat() if file.created_at else None, + } + + +async def _build_reference_name_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, str]: + if not file_paths: + return {} + result = await db.execute( + select(ReferenceFile.file_path, ReferenceFile.file_name).where(ReferenceFile.file_path.in_(file_paths)) + ) + return {file_path: file_name for file_path, file_name in result.all()} + + +async def _build_reference_records_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, ReferenceFile]: + if not file_paths: + return {} + result = await db.execute(select(ReferenceFile).where(ReferenceFile.file_path.in_(file_paths))) + return {item.file_path: item for item in result.scalars().all()} + +@router.post("/test") +async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)): + paragraph = await db.get(Paragraph, body.paragraph_id) + if paragraph is None or paragraph.template_id != body.template_id: + raise HTTPException(status_code=404, detail="段落不存在") + if body.prompt_text: + paragraph.prompt_text = body.prompt_text + + model = None + if body.model_id: + model = await db.get(AiModel, body.model_id) + elif paragraph.model_id: + model = await db.get(AiModel, paragraph.model_id) + + file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or []) + file_summaries = ( + await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping) + if body.file_paths + else [] + ) + if model is None or model.status != "enabled": + content = build_mock_content(paragraph) + message = "当前未找到可用模型,返回本地模拟生成结果。" + else: + setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning)) + result = await call_ai(paragraph, model, file_summaries) + content = result.content + message = f"已通过模型 {result.used_model} 生成。" + return Response( + data={ + "paragraph_id": paragraph.id, + "content": content, + "message": message, + "file_summaries": file_summaries, + } + ) + + +@router.post("/test-stream") +async def generate_test_stream(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)): + paragraph = await db.get(Paragraph, body.paragraph_id) + if paragraph is None or paragraph.template_id != body.template_id: + raise HTTPException(status_code=404, detail="段落不存在") + if body.prompt_text: + paragraph.prompt_text = body.prompt_text + + model = None + if body.model_id: + model = await db.get(AiModel, body.model_id) + elif paragraph.model_id: + model = await db.get(AiModel, paragraph.model_id) + + if model is None or model.status != "enabled": + raise HTTPException(status_code=400, detail="当前段落未配置可用的流式模型") + if not model.supports_streaming: + raise HTTPException(status_code=400, detail="当前模型未开启流式传输") + + file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or []) + file_summaries = ( + await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping) + if body.file_paths + else [] + ) + setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning)) + + async def event_stream(): + yield { + "event": "message", + "data": json.dumps( + { + "type": "meta", + "message": f"正在通过模型 {model.name} 流式生成...", + "file_summaries": file_summaries, + }, + ensure_ascii=False, + ), + } + try: + async for chunk in stream_ai_preview(paragraph, model, file_summaries): + yield { + "event": "message", + "data": json.dumps({"type": "delta", "content": chunk}, ensure_ascii=False), + } + yield { + "event": "message", + "data": json.dumps({"type": "done"}, ensure_ascii=False), + } + except Exception as error: + fallback_message = str(error) + if "503" in fallback_message or "temporarily unavailable" in fallback_message.lower(): + try: + result = await call_ai(paragraph, model, file_summaries) + yield { + "event": "message", + "data": json.dumps( + { + "type": "meta", + "message": "流式通道暂时不可用,已自动回退为普通返回。", + "file_summaries": file_summaries, + }, + ensure_ascii=False, + ), + } + yield { + "event": "message", + "data": json.dumps({"type": "delta", "content": result.raw_text}, ensure_ascii=False), + } + yield { + "event": "message", + "data": json.dumps({"type": "done"}, ensure_ascii=False), + } + return + except Exception as fallback_error: + fallback_message = f"{fallback_message};普通调用回退也失败:{fallback_error}" + yield { + "event": "message", + "data": json.dumps({"type": "error", "message": fallback_message}, ensure_ascii=False), + } + + return EventSourceResponse( + event_stream(), + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.post("/upload") +async def upload_reference_file(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): + if not file.filename: + raise HTTPException(status_code=400, detail="文件名不能为空") + + ext = os.path.splitext(file.filename)[1].lower() + if ext not in settings.ALLOWED_EXTENSIONS: + allowed = " / ".join(settings.ALLOWED_EXTENSIONS) + raise HTTPException(status_code=400, detail=f"文件类型不支持:{ext or '无扩展名'}。当前支持:{allowed}") + + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="上传文件不能为空") + if len(content) > settings.MAX_UPLOAD_SIZE: + raise HTTPException(status_code=400, detail="文件大小超过限制") + + object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}{ext}" + await asyncio.to_thread( + upload_bytes, + settings.MINIO_BUCKET_UPLOADS, + object_name, + content, + file.content_type or "application/octet-stream", + ) + record = ReferenceFile( + file_name=file.filename, + file_path=f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}", + file_size=len(content), + content_type=file.content_type or "application/octet-stream", + ) + db.add(record) + await db.commit() + await db.refresh(record) + return Response( + data=_serialize_reference_file(record) + ) + + +@router.get("/reference-files") +async def list_reference_files( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="按文件名搜索"), + db: AsyncSession = Depends(get_db), +): + stmt = select(ReferenceFile) + count_stmt = select(func.count(ReferenceFile.id)) + if keyword: + like_keyword = f"%{keyword.strip()}%" + stmt = stmt.where(ReferenceFile.file_name.like(like_keyword)) + count_stmt = count_stmt.where(ReferenceFile.file_name.like(like_keyword)) + + total = (await db.execute(count_stmt)).scalar_one() + result = await db.execute( + stmt.order_by(ReferenceFile.id.desc()).offset((page - 1) * page_size).limit(page_size) + ) + items = [_serialize_reference_file(item) for item in result.scalars().all()] + return Response(data={"items": items, "total": total, "page": page, "page_size": page_size}) + + +@router.get("/reference-files/{file_id}/download") +async def download_reference_file(file_id: int, db: AsyncSession = Depends(get_db)): + record = await db.get(ReferenceFile, file_id) + if record is None: + raise HTTPException(status_code=404, detail="附件记录不存在") + bucket, object_name = split_bucket_path(record.file_path) + return Response(data={"url": get_presigned_url(bucket, object_name), "file_name": record.file_name}) + + +@router.put("/reference-files/{file_id}") +async def update_reference_file(file_id: int, body: ReferenceFileUpdate, db: AsyncSession = Depends(get_db)): + record = await db.get(ReferenceFile, file_id) + if record is None: + raise HTTPException(status_code=404, detail="附件记录不存在") + record.file_name = body.file_name.strip() + await db.commit() + await db.refresh(record) + return Response(data=_serialize_reference_file(record)) + + +@router.delete("/reference-files/{file_id}") +async def delete_reference_file(file_id: int, db: AsyncSession = Depends(get_db)): + record = await db.get(ReferenceFile, file_id) + if record is None: + raise HTTPException(status_code=404, detail="附件记录不存在") + try: + bucket, object_name = split_bucket_path(record.file_path) + await asyncio.to_thread(delete_object, bucket, object_name) + except Exception: + pass + await db.delete(record) + await db.commit() + return Response(data={"id": file_id}) + + +@router.post("/full") +async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(get_db)): + template = await db.get(Template, body.template_id) + if template is None: + raise HTTPException(status_code=404, detail="模板不存在") + + result = await db.execute( + select(Paragraph) + .where(Paragraph.template_id == body.template_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + paragraphs = result.scalars().all() + if not paragraphs: + raise HTTPException(status_code=400, detail="模板下暂无可生成段落") + + normalized_file_map: dict[str, list[str]] = {} + for key, values in (body.file_map or {}).items(): + if not isinstance(values, list): + continue + normalized_file_map[str(key)] = [item for item in values if item] + all_file_paths = [item for values in normalized_file_map.values() for item in values] + file_records = await _build_reference_records_mapping(db, all_file_paths) + paragraph_snapshot = [] + for paragraph in paragraphs: + selected_paths = normalized_file_map.get(str(paragraph.id), []) + paragraph_snapshot.append( + { + "paragraph_id": paragraph.id, + "title": paragraph.title, + "sort_index": paragraph.sort_index, + "need_file": bool(paragraph.need_file), + "file_note": paragraph.file_note or "", + "selected_files": [ + _serialize_reference_file(file_records[file_path]) + for file_path in selected_paths + if file_path in file_records + ], + } + ) + request_payload = { + "template_name": template.name, + "file_map": normalized_file_map, + "paragraphs": paragraph_snapshot, + } + + document = Document( + template_id=template.id, + name=f"{template.name}-{datetime.now().strftime('%Y%m%d%H%M%S')}", + para_count_done=0, + para_count_total=len(paragraphs), + status="generating", + file_path="", + error="", + request_payload_json=json.dumps(request_payload, ensure_ascii=False), + ) + db.add(document) + await db.flush() + await db.commit() + await db.refresh(document) + update_progress(document.id, status="pending", percent=0, done=0, total=len(paragraphs), message="任务已创建") + asyncio.create_task(run_generation(document.id, template.id)) + return Response(data=_serialize_document(document)) + + +@router.get("/progress/{document_id}") +async def generate_progress(document_id: int): + async def event_generator(): + while True: + state = generation_progress.get( + document_id, + {"status": "pending", "percent": 0, "message": "等待中", "done": 0, "total": 0}, + ) + yield { + "event": "progress", + "data": json.dumps(state, ensure_ascii=False), + } + if state.get("status") in {"completed", "failed", "cancelled"}: + break + await asyncio.sleep(1) + + return EventSourceResponse(event_generator()) + + +@router.get("/documents") +async def list_documents( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), +): + total = (await db.execute(select(func.count(Document.id)))).scalar_one() + result = await db.execute( + select(Document) + .order_by(Document.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + items = [_serialize_document(item) for item in result.scalars().all()] + return Response(data={"items": items, "total": total, "page": page, "page_size": page_size}) + + +@router.get("/documents/{document_id}") +async def get_document(document_id: int, db: AsyncSession = Depends(get_db)): + document = await db.get(Document, document_id) + if document is None: + raise HTTPException(status_code=404, detail="生成记录不存在") + + log_result = await db.execute( + select(GenerationLog, Paragraph) + .join(Paragraph, Paragraph.id == GenerationLog.paragraph_id) + .where(GenerationLog.document_id == document_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + items = [] + for log, paragraph in log_result.all(): + items.append( + { + "id": log.id, + "paragraph_id": paragraph.id, + "title": paragraph.title, + "sort_index": paragraph.sort_index, + "status": log.status, + "content": json.loads(log.content) if log.content else {"content": []}, + } + ) + + payload = _serialize_document(document) + payload["logs"] = items + return Response(data=payload) + + +@router.post("/cancel/{document_id}") +async def cancel_document(document_id: int, db: AsyncSession = Depends(get_db)): + document = await db.get(Document, document_id) + if document is None: + raise HTTPException(status_code=404, detail="生成记录不存在") + + if document.status in {"completed", "failed", "cancelled"}: + return Response(data=_serialize_document(document)) + + request_cancel(document_id) + return Response(data=_serialize_document(document)) + + +@router.delete("/documents/{document_id}") +async def delete_document(document_id: int, db: AsyncSession = Depends(get_db)): + document = await db.get(Document, document_id) + if document is None: + raise HTTPException(status_code=404, detail="生成记录不存在") + + result = await db.execute(select(GenerationLog).where(GenerationLog.document_id == document_id)) + for log in result.scalars().all(): + await db.delete(log) + + await db.delete(document) + await db.commit() + return Response(data={"id": document_id}) diff --git a/backend/routers/models.py b/backend/routers/models.py new file mode 100644 index 0000000..0041efb --- /dev/null +++ b/backend/routers/models.py @@ -0,0 +1,162 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +import httpx + +from database import get_db +from models.ai_model import AiModel +from schemas.schemas import AiModelCreate, AiModelUpdate, Response +from services.ai_service import call_ai +from services.security import decrypt_text, encrypt_text, mask_secret + +router = APIRouter() + + +def _is_deepseek_model(model: AiModel) -> bool: + provider = (model.provider or "").strip().lower() + return provider == "deepseek" + + +def _serialize_model(model: AiModel) -> dict: + api_key = decrypt_text(model.api_key_encrypted) + return { + "id": model.id, + "name": model.name, + "provider": model.provider, + "api_format": model.api_format, + "api_endpoint": model.api_endpoint, + "api_key_preview": mask_secret(api_key), + "supports_streaming": bool(model.supports_streaming), + "enable_reasoning": bool(model.enable_reasoning), + "status": model.status, + "created_at": model.created_at, + } + + +@router.get("") +async def list_models(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(AiModel).order_by(AiModel.id.desc())) + items = [_serialize_model(item) for item in result.scalars().all()] + return Response(data=items) + + +@router.post("") +async def create_model(body: AiModelCreate, db: AsyncSession = Depends(get_db)): + model = AiModel( + name=body.name, + provider=body.provider, + api_format=body.api_format, + api_endpoint=body.api_endpoint, + api_key_encrypted=encrypt_text(body.api_key), + supports_streaming=body.supports_streaming, + enable_reasoning=body.enable_reasoning, + status=body.status, + ) + db.add(model) + await db.commit() + await db.refresh(model) + return Response(data=_serialize_model(model)) + + +@router.put("/{model_id}") +async def update_model(model_id: int, body: AiModelUpdate, db: AsyncSession = Depends(get_db)): + model = await db.get(AiModel, model_id) + if model is None: + raise HTTPException(status_code=404, detail="模型不存在") + + if body.name is not None: + model.name = body.name + if body.provider is not None: + model.provider = body.provider + if body.api_format is not None: + model.api_format = body.api_format + if body.api_endpoint is not None: + model.api_endpoint = body.api_endpoint + if body.supports_streaming is not None: + model.supports_streaming = body.supports_streaming + if body.enable_reasoning is not None: + model.enable_reasoning = body.enable_reasoning + if body.status is not None: + model.status = body.status + if body.api_key: + model.api_key_encrypted = encrypt_text(body.api_key) + + await db.commit() + await db.refresh(model) + return Response(data=_serialize_model(model)) + + +@router.delete("/{model_id}") +async def delete_model(model_id: int, db: AsyncSession = Depends(get_db)): + model = await db.get(AiModel, model_id) + if model is None: + raise HTTPException(status_code=404, detail="模型不存在") + + await db.delete(model) + await db.commit() + return Response(data={"id": model_id}) + + +@router.post("/{model_id}/test") +async def test_model(model_id: int, db: AsyncSession = Depends(get_db)): + model = await db.get(AiModel, model_id) + if model is None: + raise HTTPException(status_code=404, detail="模型不存在") + + class FakeParagraph: + title = "连接测试" + content = "请返回一段非常简短的测试文本。" + need_prompt = False + prompt_text = "" + output_format = "text" + enable_reasoning = bool(model.enable_reasoning) + + try: + result = await call_ai(FakeParagraph(), model) + return Response( + data={ + "id": model.id, + "success": True, + "message": f"模型 {model.name} 连接测试成功", + "preview": result.content, + } + ) + except Exception as error: + return Response( + code=-1, + message=str(error), + data={"id": model.id, "success": False}, + ) + + +@router.get("/{model_id}/balance") +async def get_model_balance(model_id: int, db: AsyncSession = Depends(get_db)): + model = await db.get(AiModel, model_id) + if model is None: + raise HTTPException(status_code=404, detail="模型不存在") + if not _is_deepseek_model(model): + raise HTTPException(status_code=400, detail="仅 DeepSeek 模型支持余额查询") + + api_key = decrypt_text(model.api_key_encrypted) + if not api_key: + raise HTTPException(status_code=400, detail="模型 API Key 不可用") + + try: + async with httpx.AsyncClient(timeout=20, trust_env=False) as client: + response = await client.get( + "https://api.deepseek.com/user/balance", + headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"}, + ) + response.raise_for_status() + payload = response.json() + except Exception as error: + raise HTTPException(status_code=400, detail=f"查询余额失败:{error}") + + return Response( + data={ + "id": model.id, + "provider": model.provider, + "is_available": payload.get("is_available", False), + "balance_infos": payload.get("balance_infos", []), + } + ) diff --git a/backend/routers/templates.py b/backend/routers/templates.py new file mode 100644 index 0000000..74fbcae --- /dev/null +++ b/backend/routers/templates.py @@ -0,0 +1,573 @@ +import asyncio +import json +import os +import tempfile +import uuid +from datetime import datetime +from io import BytesIO + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import settings +from database import get_db +from models.document import Document +from models.generation_log import GenerationLog +from models.paragraph import Paragraph +from models.template import Template +from models.template_block import TemplateBlock +from schemas.schemas import Response, TemplateSave +from services.document_export import export_document_bytes +from services.minio_client import download_object_bytes, minio_client, split_bucket_path, upload_bytes +from services.template_parser import parse_template + +router = APIRouter() + + +def _build_object_path(filename: str) -> tuple[str, str]: + ext = os.path.splitext(filename)[1].lower() + date_prefix = datetime.now().strftime("%Y%m%d") + object_name = f"{date_prefix}/{uuid.uuid4().hex}{ext}" + return ext, object_name + + +def _serialize_paragraph(paragraph: Paragraph) -> dict: + return { + "id": paragraph.id, + "template_id": paragraph.template_id, + "sort_index": paragraph.sort_index, + "anchor_title": paragraph.anchor_title, + "title": paragraph.title, + "content": paragraph.content, + "style_json": paragraph.style_json, + "is_table": paragraph.is_table, + "table_json": paragraph.table_json, + "edit_mode": paragraph.edit_mode, + "write_mode": paragraph.write_mode, + "model_id": paragraph.model_id, + "need_prompt": paragraph.need_prompt, + "prompt_text": paragraph.prompt_text, + "need_file": paragraph.need_file, + "file_note": paragraph.file_note, + "output_format": paragraph.output_format, + } + + +def _serialize_template(template: Template) -> dict: + return { + "id": template.id, + "name": template.name, + "description": template.description, + "file_path": template.file_path, + "paragraph_count": template.paragraph_count, + "status": template.status, + "created_at": template.created_at, + "updated_at": template.updated_at, + } + + +def _build_block_from_paragraph(paragraph: Paragraph) -> dict: + block_type = "heading" if paragraph.write_mode == "replace_heading_only" else ("ai_slot" if paragraph.edit_mode == "ai" else ("table" if paragraph.is_table else "text")) + content_json = json.dumps({ + "text": paragraph.content or "", + "table": json.loads(paragraph.table_json or "{}") if paragraph.is_table else None, + }, ensure_ascii=False) + return { + "source_paragraph_id": paragraph.id, + "parent_block_id": None, + "sort_index": paragraph.sort_index, + "block_type": block_type, + "anchor_ref": paragraph.anchor_title or paragraph.title, + "title": paragraph.title, + "content_json": content_json, + "style_json": paragraph.style_json or "{}", + "edit_mode": paragraph.edit_mode, + "placeholder_key": "", + "variable_key": "", + "default_value": paragraph.content or "", + "model_id": paragraph.model_id, + "need_prompt": paragraph.need_prompt, + "prompt_text": paragraph.prompt_text, + "need_file": paragraph.need_file, + "file_note": paragraph.file_note, + "output_format": paragraph.output_format, + } + + +def _build_block_from_parsed_item(item, source_paragraph_id: int | None) -> dict: + content_json = json.dumps({ + "text": item.content or "", + "table": json.loads(item.table_json or "{}") if item.is_table else None, + }, ensure_ascii=False) + return { + "source_paragraph_id": source_paragraph_id, + "parent_block_id": None, + "sort_index": item.sort_index, + "block_type": item.block_type, + "anchor_ref": item.anchor_title or item.title, + "title": item.title, + "content_json": content_json, + "style_json": item.style_json or "{}", + "edit_mode": item.edit_mode, + "placeholder_key": item.placeholder_key, + "variable_key": item.variable_key, + "default_value": item.default_value, + "model_id": None, + "need_prompt": True, + "prompt_text": "", + "need_file": False, + "file_note": "", + "output_format": item.output_format, + } + + +def _serialize_block(block: TemplateBlock) -> dict: + try: + content_json = json.loads(block.content_json or "{}") + except Exception: + content_json = {} + return { + "id": block.id, + "template_id": block.template_id, + "source_paragraph_id": block.source_paragraph_id, + "parent_block_id": block.parent_block_id, + "sort_index": block.sort_index, + "block_type": block.block_type, + "anchor_ref": block.anchor_ref, + "title": block.title, + "content_json": content_json, + "style_json": block.style_json, + "edit_mode": block.edit_mode, + "placeholder_key": block.placeholder_key, + "variable_key": block.variable_key, + "default_value": block.default_value, + "model_id": block.model_id, + "need_prompt": block.need_prompt, + "prompt_text": block.prompt_text, + "need_file": block.need_file, + "file_note": block.file_note, + "output_format": block.output_format, + } + + +async def _load_blocks(db: AsyncSession, template_id: int) -> list[TemplateBlock]: + result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == template_id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) + ) + return result.scalars().all() + + +async def _sync_blocks_from_paragraphs(db: AsyncSession, template_id: int, paragraphs: list[Paragraph]): + existing_blocks = await _load_blocks(db, template_id) + for block in existing_blocks: + await db.delete(block) + await db.flush() + + block_rows: list[TemplateBlock] = [] + for paragraph in paragraphs: + block = TemplateBlock(template_id=template_id, **_build_block_from_paragraph(paragraph)) + db.add(block) + block_rows.append(block) + await db.flush() + return block_rows + + +async def _save_blocks( + db: AsyncSession, + template_id: int, + blocks_payload, +): + existing_blocks = await _load_blocks(db, template_id) + block_map = {item.id: item for item in existing_blocks} + incoming_ids = {config.id for config in blocks_payload if config.id} + + for block in existing_blocks: + if block.id not in incoming_ids: + await db.delete(block) + + for index, config in enumerate(blocks_payload, start=1): + block = block_map.get(config.id) if config.id else None + if block is None: + block = TemplateBlock(template_id=template_id) + db.add(block) + block.source_paragraph_id = config.source_paragraph_id + block.parent_block_id = config.parent_block_id + block.sort_index = index + block.block_type = config.block_type + block.anchor_ref = config.anchor_ref or config.title + block.title = config.title + block.content_json = json.dumps(config.content_json or {}, ensure_ascii=False) + block.style_json = config.style_json or "{}" + block.edit_mode = config.edit_mode + block.placeholder_key = config.placeholder_key + block.variable_key = config.variable_key + block.default_value = config.default_value + block.model_id = config.model_id + block.need_prompt = config.need_prompt + block.prompt_text = config.prompt_text + block.need_file = config.need_file + block.file_note = config.file_note + block.output_format = config.output_format + + await db.flush() + + +def _block_text_content(block: TemplateBlock) -> str: + try: + payload = json.loads(block.content_json or "{}") + except Exception: + payload = {} + return payload.get("text") or block.default_value or "" + + +def _block_table_content(block: TemplateBlock) -> dict: + try: + payload = json.loads(block.content_json or "{}") + except Exception: + payload = {} + return payload.get("table") or {} + + +async def _sync_paragraphs_from_blocks(db: AsyncSession, template_id: int) -> list[Paragraph]: + paragraph_result = await db.execute( + select(Paragraph) + .where(Paragraph.template_id == template_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + existing_paragraphs = paragraph_result.scalars().all() + paragraph_map = {item.id: item for item in existing_paragraphs} + blocks = await _load_blocks(db, template_id) + write_modes = _resolve_block_write_modes(blocks) + kept_paragraph_ids: set[int] = set() + synced_rows: list[Paragraph] = [] + + for index, (block, write_mode) in enumerate(zip(blocks, write_modes), start=1): + paragraph = paragraph_map.get(block.source_paragraph_id) if block.source_paragraph_id else None + if paragraph is None: + paragraph = Paragraph(template_id=template_id) + db.add(paragraph) + await db.flush() + paragraph.sort_index = index + paragraph.anchor_title = block.anchor_ref or block.title + paragraph.title = block.title + paragraph.content = _block_text_content(block) + paragraph.style_json = block.style_json or "{}" + paragraph.is_table = block.block_type == "table" + paragraph.table_json = json.dumps(_block_table_content(block), ensure_ascii=False) if paragraph.is_table else "{}" + paragraph.edit_mode = "ai" if block.block_type == "ai_slot" or block.edit_mode == "ai" else "manual" + paragraph.write_mode = write_mode + paragraph.model_id = block.model_id + paragraph.need_prompt = block.need_prompt + paragraph.prompt_text = block.prompt_text + paragraph.need_file = block.need_file + paragraph.file_note = block.file_note + paragraph.output_format = block.output_format + block.source_paragraph_id = paragraph.id + kept_paragraph_ids.add(paragraph.id) + synced_rows.append(paragraph) + + for paragraph in existing_paragraphs: + if paragraph.id in kept_paragraph_ids: + continue + await db.execute(delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id)) + await db.delete(paragraph) + + await db.flush() + return synced_rows + + +def _build_export_content_from_block(block: TemplateBlock) -> dict: + if block.block_type == "table": + table_data = _block_table_content(block) + matrix = table_data.get("data") or [] + headers = matrix[0] if matrix else [] + rows = matrix[1:] if len(matrix) > 1 else [] + return {"content": [{"type": "table", "headers": headers, "rows": rows}]} + return {"content": [{"type": "text", "text": _block_text_content(block)}]} + + +def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]: + modes: list[str] = [] + anchor_counter: dict[str, int] = {} + for block in blocks: + if block.block_type == "heading": + modes.append("replace_heading_only") + continue + anchor = (block.anchor_ref or block.title or "").strip() + seen = anchor_counter.get(anchor, 0) + modes.append("replace_section" if seen == 0 else "append_after_heading") + anchor_counter[anchor] = seen + 1 + return modes + + +async def _write_template_snapshot_to_docx(db: AsyncSession, template: Template): + blocks = await _load_blocks(db, template.id) + if not blocks: + return + write_modes = _resolve_block_write_modes(blocks) + logs = [] + for block, write_mode in zip(blocks, write_modes): + logs.append( + { + "anchor_title": block.anchor_ref or block.title, + "title": block.title, + "write_mode": write_mode, + "content": _build_export_content_from_block(block), + } + ) + + template_bucket, template_object = split_bucket_path(template.file_path) + template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object) + exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs) + await asyncio.to_thread( + upload_bytes, + template_bucket, + template_object, + exported_bytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + +@router.get("") +async def list_templates( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", alias="q"), + db: AsyncSession = Depends(get_db), +): + filters = [] + if keyword: + filters.append(Template.name.like(f"%{keyword}%")) + + total_stmt = select(func.count(Template.id)) + list_stmt = select(Template).order_by(Template.id.desc()) + if filters: + total_stmt = total_stmt.where(*filters) + list_stmt = list_stmt.where(*filters) + + total = (await db.execute(total_stmt)).scalar_one() + result = await db.execute(list_stmt.offset((page - 1) * page_size).limit(page_size)) + items = [_serialize_template(item) for item in result.scalars().all()] + return Response( + data={"items": items, "total": total, "page": page, "page_size": page_size} + ) + + +@router.get("/{template_id}") +async def get_template(template_id: int, db: AsyncSession = Depends(get_db)): + template = await db.get(Template, template_id) + if template is None: + raise HTTPException(status_code=404, detail="模板不存在") + + result = await db.execute( + select(Paragraph) + .where(Paragraph.template_id == template_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + paragraph_rows = result.scalars().all() + paragraphs = [_serialize_paragraph(item) for item in paragraph_rows] + block_rows = await _load_blocks(db, template_id) + if not block_rows and paragraph_rows: + block_rows = await _sync_blocks_from_paragraphs(db, template_id, paragraph_rows) + await db.commit() + blocks = [_serialize_block(item) for item in block_rows] + payload = _serialize_template(template) + payload["paragraphs"] = paragraphs + payload["blocks"] = blocks + return Response(data=payload) + + +@router.post("/upload") +async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): + if not file.filename: + raise HTTPException(status_code=400, detail="文件名不能为空") + + ext, object_name = _build_object_path(file.filename) + if ext != ".docx": + raise HTTPException(status_code=400, detail="模板仅支持 .docx 格式") + + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="上传文件不能为空") + if len(content) > settings.MAX_UPLOAD_SIZE: + raise HTTPException(status_code=400, detail="文件大小超过限制") + + with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp_file: + temp_file.write(content) + temp_path = temp_file.name + + try: + parsed_items = await asyncio.to_thread(parse_template, temp_path) + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + await asyncio.to_thread( + minio_client.put_object, + settings.MINIO_BUCKET_TEMPLATES, + object_name, + BytesIO(content), + len(content), + file.content_type or "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + template = Template( + name=os.path.splitext(file.filename)[0], + description="", + file_path=f"{settings.MINIO_BUCKET_TEMPLATES}/{object_name}", + paragraph_count=len(parsed_items), + status="draft", + ) + db.add(template) + await db.flush() + + paragraph_rows: list[Paragraph] = [] + for item in parsed_items: + paragraph = Paragraph( + template_id=template.id, + sort_index=item.sort_index, + anchor_title=item.anchor_title, + title=item.title, + content=item.content, + style_json=item.style_json, + is_table=item.is_table, + table_json=item.table_json, + edit_mode=item.edit_mode, + write_mode=item.write_mode, + need_prompt=item.edit_mode == "ai", + output_format=item.output_format, + ) + db.add(paragraph) + paragraph_rows.append(paragraph) + + await db.flush() + existing_blocks = await _load_blocks(db, template.id) + for block in existing_blocks: + await db.delete(block) + await db.flush() + block_rows: list[TemplateBlock] = [] + for item, paragraph in zip(parsed_items, paragraph_rows): + block = TemplateBlock(template_id=template.id, **_build_block_from_parsed_item(item, paragraph.id)) + db.add(block) + block_rows.append(block) + await db.flush() + await db.commit() + await db.refresh(template) + for paragraph in paragraph_rows: + await db.refresh(paragraph) + for block in block_rows: + await db.refresh(block) + + payload = _serialize_template(template) + payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows] + payload["blocks"] = [_serialize_block(item) for item in block_rows] + return Response(data=payload) + + +@router.put("/{template_id}/paragraphs") +async def save_template_paragraphs( + template_id: int, + body: TemplateSave, + db: AsyncSession = Depends(get_db), +): + template = await db.get(Template, template_id) + if template is None: + raise HTTPException(status_code=404, detail="模板不存在") + + result = await db.execute( + select(Paragraph) + .where(Paragraph.template_id == template_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + existing_paragraphs = result.scalars().all() + paragraph_map = {item.id: item for item in existing_paragraphs} + incoming_ids = {config.id for config in body.paragraphs if config.id} + + print(f"[SAVE] template_id={template_id}, incoming_ids={incoming_ids}, existing_ids={[p.id for p in existing_paragraphs]}") + + for paragraph in existing_paragraphs: + if paragraph.id not in incoming_ids: + print(f"[SAVE] Deleting paragraph id={paragraph.id} title={paragraph.title}") + await db.execute( + delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id) + ) + await db.delete(paragraph) + + for index, config in enumerate(body.paragraphs, start=1): + paragraph = paragraph_map.get(config.id) if config.id else None + if paragraph is None: + paragraph = Paragraph(template_id=template_id) + db.add(paragraph) + paragraph.sort_index = index + paragraph.anchor_title = config.anchor_title or config.title or paragraph.anchor_title + paragraph.title = config.title + paragraph.content = config.content + paragraph.edit_mode = config.edit_mode + paragraph.write_mode = config.write_mode + paragraph.model_id = config.model_id + paragraph.need_prompt = config.need_prompt + paragraph.prompt_text = config.prompt_text + paragraph.need_file = config.need_file + paragraph.file_note = config.file_note + paragraph.output_format = config.output_format + + await db.flush() + refreshed_result = await db.execute( + select(Paragraph) + .where(Paragraph.template_id == template_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + refreshed_paragraphs = refreshed_result.scalars().all() + if body.save_mode == "manual" and body.blocks: + await _save_blocks(db, template_id, body.blocks) + refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id) + else: + await _sync_blocks_from_paragraphs(db, template_id, refreshed_paragraphs) + refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id) + template.paragraph_count = len(refreshed_paragraphs) + await _write_template_snapshot_to_docx(db, template) + await db.commit() + blocks = [_serialize_block(item) for item in await _load_blocks(db, template_id)] + return Response(data={"template_id": template_id, "saved": len(refreshed_paragraphs), "blocks": blocks}) + + +@router.delete("/{template_id}") +async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)): + template = await db.get(Template, template_id) + if template is None: + raise HTTPException(status_code=404, detail="模板不存在") + + result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id)) + paragraphs_to_delete = result.scalars().all() + paragraph_ids = [p.id for p in paragraphs_to_delete] + + doc_result = await db.execute(select(Document).where(Document.template_id == template_id)) + documents_to_delete = doc_result.scalars().all() + + if paragraph_ids: + await db.execute( + delete(GenerationLog).where(GenerationLog.paragraph_id.in_(paragraph_ids)) + ) + for document in documents_to_delete: + await db.execute( + delete(GenerationLog).where(GenerationLog.document_id == document.id) + ) + await db.delete(document) + + for paragraph in paragraphs_to_delete: + await db.delete(paragraph) + + file_path = template.file_path or "" + if "/" in file_path: + bucket, object_name = file_path.split("/", 1) + try: + await asyncio.to_thread(minio_client.remove_object, bucket, object_name) + except Exception: + pass + + await db.delete(template) + await db.commit() + return Response(data={"id": template_id}) diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py new file mode 100644 index 0000000..b713c4d --- /dev/null +++ b/backend/schemas/schemas.py @@ -0,0 +1,132 @@ +from pydantic import BaseModel, Field +from typing import Optional, Any +from datetime import datetime + +class Response(BaseModel): + code: int = 0 + data: Any = None + message: str = "ok" + +class PageData(BaseModel): + items: list = [] + total: int = 0 + page: int = 1 + page_size: int = 20 + +# 模板 +class TemplateCreate(BaseModel): + name: str + description: str = "" + +class TemplateOut(BaseModel): + id: int + name: str + description: str = "" + file_path: str = "" + paragraph_count: int = 0 + status: str = "draft" + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + +class ParagraphConfig(BaseModel): + id: int = 0 + sort_index: int = 0 + anchor_title: str = "" + title: str = "" + content: str = "" + edit_mode: str = "manual" + write_mode: str = "replace_section" + model_id: Optional[int] = None + need_prompt: bool = True + prompt_text: str = "" + need_file: bool = False + file_note: str = "" + output_format: str = "text" + + +class TemplateBlockConfig(BaseModel): + id: int = 0 + source_paragraph_id: Optional[int] = None + parent_block_id: Optional[int] = None + sort_index: int = 0 + block_type: str = "text" + anchor_ref: str = "" + title: str = "" + content_json: dict[str, Any] = Field(default_factory=dict) + style_json: str = "{}" + edit_mode: str = "manual" + placeholder_key: str = "" + variable_key: str = "" + default_value: str = "" + model_id: Optional[int] = None + need_prompt: bool = True + prompt_text: str = "" + need_file: bool = False + file_note: str = "" + output_format: str = "text" + +class TemplateSave(BaseModel): + save_mode: str = "paragraph" + paragraphs: list[ParagraphConfig] = [] + blocks: list[TemplateBlockConfig] = [] + +# 模型 +class AiModelCreate(BaseModel): + name: str + provider: str = "" + api_format: str = "openai" + api_endpoint: str = "" + api_key: str = "" + supports_streaming: bool = False + enable_reasoning: bool = False + status: str = "enabled" + + +class AiModelUpdate(BaseModel): + name: Optional[str] = None + provider: Optional[str] = None + api_format: Optional[str] = None + api_endpoint: Optional[str] = None + api_key: str = "" + supports_streaming: Optional[bool] = None + enable_reasoning: Optional[bool] = None + status: Optional[str] = None + +class AiModelOut(BaseModel): + id: int + name: str + provider: str = "" + api_format: str = "openai" + api_endpoint: str = "" + api_key_preview: str = "" + supports_streaming: bool = False + enable_reasoning: bool = False + status: str = "enabled" + created_at: Optional[datetime] = None + +# 生成 +class GenerateTestRequest(BaseModel): + paragraph_id: int + template_id: int + prompt_text: str = "" + model_id: int = 0 + file_paths: list[str] = [] + +class GenerateFullRequest(BaseModel): + template_id: int + file_map: dict[str, list[str]] = {} # paragraph_id -> file_paths + + +class ReferenceFileUpdate(BaseModel): + file_name: str = Field(min_length=1, max_length=255) + +class DocumentOut(BaseModel): + id: int + template_id: int + name: str + para_count_done: int = 0 + para_count_total: int = 0 + status: str = "pending" + file_path: str = "" + error: str = "" + created_at: Optional[datetime] = None diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py new file mode 100644 index 0000000..8172968 --- /dev/null +++ b/backend/services/ai_service.py @@ -0,0 +1,307 @@ +import asyncio +import json +import re +from dataclasses import dataclass +from urllib.parse import urlparse + +import httpx + +from config import settings +from models.ai_model import AiModel +from models.paragraph import Paragraph +from services.security import decrypt_text + + +@dataclass +class AiCallResult: + content: dict + raw_text: str + used_model: str + + +def _ensure_json_content(text: str) -> dict: + stripped = text.strip() + if not stripped: + return {"content": [{"type": "text", "text": ""}]} + + try: + parsed = json.loads(stripped) + if isinstance(parsed, dict) and "content" in parsed: + return parsed + except json.JSONDecodeError: + pass + + code_block_match = re.search(r"```json\s*(.*?)\s*```", stripped, re.S) + if code_block_match: + try: + parsed = json.loads(code_block_match.group(1)) + if isinstance(parsed, dict) and "content" in parsed: + return parsed + except json.JSONDecodeError: + pass + + return {"content": [{"type": "text", "text": stripped}]} + + +def _format_file_context(file_summaries: list[dict]) -> str: + file_blocks: list[str] = [] + for item in file_summaries: + file_name = item.get("file_name") or "未命名文件" + summary = item.get("summary") or "文件内容为空。" + file_blocks.append(f"文件:{file_name}\n内容:\n{summary}") + return "\n\n".join(file_blocks) + + +def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]: + system_prompt = ( + "你是一个企业文档撰写助手。" + "请严格输出 JSON,不要输出 JSON 之外的说明。" + '格式为:{"content":[{"type":"text","text":"..."},{"type":"table","title":"...","headers":["..."],"rows":[["..."]]}]}。' + ) + if getattr(paragraph, "enable_reasoning", False): + system_prompt += "你可以先进行充分思考,再给出最终答案,但最终只输出要求的结果内容。" + + user_parts = [f"段落标题:{paragraph.title}"] + if paragraph.content: + user_parts.append(f"模板上下文:{paragraph.content}") + if paragraph.need_prompt and paragraph.prompt_text: + user_parts.append(f"附加要求:{paragraph.prompt_text}") + if file_summaries: + user_parts.append("参考文件内容:\n" + _format_file_context(file_summaries)) + user_parts.append(f"输出格式:{paragraph.output_format}") + return system_prompt, "\n\n".join(user_parts) + + +def _normalize_openai_endpoint(api_endpoint: str) -> str: + endpoint = api_endpoint.rstrip("/") + parsed = urlparse(endpoint if "://" in endpoint else f"https://{endpoint}") + host = parsed.netloc or parsed.path.split("/")[0] + if host == "api.deepseek.com": + return "https://api.deepseek.com/chat/completions" + if endpoint.endswith("/chat/completions"): + return endpoint + if endpoint.endswith("/v1"): + return f"{endpoint}/chat/completions" + return f"{endpoint}/v1/chat/completions" + + +def _normalize_anthropic_endpoint(api_endpoint: str) -> str: + endpoint = api_endpoint.rstrip("/") + if endpoint.endswith("/messages"): + return endpoint + if endpoint.endswith("/v1"): + return f"{endpoint}/messages" + return f"{endpoint}/v1/messages" + + +async def _post_with_retry( + client: httpx.AsyncClient, + url: str, + headers: dict, + payload: dict, +) -> httpx.Response: + last_error: Exception | None = None + for attempt in range(settings.AI_MAX_RETRIES): + try: + response = await client.post(url, headers=headers, json=payload) + if response.status_code in (429, 500, 502, 503, 504): + raise httpx.HTTPStatusError( + f"上游模型响应异常: {response.status_code} - {response.text[:500]}", + request=response.request, + response=response, + ) + response.raise_for_status() + return response + except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error: + last_error = error + if attempt == settings.AI_MAX_RETRIES - 1: + break + await asyncio.sleep(2 ** attempt) + error_message = str(last_error) + if isinstance(last_error, httpx.HTTPStatusError) and last_error.response is not None: + error_message = f"{error_message}\n响应内容: {last_error.response.text[:1000]}" + raise RuntimeError(f"模型调用失败:{error_message}") + + +async def _call_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult: + api_key = decrypt_text(model.api_key_encrypted) + if not api_key: + raise RuntimeError("模型 API Key 不可用") + + payload = { + "model": model.name, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.3, + } + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: + response = await _post_with_retry(client, _normalize_openai_endpoint(model.api_endpoint), headers, payload) + body = response.json() + text = body["choices"][0]["message"]["content"] + return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name) + + +async def _stream_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str): + api_key = decrypt_text(model.api_key_encrypted) + if not api_key: + raise RuntimeError("模型 API Key 不可用") + + payload = { + "model": model.name, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.3, + "stream": True, + } + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + last_error: Exception | None = None + for attempt in range(settings.AI_MAX_RETRIES): + try: + async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: + async with client.stream("POST", _normalize_openai_endpoint(model.api_endpoint), headers=headers, json=payload) as response: + if response.status_code in (429, 500, 502, 503, 504): + body = await response.aread() + raise httpx.HTTPStatusError( + f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}", + request=response.request, + response=response, + ) + response.raise_for_status() + async for line in response.aiter_lines(): + if not line or not line.startswith("data:"): + continue + payload_line = line[5:].strip() + if payload_line == "[DONE]": + break + try: + chunk = json.loads(payload_line) + except json.JSONDecodeError: + continue + delta_payload = chunk.get("choices", [{}])[0].get("delta", {}) + delta = delta_payload.get("content", "") + reasoning = delta_payload.get("reasoning_content", "") + if isinstance(delta, list): + delta = "".join( + item.get("text", "") if isinstance(item, dict) else str(item) + for item in delta + ) + if delta: + yield delta + if reasoning: + yield reasoning + return + except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error: + last_error = error + if attempt == settings.AI_MAX_RETRIES - 1: + break + await asyncio.sleep(2 ** attempt) + raise RuntimeError(f"模型流式调用失败:{last_error}") + + +async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult: + api_key = decrypt_text(model.api_key_encrypted) + if not api_key: + raise RuntimeError("模型 API Key 不可用") + + payload = { + "model": model.name, + "max_tokens": 2048, + "system": system_prompt, + "messages": [{"role": "user", "content": user_prompt}], + } + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: + response = await _post_with_retry(client, _normalize_anthropic_endpoint(model.api_endpoint), headers, payload) + body = response.json() + text = "" + for item in body.get("content", []): + if item.get("type") == "text": + text += item.get("text", "") + return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name) + + +async def _stream_anthropic(model: AiModel, system_prompt: str, user_prompt: str): + api_key = decrypt_text(model.api_key_encrypted) + if not api_key: + raise RuntimeError("模型 API Key 不可用") + + payload = { + "model": model.name, + "max_tokens": 2048, + "system": system_prompt, + "messages": [{"role": "user", "content": user_prompt}], + "stream": True, + } + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + last_error: Exception | None = None + for attempt in range(settings.AI_MAX_RETRIES): + try: + async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: + async with client.stream("POST", _normalize_anthropic_endpoint(model.api_endpoint), headers=headers, json=payload) as response: + if response.status_code in (429, 500, 502, 503, 504): + body = await response.aread() + raise httpx.HTTPStatusError( + f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}", + request=response.request, + response=response, + ) + response.raise_for_status() + async for line in response.aiter_lines(): + if not line or not line.startswith("data:"): + continue + payload_line = line[5:].strip() + if payload_line == "[DONE]": + break + try: + chunk = json.loads(payload_line) + except json.JSONDecodeError: + continue + if chunk.get("type") == "content_block_delta": + delta = chunk.get("delta", {}).get("text", "") + if delta: + yield delta + return + except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error: + last_error = error + if attempt == settings.AI_MAX_RETRIES - 1: + break + await asyncio.sleep(2 ** attempt) + raise RuntimeError(f"模型流式调用失败:{last_error}") + + +async def call_ai(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None) -> AiCallResult: + system_prompt, user_prompt = _build_prompt(paragraph, file_summaries) + if model.api_format == "anthropic": + return await _call_anthropic(model, system_prompt, user_prompt) + return await _call_openai_compatible(model, system_prompt, user_prompt) + + +def build_test_stream_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]: + system_prompt = "你是一个企业文档撰写助手。请直接输出适合预览的正文内容或 Markdown 表格,不要输出 JSON。" + if getattr(paragraph, "enable_reasoning", False): + system_prompt += "你可以先进行充分思考,再持续输出最终可展示的内容。" + _, user_prompt = _build_prompt(paragraph, file_summaries or []) + return system_prompt, user_prompt + + +async def stream_ai_preview(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None): + system_prompt, user_prompt = build_test_stream_prompt(paragraph, file_summaries) + if model.api_format == "anthropic": + async for chunk in _stream_anthropic(model, system_prompt, user_prompt): + yield chunk + return + async for chunk in _stream_openai_compatible(model, system_prompt, user_prompt): + yield chunk diff --git a/backend/services/document_export.py b/backend/services/document_export.py new file mode 100644 index 0000000..34fb58f --- /dev/null +++ b/backend/services/document_export.py @@ -0,0 +1,463 @@ +from io import BytesIO +from copy import deepcopy + +from docx import Document +from docx.document import Document as DocumentObject +from docx.oxml import OxmlElement +from docx.oxml.table import CT_Tbl +from docx.oxml.text.paragraph import CT_P +from docx.table import Table, _Cell +from docx.text.paragraph import Paragraph + + +def _iter_block_items(parent: DocumentObject | _Cell): + parent_elm = parent.element.body if isinstance(parent, DocumentObject) else parent._tc + for child in parent_elm.iterchildren(): + if isinstance(child, CT_P): + yield Paragraph(child, parent) + elif isinstance(child, CT_Tbl): + yield Table(child, parent) + + +def _is_heading(paragraph: Paragraph) -> bool: + style_name = paragraph.style.name if paragraph.style is not None else "" + normalized = style_name.lower().replace(" ", "") + return normalized.startswith("heading") + + +def _delete_block(block): + element = block._element + parent = element.getparent() + if parent is not None: + parent.remove(element) + + +def _delete_heading_section(heading: Paragraph): + blocks = [heading] + current = heading._element.getnext() + while current is not None: + if isinstance(current, CT_P): + para = Paragraph(current, heading._parent) + if _is_heading(para): + break + blocks.append(para) + elif isinstance(current, CT_Tbl): + blocks.append(Table(current, heading._parent)) + current = current.getnext() + for block in blocks: + _delete_block(block) + + +def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors: set[str]): + headings_to_remove: list[Paragraph] = [] + found_first_heading = False + pre_heading_blocks: list = [] + print(f"[EXPORT] referenced_anchors: {referenced_anchors}") + for block in _iter_block_items(document): + if isinstance(block, Paragraph) and _is_heading(block): + found_first_heading = True + text = block.text.strip() + if text not in referenced_anchors: + print(f"[EXPORT] Unreferenced heading found, will remove: '{text}'") + headings_to_remove.append(block) + elif not found_first_heading: + pre_heading_blocks.append(block) + for heading in headings_to_remove: + _delete_heading_section(heading) + if not referenced_anchors: + for block in pre_heading_blocks: + _delete_block(block) + + +def _ordered_unique_anchors(logs: list[dict]) -> list[str]: + ordered: list[str] = [] + seen: set[str] = set() + for item in logs: + anchor = (item.get("anchor_title") or item.get("title") or "").strip() + if not anchor or anchor in seen: + continue + seen.add(anchor) + ordered.append(anchor) + return ordered + + +def _reorder_heading_sections(document: DocumentObject, ordered_anchors: list[str]): + body = document.element.body + elements = list(body.iterchildren()) + pre_heading: list = [] + sections: list[tuple[str, list]] = [] + found_heading = False + index = 0 + + while index < len(elements): + child = elements[index] + if isinstance(child, CT_P): + paragraph = Paragraph(child, document) + if _is_heading(paragraph): + found_heading = True + anchor = paragraph.text.strip() + section_elements = [child] + index += 1 + while index < len(elements): + current = elements[index] + if isinstance(current, CT_P): + current_paragraph = Paragraph(current, document) + if _is_heading(current_paragraph): + break + section_elements.append(current) + index += 1 + sections.append((anchor, section_elements)) + continue + if not found_heading: + pre_heading.append(child) + index += 1 + + if not sections: + return + + section_map: dict[str, list[list]] = {} + for anchor, section_elements in sections: + section_map.setdefault(anchor, []).append(section_elements) + + all_section_elements = [element for _, section_elements in sections for element in section_elements] + for element in all_section_elements: + parent = element.getparent() + if parent is not None: + parent.remove(element) + + sect_pr = None + for child in list(body.iterchildren()): + if not isinstance(child, (CT_P, CT_Tbl)): + sect_pr = child + break + + for anchor in ordered_anchors: + for section_elements in section_map.pop(anchor, []): + for element in section_elements: + if sect_pr is not None: + sect_pr.addprevious(element) + else: + body.append(element) + + +def _clear_paragraph(paragraph: Paragraph): + element = paragraph._element + for child in list(element): + if child.tag.endswith("}r"): + element.remove(child) + + +def _copy_paragraph_format(target: Paragraph, source: Paragraph | None): + if source is None: + return + source_ppr = source._element.pPr + if source_ppr is not None: + target._element.insert(0, deepcopy(source_ppr)) + + +def _copy_run_format(target_run, source_paragraph: Paragraph | None): + if source_paragraph is None: + return + for source_run in source_paragraph.runs: + if source_run._element.rPr is not None: + target_run._element.insert(0, deepcopy(source_run._element.rPr)) + break + + +def _extract_first_run_format(source_paragraph: Paragraph | None): + if source_paragraph is None: + return None + for source_run in source_paragraph.runs: + if source_run._element.rPr is not None: + return deepcopy(source_run._element.rPr) + return None + + +def _set_paragraph_text( + paragraph: Paragraph, + text: str, + style_name: str | None = None, + template_paragraph: Paragraph | None = None, +): + run_format = _extract_first_run_format(template_paragraph) + _clear_paragraph(paragraph) + if style_name: + try: + paragraph.style = style_name + except Exception: + pass + if text: + run = paragraph.add_run(text) + if run_format is not None: + run._element.insert(0, run_format) + + +def _append_paragraph_after( + paragraph: Paragraph, + text: str, + style_name: str | None = None, + template_paragraph: Paragraph | None = None, +) -> Paragraph: + new_p = OxmlElement("w:p") + paragraph._element.addnext(new_p) + new_para = Paragraph(new_p, paragraph._parent) + _copy_paragraph_format(new_para, template_paragraph) + if style_name: + try: + new_para.style = style_name + except Exception: + pass + if text: + run = new_para.add_run(text) + _copy_run_format(run, template_paragraph) + return new_para + + +def _set_cell_text_with_template(cell, value: str, template_paragraph: Paragraph | None = None): + if not cell.paragraphs: + cell.text = value + return + paragraph = cell.paragraphs[0] + _clear_paragraph(paragraph) + run = paragraph.add_run(value) + _copy_run_format(run, template_paragraph) + + +def _resize_table_rows(table: Table, row_count: int): + current_rows = len(table.rows) + if current_rows == 0: + return + if current_rows < row_count: + template_row = table.rows[-1]._tr + for _ in range(row_count - current_rows): + table._tbl.append(deepcopy(template_row)) + elif current_rows > row_count: + for _ in range(current_rows - row_count): + table._tbl.remove(table.rows[-1]._tr) + + +def _fill_table(table: Table, matrix: list[list[str]]): + if not matrix: + return + _resize_table_rows(table, len(matrix)) + template_cell_paragraph = table.rows[0].cells[0].paragraphs[0] if table.rows and table.rows[0].cells else None + for row_index, row_values in enumerate(matrix): + row = table.rows[row_index] + for col_index, cell in enumerate(row.cells): + value = row_values[col_index] if col_index < len(row_values) else "" + _set_cell_text_with_template(cell, value, template_cell_paragraph) + + +def _append_table_after( + paragraph: Paragraph, + rows: list[list[str]], + headers: list[str] | None = None, + template_table: Table | None = None, +): + matrix = [headers, *rows] if headers else rows + if template_table is not None: + cloned_tbl = deepcopy(template_table._tbl) + paragraph._element.addnext(cloned_tbl) + cloned_table = Table(cloned_tbl, paragraph._parent) + _fill_table(cloned_table, matrix) + return cloned_table + + container = paragraph._parent + table = container.add_table(rows=max(len(matrix), 1), cols=max(len(headers or []), len(rows[0]) if rows else 1)) + if headers: + for row_index, row_values in enumerate(matrix): + for index, value in enumerate(row_values): + table.rows[row_index].cells[index].text = value + elif rows: + for row_index, row_values in enumerate(matrix): + for index, value in enumerate(row_values): + table.rows[row_index].cells[index].text = value + + tbl = table._tbl + tbl.getparent().remove(tbl) + paragraph._element.addnext(tbl) + return Table(tbl, container) + + +def _append_empty_paragraph_after_table(table: Table, style_name: str | None = None) -> Paragraph: + new_p = OxmlElement("w:p") + table._tbl.addnext(new_p) + new_para = Paragraph(new_p, table._parent) + if style_name: + try: + new_para.style = style_name + except Exception: + pass + return new_para + + +def _find_heading_paragraph(document: DocumentObject, heading_text: str, after_element=None) -> Paragraph | None: + started = after_element is None + for block in _iter_block_items(document): + if isinstance(block, Paragraph) and _is_heading(block) and block.text.strip() == heading_text.strip(): + if started: + return block + if after_element is not None and block._element == after_element: + started = True + return None + + +def _collect_section_templates(heading: Paragraph): + first_body_style = None + paragraph_template = None + table_template = None + blocks = [] + current = heading._element.getnext() + while current is not None: + if isinstance(current, CT_P): + current_paragraph = Paragraph(current, heading._parent) + if _is_heading(current_paragraph): + break + if first_body_style is None and current_paragraph.style is not None: + first_body_style = current_paragraph.style.name + if paragraph_template is None: + paragraph_template = current_paragraph + blocks.append(current_paragraph) + elif isinstance(current, CT_Tbl): + current_table = Table(current, heading._parent) + if table_template is None: + table_template = current_table + blocks.append(current_table) + current = current.getnext() + return first_body_style, paragraph_template, table_template, blocks + + +def _insert_content_after( + insert_after: Paragraph, + content: dict, + first_body_style: str | None, + paragraph_template: Paragraph | None, + table_template: Table | None, +): + current_anchor: Paragraph = insert_after + content_blocks = content.get("content", []) + for block in content_blocks: + block_type = block.get("type") + if block_type == "table": + rows = [list(row) for row in block.get("rows", [])] + headers = block.get("headers") or [] + table = _append_table_after(current_anchor, rows, headers, table_template) + current_anchor = _append_empty_paragraph_after_table(table, first_body_style) + else: + text = block.get("text", "") + text_parts = [item for item in text.split("\n") if item] or [text] + for text_part in text_parts: + current_anchor = _append_paragraph_after( + current_anchor, + text_part, + first_body_style, + paragraph_template, + ) + return current_anchor + + +def _replace_section_content( + document: DocumentObject, + anchor_title: str, + target_title: str, + content: dict, + write_mode: str, + after_element=None, +): + heading = _find_heading_paragraph(document, anchor_title, after_element) + if heading is None: + return after_element + _set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading) + first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading) + + if write_mode == "replace_heading_only": + return heading._element + + if write_mode == "replace_section": + for block in blocks_to_remove: + _delete_block(block) + + _insert_content_after( + heading, + content, + first_body_style, + paragraph_template, + table_template, + ) + return heading._element + + +def _group_logs(logs: list[dict]) -> list[list[dict]]: + groups: list[list[dict]] = [] + for item in logs: + anchor_title = item.get("anchor_title") or item.get("title") or "" + if not groups: + groups.append([item]) + continue + last_group = groups[-1] + last_anchor = last_group[0].get("anchor_title") or last_group[0].get("title") or "" + if anchor_title == last_anchor: + last_group.append(item) + else: + groups.append([item]) + return groups + + +def _replace_section_group( + document: DocumentObject, + items: list[dict], + after_element=None, +): + first_item = items[0] + anchor_title = first_item.get("anchor_title") or first_item.get("title") or "" + target_title = first_item.get("title") or anchor_title + heading = _find_heading_paragraph(document, anchor_title, after_element) + if heading is None: + return after_element + + _set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading) + first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading) + + if len(items) == 1 and first_item.get("write_mode") == "replace_heading_only": + return heading._element + + preserve_existing = len(items) == 1 and first_item.get("write_mode") == "append_after_heading" + if not preserve_existing: + for block in blocks_to_remove: + _delete_block(block) + + current_anchor = heading + for item in items: + current_anchor = _insert_content_after( + current_anchor, + item.get("content") or {"content": []}, + first_body_style, + paragraph_template, + table_template, + ) + return heading._element + + +def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes: + document = Document(BytesIO(template_bytes)) + ordered_anchors = _ordered_unique_anchors(logs) + _reorder_heading_sections(document, ordered_anchors) + + referenced_anchors: set[str] = set() + for item in logs: + for key in ("anchor_title", "title"): + val = (item.get(key) or "").strip() + if val: + referenced_anchors.add(val) + + print(f"[EXPORT] logs count={len(logs)}, anchor_titles={[(l.get('anchor_title'), l.get('title')) for l in logs]}") + + last_heading_element = None + for group in _group_logs(logs): + last_heading_element = _replace_section_group(document, group, last_heading_element) + + _remove_unreferenced_headings(document, referenced_anchors) + + output = BytesIO() + document.save(output) + return output.getvalue() diff --git a/backend/services/file_summary.py b/backend/services/file_summary.py new file mode 100644 index 0000000..487e4d8 --- /dev/null +++ b/backend/services/file_summary.py @@ -0,0 +1,110 @@ +import csv +import io +import json +import subprocess +import tempfile +from pathlib import Path + +import pandas as pd +from docx import Document + +from services.minio_client import download_object_bytes, split_bucket_path + +try: + from pypdf import PdfReader +except Exception: # pragma: no cover + PdfReader = None + + +def _decode_text(content: bytes) -> str: + for encoding in ("utf-8", "utf-8-sig", "gbk", "gb18030"): + try: + return content.decode(encoding) + except Exception: + continue + return content.decode("utf-8", errors="ignore") + + +def _summarize_docx(content: bytes) -> str: + doc = Document(io.BytesIO(content)) + texts = [paragraph.text.strip() for paragraph in doc.paragraphs if paragraph.text.strip()] + return "\n".join(texts[:40])[:4000] + + +def _summarize_csv(content: bytes) -> str: + text = _decode_text(content) + reader = csv.reader(io.StringIO(text)) + rows = list(reader[:20]) + return "\n".join([" | ".join(row) for row in rows])[:4000] + + +def _summarize_excel(content: bytes, suffix: str) -> str: + excel_buffer = io.BytesIO(content) + if suffix in {".xlsx", ".xlsm"}: + sheet_map = pd.read_excel(excel_buffer, sheet_name=None) + else: + sheet_map = pd.read_excel(excel_buffer, sheet_name=None, engine="xlrd") + parts: list[str] = [] + for sheet_name, dataframe in list(sheet_map.items())[:5]: + preview = dataframe.head(10).fillna("").astype(str) + parts.append(f"[工作表] {sheet_name}") + parts.append(preview.to_csv(index=False).strip()) + return "\n".join(parts)[:5000] + + +def _summarize_doc(content: bytes) -> str: + with tempfile.NamedTemporaryFile(suffix=".doc") as temp_file: + temp_file.write(content) + temp_file.flush() + result = subprocess.run( + ["textutil", "-convert", "txt", "-stdout", temp_file.name], + capture_output=True, + check=False, + ) + if result.returncode != 0: + stderr = result.stderr.decode("utf-8", errors="ignore").strip() + return f"旧版 Word 文件解析失败:{stderr or 'textutil 无法提取正文'}" + return _decode_text(result.stdout)[:4000] + + +def _summarize_pdf(content: bytes) -> str: + if PdfReader is None: + return "当前环境未安装 PDF 文本解析依赖,无法提取 PDF 正文。" + reader = PdfReader(io.BytesIO(content)) + texts: list[str] = [] + for page in reader.pages[:10]: + texts.append((page.extract_text() or "").strip()) + return "\n".join(filter(None, texts))[:4000] + + +def summarize_file_bytes(file_name: str, content: bytes) -> str: + suffix = Path(file_name).suffix.lower() + if suffix in {".txt", ".md", ".json"}: + return _decode_text(content)[:4000] + if suffix == ".csv": + return _summarize_csv(content) + if suffix in {".xlsx", ".xls", ".xlsm"}: + return _summarize_excel(content, suffix) + if suffix == ".docx": + return _summarize_docx(content) + if suffix == ".doc": + return _summarize_doc(content) + if suffix == ".pdf": + return _summarize_pdf(content) + return f"暂不支持解析该文件内容:{file_name}" + + +def summarize_minio_files(file_paths: list[str], file_name_mapping: dict[str, str] | None = None) -> list[dict]: + summaries: list[dict] = [] + for file_path in file_paths: + bucket, object_name = split_bucket_path(file_path) + content = download_object_bytes(bucket, object_name) + file_name = (file_name_mapping or {}).get(file_path) or Path(object_name).name + summaries.append( + { + "file_name": file_name, + "file_path": file_path, + "summary": summarize_file_bytes(file_name, content), + } + ) + return summaries diff --git a/backend/services/generation_runtime.py b/backend/services/generation_runtime.py new file mode 100644 index 0000000..535f4c5 --- /dev/null +++ b/backend/services/generation_runtime.py @@ -0,0 +1,197 @@ +import asyncio +import json +import time +from datetime import datetime + +from sqlalchemy import select + +from database import async_session +from models.ai_model import AiModel +from models.document import Document +from models.generation_log import GenerationLog +from models.paragraph import Paragraph +from models.template import Template +from services.ai_service import call_ai +from services.file_summary import summarize_minio_files + +generation_progress: dict[int, dict] = {} +generation_cancel_flags: dict[int, bool] = {} + + +def build_mock_content(paragraph: Paragraph) -> dict: + if paragraph.output_format == "table": + return { + "content": [ + { + "type": "table", + "title": paragraph.title, + "headers": ["字段", "内容"], + "rows": [ + ["段落标题", paragraph.title], + ["生成说明", paragraph.prompt_text or "根据模板内容生成"], + ], + } + ] + } + + blocks = [ + { + "type": "text", + "text": f"这是“{paragraph.title}”的示例生成内容,可用于前端联调与流程验证。" + } + ] + if paragraph.content: + blocks.append({"type": "text", "text": f"模板上下文:{paragraph.content[:200]}"}) + if paragraph.need_prompt and paragraph.prompt_text: + blocks.append({"type": "text", "text": f"预设提示词:{paragraph.prompt_text[:200]}"}) + return {"content": blocks} + + +async def get_effective_model(paragraph: Paragraph) -> AiModel | None: + async with async_session() as db: + if paragraph.model_id: + model = await db.get(AiModel, paragraph.model_id) + if model is not None and model.status == "enabled": + return model + result = await db.execute( + select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1) + ) + return result.scalars().first() + + +def update_progress(document_id: int, **kwargs): + state = generation_progress.setdefault( + document_id, + {"percent": 0, "status": "pending", "message": "等待中", "done": 0, "total": 0}, + ) + state.update(kwargs) + + +def request_cancel(document_id: int): + generation_cancel_flags[document_id] = True + update_progress(document_id, status="cancelling", message="正在取消...") + + +def is_cancel_requested(document_id: int) -> bool: + return generation_cancel_flags.get(document_id, False) + + +async def run_generation(document_id: int, template_id: int): + async with async_session() as db: + document = await db.get(Document, document_id) + template = await db.get(Template, template_id) + if document is None or template is None: + update_progress(document_id, status="failed", message="生成任务初始化失败") + return + + result = await db.execute( + select(Paragraph) + .where(Paragraph.template_id == template_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + paragraphs = result.scalars().all() + total = len(paragraphs) + request_payload = {} + if document.request_payload_json: + try: + request_payload = json.loads(document.request_payload_json) + except Exception: + request_payload = {} + file_map = request_payload.get("file_map", {}) if isinstance(request_payload, dict) else {} + update_progress(document_id, status="generating", total=total, done=0, percent=0, message="开始生成...") + + done_count = 0 + failed_count = 0 + try: + for index, paragraph in enumerate(paragraphs, start=1): + if is_cancel_requested(document_id): + document.status = "cancelled" + document.error = "用户已取消生成" + await db.commit() + update_progress(document_id, status="cancelled", percent=min(99, int(done_count / max(total, 1) * 100)), message="已取消生成", done=done_count) + return + + if paragraph.edit_mode == "manual": + content = {"content": [{"type": "text", "text": paragraph.content or "该段落为人工编辑模式。"}]} + status = "success" + duration = 0 + error_message = "" + model_id = paragraph.model_id + else: + start = time.perf_counter() + model = await get_effective_model(paragraph) + model_id = model.id if model is not None else paragraph.model_id + try: + selected_file_paths = file_map.get(str(paragraph.id), []) + file_summaries = [] + if selected_file_paths: + file_name_mapping = {} + for paragraph_item in request_payload.get("paragraphs", []): + for selected_file in paragraph_item.get("selected_files", []): + file_path = selected_file.get("file_path") + if file_path in selected_file_paths: + file_name_mapping[file_path] = selected_file.get("file_name") + file_summaries = await asyncio.to_thread( + summarize_minio_files, + selected_file_paths, + file_name_mapping, + ) + if model is None: + content = build_mock_content(paragraph) + else: + setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning)) + result_data = await call_ai(paragraph, model, file_summaries) + content = result_data.content + status = "success" + error_message = "" + except Exception as error: + content = build_mock_content(paragraph) + status = "failed" + error_message = str(error) + failed_count += 1 + duration = round(time.perf_counter() - start, 4) + + log = GenerationLog( + document_id=document.id, + paragraph_id=paragraph.id, + model_id=model_id, + status=status, + content=json.dumps(content, ensure_ascii=False), + duration=duration, + error_msg=error_message, + ) + db.add(log) + done_count += 1 + document.para_count_done = done_count + percent = int(done_count / max(total, 1) * 100) + update_progress( + document_id, + status="generating", + percent=percent, + done=done_count, + total=total, + current_paragraph=paragraph.title, + message=f"正在生成:{paragraph.title}", + ) + await db.commit() + + document.status = "completed" if failed_count == 0 else "failed" + document.error = "" if failed_count == 0 else f"{failed_count} 个段落生成失败,已回退为模拟结果。" + document.file_path = f"mock://document/{document.id}" + document.updated_at = datetime.now() + await db.commit() + update_progress( + document_id, + status=document.status, + percent=100, + done=done_count, + total=total, + message="生成完成" if failed_count == 0 else document.error, + ) + except Exception as error: + document.status = "failed" + document.error = str(error) + await db.commit() + update_progress(document_id, status="failed", message=str(error), done=done_count, total=total) + finally: + generation_cancel_flags.pop(document_id, None) diff --git a/backend/services/minio_client.py b/backend/services/minio_client.py new file mode 100644 index 0000000..ba01e4c --- /dev/null +++ b/backend/services/minio_client.py @@ -0,0 +1,74 @@ +import io +from datetime import timedelta + +from minio import Minio +from config import settings + +# MinIO 客户端 +minio_client = Minio( + settings.MINIO_ENDPOINT, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + secure=settings.MINIO_USE_SSL, +) + + +async def init_buckets(): + """初始化 MinIO 存储桶(应用启动时调用)""" + buckets = [ + settings.MINIO_BUCKET_TEMPLATES, # 原始模板文件 + settings.MINIO_BUCKET_UPLOADS, # 用户上传的参考文件 + settings.MINIO_BUCKET_OUTPUTS, # 生成的文档 + ] + for bucket in buckets: + if not minio_client.bucket_exists(bucket): + minio_client.make_bucket(bucket) + print(f"[MinIO] 创建存储桶: {bucket}") + + +def get_file_url(bucket: str, object_name: str) -> str: + """获取文件的公开访问 URL""" + if settings.MINIO_USE_SSL: + protocol = "https" + else: + protocol = "http" + return f"{protocol}://{settings.MINIO_ENDPOINT}/{bucket}/{object_name}" + + +def get_presigned_url(bucket: str, object_name: str, expires: int = 3600) -> str: + """获取预签名下载 URL(带过期时间)""" + return minio_client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=expires)) + + +def split_bucket_path(file_path: str) -> tuple[str, str]: + if "/" not in file_path: + raise ValueError("非法的 MinIO 文件路径") + return file_path.split("/", 1) + + +def download_object_bytes(bucket: str, object_name: str) -> bytes: + response = minio_client.get_object(bucket, object_name) + try: + return response.read() + finally: + response.close() + response.release_conn() + + +def upload_bytes( + bucket: str, + object_name: str, + content: bytes, + content_type: str = "application/octet-stream", +): + minio_client.put_object( + bucket, + object_name, + io.BytesIO(content), + len(content), + content_type=content_type, + ) + + +def delete_object(bucket: str, object_name: str): + minio_client.remove_object(bucket, object_name) diff --git a/backend/services/security.py b/backend/services/security.py new file mode 100644 index 0000000..2642c5c --- /dev/null +++ b/backend/services/security.py @@ -0,0 +1,35 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet, InvalidToken + +from config import settings + + +def _build_fernet() -> Fernet: + raw_key = settings.ENCRYPTION_KEY.encode("utf-8") + digest = hashlib.sha256(raw_key).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def encrypt_text(value: str) -> str: + if not value: + return "" + return _build_fernet().encrypt(value.encode("utf-8")).decode("utf-8") + + +def decrypt_text(value: str) -> str: + if not value: + return "" + try: + return _build_fernet().decrypt(value.encode("utf-8")).decode("utf-8") + except InvalidToken: + return "" + + +def mask_secret(value: str) -> str: + if not value: + return "" + if len(value) <= 7: + return "*" * len(value) + return f"{value[:3]}****{value[-4:]}" diff --git a/backend/services/template_parser.py b/backend/services/template_parser.py new file mode 100644 index 0000000..792741f --- /dev/null +++ b/backend/services/template_parser.py @@ -0,0 +1,282 @@ +import json +import re +from collections.abc import Iterator +from dataclasses import dataclass + +from docx import Document +from docx.document import Document as DocumentObject +from docx.oxml.ns import qn +from docx.oxml.table import CT_Tbl +from docx.oxml.text.paragraph import CT_P +from docx.table import Table +from docx.text.paragraph import Paragraph +from docx.enum.text import WD_ALIGN_PARAGRAPH + + +@dataclass +class ParsedParagraph: + sort_index: int + anchor_title: str + title: str + content: str + style_json: str + is_table: bool + table_json: str + write_mode: str + block_type: str = "text" + placeholder_key: str = "" + variable_key: str = "" + default_value: str = "" + edit_mode: str = "manual" + output_format: str = "text" + + +def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]: + body = document.element.body + for child in body.iterchildren(): + if isinstance(child, CT_P): + yield Paragraph(child, document) + elif isinstance(child, CT_Tbl): + yield Table(child, document) + + +def _safe_pt(value: object) -> float | None: + if value is None: + return None + try: + return round(float(value.pt), 2) + except AttributeError: + return None + + +def _safe_indent(value: object) -> float | None: + if value is None: + return None + try: + return round(float(value.pt), 2) + except AttributeError: + return None + + +def _alignment_name(value: WD_ALIGN_PARAGRAPH | None) -> str: + if value is None: + return "LEFT" + return getattr(value, "name", "LEFT") + + +def _heading_level(style_name: str) -> int | None: + if not style_name: + return None + normalized = style_name.lower().replace(" ", "") + if normalized.startswith("heading"): + level = normalized.replace("heading", "") + if level.isdigit(): + return int(level) + return None + + +def _get_run_font_info(paragraph: Paragraph) -> dict: + for run in paragraph.runs: + if not run.text.strip(): + continue + r_fonts = getattr(run._element.rPr, "rFonts", None) if run._element.rPr is not None else None + east_asia = r_fonts.get(qn("w:eastAsia")) if r_fonts is not None else None + color = None + if run.font.color is not None and run.font.color.rgb is not None: + color = str(run.font.color.rgb) + return { + "name": run.font.name, + "eastAsia": east_asia, + "size": _safe_pt(run.font.size), + "bold": bool(run.bold) if run.bold is not None else False, + "italic": bool(run.italic) if run.italic is not None else False, + "color": color or "000000", + } + return { + "name": None, + "eastAsia": None, + "size": None, + "bold": False, + "italic": False, + "color": "000000", + } + + +def _capture_paragraph_style(paragraph: Paragraph, level: int) -> dict: + fmt = paragraph.paragraph_format + return { + "font": _get_run_font_info(paragraph), + "paragraph": { + "alignment": _alignment_name(paragraph.alignment), + "spaceBefore": _safe_pt(fmt.space_before), + "spaceAfter": _safe_pt(fmt.space_after), + "lineSpacing": fmt.line_spacing, + "firstLineIndent": _safe_indent(fmt.first_line_indent), + }, + "headingLevel": level, + } + + +def _get_cell_style(cell) -> dict: + paragraph = cell.paragraphs[0] if cell.paragraphs else None + font_info = _get_run_font_info(paragraph) if paragraph is not None else { + "name": None, + "eastAsia": None, + "size": None, + "bold": False, + "italic": False, + "color": "000000", + } + return { + "font": font_info, + "shading": None, + "alignment": _alignment_name(paragraph.alignment) if paragraph is not None else "LEFT", + "borders": {"top": None, "bottom": None, "left": None, "right": None}, + } + + +def _extract_table_data(table: Table) -> dict: + rows = len(table.rows) + cols = max((len(row.cells) for row in table.rows), default=0) + grid_span: dict[str, int] = {} + cell_styles: list[dict] = [] + matrix: list[list[str]] = [] + + for row_index, row in enumerate(table.rows): + row_values: list[str] = [] + for col_index, cell in enumerate(row.cells): + text = "\n".join(paragraph.text.strip() for paragraph in cell.paragraphs if paragraph.text.strip()) + row_values.append(text) + tc_pr = cell._tc.tcPr + grid_span_value = None + if tc_pr is not None and tc_pr.gridSpan is not None: + grid_span_value = tc_pr.gridSpan.val + if grid_span_value: + grid_span[f"{row_index}-{col_index}"] = int(grid_span_value) + cell_styles.append(_get_cell_style(cell)) + matrix.append(row_values) + + return { + "rows": rows, + "cols": cols, + "gridSpan": grid_span, + "cellStyles": cell_styles, + "tableWidth": None, + "data": matrix, + } + + +PLACEHOLDER_PATTERN = re.compile(r"^\{\{\s*([a-zA-Z0-9_\-\.]+)\s*\}\}$") + + +def _build_block_title(text: str, fallback: str) -> str: + normalized = " ".join((text or "").split()) + if not normalized: + return fallback + return normalized[:24] + ("..." if len(normalized) > 24 else "") + + +def _classify_placeholder(text: str) -> tuple[str, str, str]: + matched = PLACEHOLDER_PATTERN.match(text.strip()) + if not matched: + return "text", "", "" + key = matched.group(1) + lowered = key.lower() + if any(token in lowered for token in ("summary", "opening", "section", "content", "analysis")): + return "ai_slot", key, "" + return "variable", "", key + + +def parse_template(file_path: str) -> list[ParsedParagraph]: + document = Document(file_path) + parsed: list[ParsedParagraph] = [] + current_heading: str | None = None + current_heading_style_json = "{}" + loose_table_count = 0 + body_block_count = 0 + preface_count = 0 + + for block in _iter_block_items(document): + if isinstance(block, Paragraph): + text = block.text.strip() + if not text: + continue + + level = _heading_level(block.style.name if block.style is not None else "") + if level is not None: + current_heading = text + current_heading_style_json = json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False) + body_block_count = 0 + parsed.append(ParsedParagraph( + sort_index=len(parsed) + 1, + anchor_title=text, + title=text, + content="", + style_json=current_heading_style_json, + is_table=False, + table_json="{}", + write_mode="replace_heading_only", + block_type="heading", + edit_mode="manual", + output_format="text", + )) + continue + + block_type, placeholder_key, variable_key = _classify_placeholder(text) + if current_heading is None: + preface_count += 1 + anchor_title = f"文档起始_{preface_count}" + title = _build_block_title(text, anchor_title) + write_mode = "replace_section" + else: + body_block_count += 1 + anchor_title = current_heading + title = _build_block_title(text, f"{current_heading}-正文{body_block_count}") + write_mode = "append_after_heading" + + parsed.append(ParsedParagraph( + sort_index=len(parsed) + 1, + anchor_title=anchor_title, + title=title, + content=text, + style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False), + is_table=False, + table_json="{}", + write_mode=write_mode, + block_type=block_type, + placeholder_key=placeholder_key, + variable_key=variable_key, + default_value="" if variable_key else text, + edit_mode="ai" if block_type == "ai_slot" else "manual", + output_format="text", + )) + else: + table_data = _extract_table_data(block) + table_text = f"[表格] {table_data['rows']} 行 {table_data['cols']} 列" + if current_heading is None: + loose_table_count += 1 + anchor_title = f"表格_{loose_table_count}" + title = anchor_title + write_mode = "replace_section" + else: + body_block_count += 1 + anchor_title = current_heading + title = f"{current_heading}-表格{body_block_count}" + write_mode = "append_after_heading" + + parsed.append(ParsedParagraph( + sort_index=len(parsed) + 1, + anchor_title=anchor_title, + title=title, + content=table_text, + style_json=current_heading_style_json if current_heading else "{}", + is_table=True, + table_json=json.dumps(table_data, ensure_ascii=False), + write_mode=write_mode, + block_type="table", + default_value=table_text, + edit_mode="manual", + output_format="table", + )) + + return parsed diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fbb89ef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +version: "3.8" + +services: + # === MySQL === + mysql: + image: mysql:8.0 + container_name: doc-forge-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root123 + MYSQL_DATABASE: doc_forge + MYSQL_USER: docforge + MYSQL_PASSWORD: docforge123 + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + + # === MinIO(对象存储)=== + minio: + image: minio/minio:latest + container_name: doc-forge-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: docforge + MINIO_ROOT_PASSWORD: docforge123 + ports: + - "9000:9000" # API + - "9001:9001" # Console + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 3 + + # === 后端 === + backend: + build: ./backend + container_name: doc-forge-backend + restart: unless-stopped + depends_on: + mysql: + condition: service_started + minio: + condition: service_healthy + environment: + DB_HOST: mysql + DB_PORT: 3306 + DB_USER: docforge + DB_PASSWORD: docforge123 + DB_NAME: doc_forge + MINIO_ENDPOINT: minio:9000 + MINIO_ACCESS_KEY: docforge + MINIO_SECRET_KEY: docforge123 + ENCRYPTION_KEY: "${ENCRYPTION_KEY}" + ports: + - "8000:8000" + volumes: + - ./backend:/app + - local_cache:/app/local_cache + + # === 前端 === + web: + build: ./web + container_name: doc-forge-web + restart: unless-stopped + depends_on: + - backend + ports: + - "5173:80" + +volumes: + mysql_data: + minio_data: + local_cache: diff --git a/docs/tasks/task_detail_2026_07_02.md b/docs/tasks/task_detail_2026_07_02.md new file mode 100644 index 0000000..da9f033 --- /dev/null +++ b/docs/tasks/task_detail_2026_07_02.md @@ -0,0 +1,239 @@ +# 任务执行摘要 + +## 会话 ID: local-20260702145410 +- [2026-07-02 14:54:10] +- **执行原因**: 按任务拆解清单逐项推进,优先打通模板管理与模板解析的第一条可用链路,并同步回填已完成状态。 +- **执行过程**: + 1. 核对任务拆解清单、开发规范和模板格式规范,确认项目约束与当前仓库状态。 + 2. 检查前后端现状,识别出前端脚手架、数据库模型和多页面壳子已存在,但后端核心路由仍为空。 + 3. 新增模板解析服务,基于 `python-docx` 实现标题识别、正文归并、样式提取、表格基础结构提取和结构化输出。 + 4. 实现模板 CRUD 路由,补齐模板列表、详情、上传解析、段落配置保存和删除能力,并接入 MinIO 模板存储。 + 5. 为后端补充统一错误响应格式与空路由占位,避免应用启动时报错。 + 6. 修复前端若干现存类型/图标问题,确保 `vue-tsc --noEmit` 可通过。 + 7. 更新任务拆解清单,标记本轮已确认完成的阶段项与子任务。 +- **执行结果**: 已完成模板 CRUD 路由和模板解析器基础能力,项目当前可通过后端语法检查与前端类型检查,任务清单已同步标注已完成项。 + +## 会话 ID: local-20260702145958 +- [2026-07-02 14:59:58] +- **执行原因**: 用户要求先提交当前代码,并继续完善到可以初步使用的程度。 +- **执行过程**: + 1. 将首批模板解析与模板管理相关改动整理后提交,提交信息使用中文。 + 2. 新增模型管理后端接口,支持模型列表、创建、更新、删除、状态切换与本地模拟测试。 + 3. 新增加密工具,按项目要求对 API Key 做 Fernet 形式加密存储并提供脱敏展示。 + 4. 新增生成与记录接口,支持单段测试、整份文档模拟生成、历史列表、详情预览、取消与删除。 + 5. 将预览页接入真实生成记录数据,将模板编辑页补齐“保存配置”和“立即测试”能力,并让历史页显示动态统计。 + 6. 更新任务拆解清单,补记“模型 CRUD 路由”已完成。 + 7. 再次执行后端语法检查与前端类型检查,确保本轮改动可用。 +- **执行结果**: 当前系统已可初步走通“上传模板 → 配置段落 → 管理模型 → 触发生成 → 查看记录/预览”的联调链路,导出仍为占位提示实现。 + +## 会话 ID: local-20260702150745 +- [2026-07-02 15:07:45] +- **执行原因**: 用户反馈项目已运行成功,要求将运行方式补充进 README,并继续完成后续任务。 +- **执行过程**: + 1. 重写 README,补充本地开发启动方式、Docker 启动方式、初步使用流程、常见问题与 `.idea` 误提交处理方案。 + 2. 扩展 MinIO 工具方法,补充对象路径拆分、字节下载、字节上传与预签名 URL 生成能力。 + 3. 新增 Word 导出服务,基于原始模板按标题定位段落并替换生成结果,支持基础文本与表格内容导出。 + 4. 更新导出路由,使“导出 Word”能够生成文件、上传到 MinIO 输出桶并返回可下载链接。 + 5. 再次执行后端语法检查与前端类型检查。 + 6. 同步更新任务拆解清单,标记当前前端页面中已实际可用的子项。 +- **执行结果**: README 已补全运行说明,系统新增基础 Word 导出能力,当前可以从预览页直接导出可下载的 Word 文件。 + +## 会话 ID: local-20260702151621 +- [2026-07-02 15:16:21] +- **执行原因**: 用户要求继续完善系统能力,并提交当前进展。 +- **执行过程**: + 1. 新增统一 AI 调用服务,支持 OpenAI 兼容接口与 Anthropic 接口两类模型调用。 + 2. 为 AI 调用补充 JSON 解析兜底、超时控制、重试机制与异常回退逻辑。 + 3. 将模型管理中的“连接测试”接入真实后端调用,并在前端增加测试与删除操作入口。 + 4. 将整份文档生成流程接入真实模型调用;当模型不可用或调用失败时,自动回退为模拟结果并记录失败状态。 + 5. 新增参考文件上传接口,将执行生成页的附件上传接入 MinIO,并把文件路径带入生成请求。 + 6. 更新 README 与任务拆解清单,标记真实模型调用与文件上传相关能力的完成状态。 + 7. 再次执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前系统已支持真实模型调用、模型连接测试和参考文件上传,生成链路从纯模拟升级为“真实调用优先、失败自动回退”的可用形态。 + +## 会话 ID: local-20260702152050 +- [2026-07-02 15:20:50] +- **执行原因**: 用户反馈模板列表页缺少编辑/删除入口,且模型测试被本机 SOCKS 代理环境阻断。 +- **执行过程**: + 1. 重写模板管理列表页,补齐“编辑”和“删除”操作入口,并接入删除确认提示。 + 2. 保持模板上传后自动跳转到编辑页,同时支持从列表直接进入模板编辑页。 + 3. 调整 AI 调用服务的 `httpx.AsyncClient` 配置,关闭 `trust_env`,避免读取系统 SOCKS 代理环境变量。 + 4. 再次执行后端语法检查与前端类型检查,验证修复稳定。 +- **执行结果**: `/templates` 页面现已支持直接编辑和删除模板,模型连接测试不再依赖系统 SOCKS 代理配置。 + +## 会话 ID: local-20260702152537 +- [2026-07-02 15:25:37] +- **执行原因**: 按继续完善要求,补齐生成过程中的实时进度展示与取消能力。 +- **执行过程**: + 1. 新增生成运行时服务,维护任务进度状态、取消标记和后台生成逻辑。 + 2. 将整份文档生成改为“创建任务后后台执行”,避免接口阻塞等待。 + 3. 新增 SSE 进度路由,向前端持续推送生成百分比、当前段落和状态变化。 + 4. 将执行生成页接入 `EventSource`,显示真实进度,并补充“取消生成”按钮。 + 5. 更新 README 与任务拆解清单,标记 SSE、取消生成、生成结果入库等已完成项。 + 6. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前生成流程已支持后台执行、SSE 实时进度推送和取消生成,执行页的进度展示从假进度升级为真实任务状态。 + +## 会话 ID: local-20260702152846 +- [2026-07-02 15:28:46] +- **执行原因**: 用户反馈 DeepSeek 模型连接测试返回 400,需要修正接口兼容逻辑。 +- **执行过程**: + 1. 对照 DeepSeek 官方文档检查 OpenAI 兼容接口地址格式。 + 2. 调整 OpenAI 兼容端点拼接逻辑,对 `api.deepseek.com` 特判为 `/chat/completions`,避免误拼成 `/v1/chat/completions`。 + 3. 补充模型调用错误信息,失败时输出更多响应正文,便于区分模型名错误、余额不足或参数不合法。 + 4. 执行后端语法检查,确认修复稳定。 +- **执行结果**: DeepSeek OpenAI 兼容地址的拼接逻辑已修正,后续模型测试若仍失败,将返回更具体的上游响应内容便于排查。 + +## 会话 ID: local-20260702153354 +- [2026-07-02 15:33:54] +- **执行原因**: 用户指出页面没有参考原型稿,需要开始按 [原型v3-HTML](/Users/zhouwentao/Workspaces/Yangliu/doc-forge/docs/原型v3-HTML/) 对齐界面。 +- **执行过程**: + 1. 重新阅读模板管理、模型管理、执行生成三个原型页面,提取顶部导航、面包屑、卡片、按钮和双栏布局结构。 + 2. 重写全局 `App.vue`,将侧边栏导航改为更接近原型的顶部导航结构。 + 3. 重写模板管理页,将表格列表改为原型风格的卡片式模板列表,并保留编辑、删除、前去生成等真实操作。 + 4. 重写模型管理页,将页面调整为原型风格的模型卡片列表,同时保留连接测试、启用禁用和编辑能力。 + 5. 重写执行生成页,使其更接近原型中的左侧模板概览 + 右侧文件配置 + 左下状态区布局,并保留真实 SSE 进度与取消生成能力。 + 6. 执行前端类型检查,确认本轮页面重构稳定。 +- **执行结果**: 现有三大主页面已开始按原型稿收口,整体信息层级和布局结构明显向原型靠齐,同时保留了当前已完成的真实业务能力。 + +## 会话 ID: local-20260702161641 +- [2026-07-02 16:16:41] +- **执行原因**: 用户指出模板编辑页尚未对齐原型编辑态,且“立即测试”未弹出文件选择并完成多文件测试链路。 +- **执行过程**: + 1. 重新核对模板管理原型中的三栏编辑态和段落测试三步弹窗交互。 + 2. 新增文件摘要服务,支持从 MinIO 下载并解析多种参考文件内容,包括 txt、md、csv、xlsx、xls、docx、pdf。 + 3. 调整 AI 调用服务,在请求模型时拼接“模板上下文 + 段落提示词 + 文件摘要”作为输入。 + 4. 更新段落测试接口,支持接收多文件路径、解析文件内容并把解析摘要返回前端展示。 + 5. 重写模板编辑页,使其更接近原型的三栏编辑结构,并实现“立即测试”三步弹窗、多文件上传、文件摘要展示和测试结果预览。 + 6. 增补 PDF 与老式 Excel 解析依赖,并执行后端语法检查与前端类型检查。 +- **执行结果**: 模板编辑页已更接近原型编辑态,“立即测试”现支持多文件上传、文件内容解析、带提示词调用 AI 模型并返回结果。 + +## 会话 ID: local-20260702163801 +- [2026-07-02 16:38:01] +- **执行原因**: 用户要求先提交代码后继续完善,并新增模型“是否支持流式传输”配置及模板测试流式返回能力。 +- **执行过程**: + 1. 先提交“模板测试弹窗与多文件解析链路”改动,保持工作区清晰。 + 2. 为模型表新增 `supports_streaming` 字段,并在数据库初始化时兼容旧表自动补列。 + 3. 更新模型创建、编辑、列表返回结构,以及模型管理页表单和展示,支持配置是否开启流式传输。 + 4. 扩展 AI 服务,新增 OpenAI / Anthropic 的流式输出能力。 + 5. 为段落测试新增流式接口;当模型开启流式能力时,模板编辑页测试弹窗在处理中阶段实时显示模型返回内容。 + 6. 执行后端语法检查与前端类型检查,确认本轮流式能力改动稳定。 +- **执行结果**: 当前系统已支持在模型配置中开启流式传输,并在模板编辑页对开启流式的模型进行实时测试返回。 + +## 会话 ID: local-20260702165813 +- [2026-07-02 16:58:13] +- **执行原因**: 用户希望在模型设置中增加“是否开启思考”能力,并确认是否能接入现有测试与生成流程。 +- **执行过程**: + 1. 为模型表、初始化 SQL、后端 Schema 和模型管理页补齐 `enable_reasoning` 字段,支持创建、编辑和展示思考模式开关。 + 2. 修正段落测试、流式测试、模型连接测试和整份文档生成链路,将模型上的思考模式显式传递给 AI 提示词构建逻辑。 + 3. 执行后端语法检查与前端类型检查,确认本轮改动稳定可用。 +- **执行结果**: 当前系统已支持在模型配置中开启或关闭思考模式,且会同时作用于模型连接测试、模板编辑页测试和正式生成流程。 + +## 会话 ID: local-20260702170209 +- [2026-07-02 17:02:09] +- **执行原因**: 用户希望在提交给 AI 的参考文件内容中显式带上文件名,便于模型理解每份内容对应的来源文件。 +- **执行过程**: + 1. 调整 AI 提示词中的参考文件拼接格式,将多文件上下文改为“文件:xxx”加“内容:...”的结构化文本。 + 2. 修正流式预览调用,确保测试流式场景也复用同一套系统提示词与文件上下文格式。 + 3. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前无论普通测试还是流式测试,AI 在接收参考文件时都能明确看到每个文件的文件名与对应内容摘要。 + +## 会话 ID: local-20260702170627 +- [2026-07-02 17:06:27] +- **执行原因**: 用户希望参考文件支持一次选择多个,并将已上传文件保存为系统历史,后续可直接复用或重新上传。 +- **执行过程**: + 1. 新增 `reference_files` 数据表与后端模型,在参考文件上传成功后持久化保存文件名、对象路径、大小、类型和创建时间。 + 2. 新增历史文件列表接口,支持按文件名搜索和分页读取已上传的参考文件记录。 + 3. 重写模板编辑页测试弹窗的文件区,保留多文件本地上传,同时新增历史文件库选择区,允许“历史文件 + 新上传文件”混合提交给 AI。 + 4. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前段落测试已支持多文件一起提交,且上传过的参考文件会进入系统历史库,后续可以直接勾选历史文件或重新上传新文件。 + +## 会话 ID: local-20260702171007 +- [2026-07-02 17:10:07] +- **执行原因**: 用户反馈上传参考文件时报“文件类型不支持”,需要补齐支持范围并提升报错可读性。 +- **执行过程**: + 1. 扩展后端参考文件白名单,补充 `.doc`、`.xlsm`、`.json` 等常见文件后缀。 + 2. 调整文件摘要服务,支持解析 `.xlsm`,并对 `.doc` 返回明确的转换建议说明。 + 3. 优化上传接口错误提示,返回实际不支持的扩展名和当前支持列表。 + 4. 同步更新模板测试弹窗中的上传提示文案,并执行后端语法检查与前端类型检查。 +- **执行结果**: 当前参考文件上传支持范围更完整,遇到不支持的文件类型时也会直接显示具体后缀和支持列表,便于快速判断问题。 + +## 会话 ID: local-20260702173843 +- [2026-07-02 17:38:43] +- **执行原因**: 用户要求先提交当前代码,并继续修复参考文件原名称丢失问题,同时核实 `.doc`、Excel、PDF 的内容提取能力。 +- **执行过程**: + 1. 提交当前阶段代码,提交信息为“完善模型测试与参考文件历史能力”,保持工作区清晰。 + 2. 修正参考文件摘要逻辑,优先从 `reference_files` 历史记录中读取原始文件名,不再把 MinIO 中的 UUID 文件名传给 AI。 + 3. 为 `.doc` 文件接入 macOS `textutil` 文本提取能力,补齐旧版 Word 文档正文解析。 + 4. 修正 Excel 提取分支,确保 `.xlsm` 与 `.xlsx` 走正确读取方式;保留现有 PDF 文本提取逻辑。 + 5. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前参考文件在提交给 AI 时会保留用户原始文件名;`.doc`、`.docx`、Excel、PDF 等常见文件均可进入提取链路,其中 Excel 和 PDF 原本已支持,本轮补强了 `.doc` 与 `.xlsm`。 + +## 会话 ID: local-20260702175543 +- [2026-07-02 17:55:43] +- **执行原因**: 用户提出系统缺少单独的附件历史菜单,希望可以直接查看已经上传过的文件。 +- **执行过程**: + 1. 新增前端附件历史页面,展示参考文件总数、当前页数量、文件名、对象路径、类型、大小和上传时间。 + 2. 将页面接入现有 `reference-files` 接口,支持按文件名搜索和分页查看。 + 3. 在顶部导航中新增“附件历史”菜单入口,并补充路由配置。 + 4. 执行前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前系统已新增独立的“附件历史”菜单,用户可直接查看和检索已上传的参考文件记录。 + +## 会话 ID: local-20260702181456 +- [2026-07-02 18:14:56] +- **执行原因**: 用户希望附件历史支持下载、改名、查看与删除,同时要求生成页面支持多文件和文件说明,并让生成任务脱离当前页面、在记录页可持续查看状态与附件映射。 +- **执行过程**: + 1. 扩展参考附件接口,新增附件下载、改名、删除能力,并在附件历史页补齐对应操作按钮。 + 2. 为生成任务数据模型新增请求快照字段,在提交任务时持久化保存模板名、段落文件说明以及每个段落选中的附件列表。 + 3. 调整正式生成运行时,让每个段落在后台生成时真正读取该段落绑定的多文件内容,并把原始文件名传给 AI。 + 4. 重写执行生成页,展示每个段落的文件说明,支持多文件上传与移除,并将页面改为“提交任务后立即转到任务详情”模式,不再绑死在提交页。 + 5. 重写任务详情页,支持查看任务状态、实时进度、段落与附件映射、已完成段落内容,以及在运行中取消任务。 + 6. 执行后端语法检查与前端类型检查,确认本轮改动稳定。 +- **执行结果**: 当前附件历史已支持基础管理;生成任务提交后会在后台继续执行,用户可离开提交页并在任务详情中持续查看状态、段落附件映射与生成结果。 + +## 会话 ID: local-20260702182202 +- [2026-07-02 18:22:02] +- **执行原因**: 用户反馈 MySQL 启动时报 `TEXT` 列不能设置默认值,需要修复 `request_payload_json` 字段的数据库兼容性。 +- **执行过程**: + 1. 调整 `documents.request_payload_json` 的模型定义,取消 `TEXT` 列默认值,改为允许空值并由代码层负责兜底。 + 2. 修正数据库初始化与自动补列逻辑,避免执行 `ALTER TABLE ... TEXT DEFAULT '{}'` 这一类 MySQL 非法 SQL。 + 3. 同步更新 `init.sql` 中 `documents` 表定义,去掉 `request_payload_json` 的默认值。 + 4. 执行后端语法检查,确认修复稳定。 +- **执行结果**: 当前 `request_payload_json` 字段已兼容 MySQL,应用启动时不会再因为 `TEXT DEFAULT` 语句失败。 + +## 会话 ID: local-20260702182417 +- [2026-07-02 18:24:17] +- **执行原因**: 用户反馈提交生成任务时报 `Object of type datetime is not JSON serializable`,需要修复任务快照里的附件时间字段序列化。 +- **执行过程**: + 1. 检查生成任务快照构建逻辑,确认报错来源于附件记录中的 `created_at` 为 `datetime` 对象。 + 2. 调整参考附件序列化方法,将 `created_at` 统一转为 ISO 字符串后再写入任务快照 JSON。 + 3. 执行后端语法检查,确认修复稳定。 +- **执行结果**: 当前生成任务提交时不会再因为附件记录中的 `datetime` 字段导致 JSON 序列化失败。 + +## 会话 ID: local-20260702182751 +- [2026-07-02 18:27:51] +- **执行原因**: 用户反馈导出的模板与原模板几乎一致,AI 结果没有正确写入,同时模板样式在导出后发生明显偏移。 +- **执行过程**: + 1. 检查导出链路,确认原实现采用“删除原内容后新建普通段落/表格”的方式回写,容易导致标题命中不稳和样式丢失。 + 2. 重构导出服务的段落替换逻辑,改为按标题顺序匹配模板中的章节,减少重复标题导致的误替换风险。 + 3. 调整导出时的文本回写方式,优先复用模板原有段落的段落属性与首个 run 的字体样式,再写入 AI 返回文本。 + 4. 调整导出时的表格回写方式,优先克隆模板原有表格结构并填充新数据,尽量保留表格外观和基础样式。 + 5. 执行后端语法检查,确认导出服务改动稳定。 +- **执行结果**: 当前导出链路已改为“尽量复用模板原始段落/表格样式后写入 AI 内容”,比此前的新建空白内容块方式更接近原模板样式,也更容易把 AI 结果正确写回文档。 + +## 会话 ID: local-20260702202424 +- [2026-07-02 20:24:24] +- **执行原因**: 用户希望用极简一句话概括当前项目功能状态,并说明明天的工作重点。 +- **执行过程**: + 1. 基于当前已完成能力,压缩总结项目现状。 + 2. 提炼明日优先事项,聚焦导出准确性与样式保真。 +- **执行结果**: 已形成简短状态说明,可作为明日继续开发的工作摘要。 + +## 会话 ID: local-20260702202807 +- [2026-07-02 20:28:07] +- **执行原因**: 用户希望将任务详情页改为“上 + 左右”结构,左侧显示段落,右侧显示生成结果预览。 +- **执行过程**: + 1. 重构任务详情页布局,将页面调整为顶部任务概览、下方左右分栏的结构。 + 2. 左侧增加段落列表与段落状态展示,并保留附件映射信息。 + 3. 右侧改为当前选中段落的结果预览区域,展示段落标题、状态、文件要求和生成内容。 + 4. 执行前端类型检查,确认本轮布局调整稳定。 +- **执行结果**: 当前任务详情页已改为“上 + 左右”结构,用户可在左侧切换段落,在右侧查看对应生成结果预览。 diff --git a/docs/tasks/task_detail_2026_07_03.md b/docs/tasks/task_detail_2026_07_03.md new file mode 100644 index 0000000..fb23a95 --- /dev/null +++ b/docs/tasks/task_detail_2026_07_03.md @@ -0,0 +1,164 @@ +# 任务执行摘要 + +## 会话 ID: local-20260703092828 +- [2026-07-03 09:28:28] +- **执行原因**: 用户要求提交当前已完成的预览页布局调整代码。 +- **执行过程**: + 1. 检查当前工作区改动,确认仅包含任务详情页左右布局调整和对应任务记录。 + 2. 整理并暂存相关文件,排除未跟踪的原型目录。 + 3. 准备使用中文提交信息完成本次代码提交。 +- **执行结果**: 当前改动已整理完成并准备提交,包含任务详情页“上 + 左右”结构调整。 + +## 会话 ID: local-20260703092933 +- [2026-07-03 09:29:33] +- **执行原因**: 用户希望预览页左右两栏固定在顶层显示,不随页面整体滚动,而是在局部区域内滚动。 +- **执行过程**: + 1. 调整任务详情页根容器高度和溢出策略,禁止页面整体滚动。 + 2. 为左右分栏区域设置固定可用高度和内部滚动容器,使左侧段落列表、右侧预览内容各自滚动。 + 3. 执行前端类型检查,确认布局调整稳定。 +- **执行结果**: 当前预览页已改为页面整体固定、左右两栏局部滚动的显示方式,顶部信息区域保持固定可见。 + +## 会话 ID: local-20260703093617 +- [2026-07-03 09:36:17] +- **执行原因**: 用户询问当前模板导入时如何识别 `.doc/.docx` 文件中的段落边界。 +- **执行过程**: + 1. 定位模板上传入口与解析服务,确认模板导入的实际文件格式限制。 + 2. 阅读 `template_parser` 实现,核对标题识别、正文归并和表格归属逻辑。 + 3. 结合设计文档整理当前段落识别规则与边界行为,准备向用户说明。 +- **执行结果**: 已确认当前模板导入仅支持 `.docx`;段落边界基于 Word 内置 `Heading` 样式识别,普通正文归并到最近标题下,表格归属最近段落或独立成段。 + +## 会话 ID: local-20260703093913 +- [2026-07-03 09:39:13] +- **执行原因**: 用户进一步询问特殊模板场景下,是否支持只修改标题、不修改标题下固定内容,以及是否可以手动调整段落与模板内容。 +- **执行过程**: + 1. 核对模板编辑页界面与保存逻辑,确认当前可编辑字段范围。 + 2. 检查后端模板保存 schema,确认是否支持手动拆段、合段或正文内容持久化编辑。 + 3. 基于现状整理可行产品方案,包括自动识别候选标题与人工微调两类路径。 +- **执行结果**: 已确认当前系统支持修改段落标题和生成配置,但暂不支持手动拆段/合段,也不支持在系统内直接编辑模板正文;可通过新增“标题仅替换”模式与手动段落调整能力满足该场景。 + +## 会话 ID: local-20260703094127 +- [2026-07-03 09:41:27] +- **执行原因**: 用户希望模板编辑阶段支持人工直接编辑模板内容,并讨论是否应改为在标题下方占位填充而非整段替换,同时询问在线文档编辑实现思路。 +- **执行过程**: + 1. 检查当前导出实现,确认模板内容替换的实际粒度与边界。 + 2. 结合现有解析和导出方式,评估“段落模式”与“手动编辑 Word 模式”的双模式方案。 + 3. 查阅腾讯文档相关公开资料,整理在线文档通常采用的协同编辑架构和导入导出模型。 +- **执行结果**: 已确认当前导出为标题间整段替换;建议新增“手动编辑模板内容”与“占位填充”能力,并采用结构化文档模型而非直接把 `.docx` 当在线编辑源格式处理。 + +## 会话 ID: local-20260703094614 +- [2026-07-03 09:46:14] +- **执行原因**: 用户确认实施模板编辑增强,要求支持“段落配置 / 手动编辑模板”切换,并改进 AI 内容写回 Word 的方式。 +- **执行过程**: + 1. 扩展段落数据结构,新增原始标题锚点和写入方式字段,并补充数据库自动迁移逻辑。 + 2. 改造模板编辑页,增加“段落配置 / 手动编辑模板”切换,支持直接编辑标题、正文和写入方式。 + 3. 改造导出逻辑,支持仅替换标题、标题下插入内容、替换整段三种写入模式,同时保持原始标题定位能力。 + 4. 执行后端编译检查与前端 `npm run build`,确认本轮改动可正常通过。 +- **执行结果**: 模板编辑页现已支持结构化手动编辑;导出时可按段落配置选择“整段替换 / 标题下插入 / 仅改标题”,能更好处理固定正文与 AI 生成内容并存的模板场景。 + +## 会话 ID: local-20260703094757 +- [2026-07-03 09:47:57] +- **执行原因**: 用户需要本轮模板编辑增强对应的数据库增量 SQL。 +- **执行过程**: + 1. 对照本轮后端模型与初始化脚本,确认实际新增的持久化字段。 + 2. 整理兼容现有数据的 `ALTER TABLE` 与回填语句,确保旧模板可正常迁移。 + 3. 记录增量说明,便于后续环境执行与核验。 +- **执行结果**: 已输出可直接执行的 MySQL 增量 SQL,包含 `paragraphs.anchor_title`、`paragraphs.write_mode` 两个新字段及历史数据回填语句。 + +## 会话 ID: local-20260703095307 +- [2026-07-03 09:53:07] +- **执行原因**: 用户质疑当前模板编辑仍像段落配置而非在线 Word 编辑,并询问是否可以手动插入新的 AI 段落。 +- **执行过程**: + 1. 重新核对模板解析逻辑,确认当前仍以 Word `Heading` 样式作为段落边界。 + 2. 核对导出写回逻辑,确认当前是围绕已识别标题区块进行替换或插入,而不是对文档块级结构进行自由编辑。 + 3. 基于用户反馈梳理下一阶段应改造为“块级在线文档编辑 + AI 占位段落”的方向。 +- **执行结果**: 已明确当前系统还不支持像腾讯文档那样手动插入新段落块;若要满足该诉求,应将模板编辑从“段落配置”升级为“文档块编辑”,支持新增 AI 段落占位、拆分正文块与固定块。 + +## 会话 ID: local-20260703095628 +- [2026-07-03 09:56:28] +- **执行原因**: 用户要求继续推进,支持在模板中手动拆块并插入 AI 段落。 +- **执行过程**: + 1. 扩展模板保存接口,支持创建新块、删除旧块,并按当前编辑顺序重排 `sort_index`。 + 2. 改造导出逻辑,按连续的 `anchor_title` 分组写回同一节内容,使一个原标题下可挂多个手动/AI 块。 + 3. 改造模板编辑页,在手动编辑模式下新增“在后面新增固定块 / AI 块 / 删除当前块”操作。 + 4. 执行后端编译检查与前端 `npm run build`,确认新增块编辑能力可正常通过构建。 +- **执行结果**: 当前模板编辑已支持把同一原标题下的内容手动拆成多个块,并插入新的 AI 块或固定块;导出时会按块顺序写回同一节内容,较之前更接近在线文档式的人工干预流程。 + +## 会话 ID: local-20260703095940 +- [2026-07-03 09:59:40] +- **执行原因**: 用户要求模板默认导入后全部识别为人工手动,而不是 AI 生成。 +- **执行过程**: + 1. 调整段落模型默认值与初始化脚本默认值,将 `edit_mode` 默认改为 `manual`。 + 2. 调整模板上传落库逻辑,显式将新导入段落设置为 `manual`,避免受历史数据库默认值影响。 + 3. 执行后端编译检查,确认默认值调整未引入语法或依赖问题。 +- **执行结果**: 新导入模板中的识别段落现在默认全部为人工手动;如需 AI 生成,需要用户在模板编辑页中显式切换对应块为 AI 模式。 + +## 会话 ID: local-20260703100253 +- [2026-07-03 10:02:53] +- **执行原因**: 用户反馈模板编辑页 `doc-edit-page` 没有随内容高度增长,导致内容超出纸张容器显示。 +- **执行过程**: + 1. 检查编辑页中部滚动区与纸张容器的 flex 布局关系,定位到默认纵向拉伸导致纸张高度被固定。 + 2. 调整 `center-scroll` 的对齐方式为顶部对齐,并禁止 `doc-edit-page` 在 flex 布局中被压缩。 + 3. 执行前端 `npm run build`,确认样式修复后页面仍可正常构建。 +- **执行结果**: 模板编辑页中的纸张容器现在会按内容自然增高,不再因为父级 flex 拉伸而出现内容超出容器显示的问题。 + +## 会话 ID: local-20260703100656 +- [2026-07-03 10:06:56] +- **执行原因**: 用户询问执行生成页是否支持从历史文件中复用已上传附件。 +- **执行过程**: + 1. 检查 `GeneratePage.vue` 的文件上传区域与状态管理逻辑,确认当前前端入口能力。 + 2. 对照 `generateApi` 与后端 `reference-files` 接口,确认后端已有历史文件查询能力是否被生成页接入。 + 3. 整理当前支持范围与缺口,准备向用户说明现状与后续改造方向。 +- **执行结果**: 已确认生成页当前仅支持新上传文件,不支持在页面内选择历史文件复用;后端已有历史文件接口,但该页尚未接入对应 UI 与选择逻辑。 + +## 会话 ID: local-20260703104125 +- [2026-07-03 10:41:25] +- **执行原因**: 用户建议参考模板编辑中的历史文件复用能力,并封装成通用组件供执行生成页复用。 +- **执行过程**: + 1. 抽离公共 `ReferenceFileSelector` 组件,统一封装新上传、历史文件搜索复用、已选文件展示与移除逻辑。 + 2. 将模板编辑页段落测试弹窗接入该组件,替换原有分散的上传与历史文件逻辑。 + 3. 将执行生成页接入同一组件,使每个需上传文件的段落同时支持上传新文件和选择历史文件。 + 4. 执行前端 `npm run build`,确认组件复用后页面构建正常。 +- **执行结果**: 当前模板编辑测试弹窗与执行生成页已共用同一套文件选择组件;执行生成页现已支持历史文件复用,不再局限于本次新上传。 + +## 会话 ID: local-20260703104741 +- [2026-07-03 10:47:41] +- **执行原因**: 用户希望模型管理页支持厂商预设、DeepSeek 余额查看,以及将测试按钮改为带转圈的刷新式提示。 +- **执行过程**: + 1. 扩展模型前端 API 与 store,新增余额查询调用。 + 2. 在后端模型路由中新增 DeepSeek 余额查询接口,并基于已保存的 API Key 调用官方余额接口。 + 3. 改造模型管理页,新增 `DeepSeek / 自定义` 厂商预设、DeepSeek 余额展示与查询按钮。 + 4. 将测试按钮改为带 loading 的“刷新测试”,结果改为自动消失的轻提示,不再使用需要手动关闭的弹窗。 + 5. 执行后端编译检查与前端 `npm run build`,确认改动可正常构建。 +- **执行结果**: 模型管理页现已支持厂商预设;DeepSeek 模型可直接查询余额;测试按钮改为更轻量的刷新式交互,点击后会转圈并自动提示结果。 + +## 会话 ID: local-20260703104944 +- [2026-07-03 10:49:44] +- **执行原因**: 用户发现将厂商改成自定义后,页面仍被识别为 DeepSeek,且自定义厂商也出现余额查询能力。 +- **执行过程**: + 1. 排查模型管理页与后端余额接口的 DeepSeek 判定条件。 + 2. 将判定逻辑从“厂商或 endpoint 命中 DeepSeek”收紧为“仅当 provider 明确为 DeepSeek 时才视为 DeepSeek 模型”。 + 3. 执行后端编译检查与前端 `npm run build`,确认修正后功能正常。 +- **执行结果**: 当前只有在厂商明确设置为 `DeepSeek` 时,页面才会显示 DeepSeek 预设状态与余额查询按钮;改为自定义厂商后不会再被 endpoint 误判为 DeepSeek。 + +## 会话 ID: local-20260703111410 +- [2026-07-03 11:14:10] +- **执行原因**: 用户要求将当前阶段改动提交到 Git。 +- **执行过程**: + 1. 检查工作区变更,确认本轮后端、前端与任务记录文件可一并提交。 + 2. 排除未跟踪的原型目录,仅暂存本次功能实现相关文件。 + 3. 使用中文提交信息完成本次代码提交。 +- **执行结果**: 当前模板编辑、历史文件复用、模型管理增强等改动已整理完成,准备提交到本地 Git 历史。 + +## 会话 ID: local-20260703154400 +- [2026-07-03 15:44:00] +- **执行原因**: 用户需要在模板编辑器中支持段落删除与移动排序功能,同时修复删除后导出 Word 仍有残留段落的问题。 +- **执行过程**: + 1. 改造前端 TemplateEditor.vue,在左侧段落列表、段落配置预览区、手动编辑区三处新增上移/下移/删除按钮,hover 时显示。 + 2. 新增 canMoveUp/canMoveDown/moveUp/moveDown/handleDeleteParagraph 函数,移动用 splice 交换后 normalizeSortIndex,删除走 Modal.confirm 确认框。 + 3. 修正 canDeleteBlock 判定逻辑(原为同 anchor_title 下有 >1 个段落才能删,改为总段落数 >1 即可删)。 + 4. 新增 autoSaveParagraphs 函数,移动/删除后直接调 API 自动保存,同步 store 状态。 + 5. 修复后端 PUT /{template_id}/paragraphs 接口:删除段落前先级联删除 generation_logs,避免 FK 约束报错。 + 6. 修复后端导出逻辑 document_export.py:新增 _delete_heading_section 和 _remove_unreferenced_headings 函数,导出时清理未被 generation_logs 引用的标题段落,确保已删段落的原标题和内容不会残留在 Word 中。 + 7. 修复后端 DELETE /{template_id} 接口:级联清理 generation_logs、documents、paragraphs,解决删除整个模板时的 FK 约束失败。 + 8. 添加前后端调试日志辅助排查,确认功能正常后提交代码。 +- **执行结果**: 段落删除与移动排序功能完整实现,已删段落在生成导出后不再残留,模板删除 FK 约束已修复。提交 commit 1369d87。 diff --git a/docs/tasks/task_detail_2026_07_05.md b/docs/tasks/task_detail_2026_07_05.md new file mode 100644 index 0000000..33ab6ae --- /dev/null +++ b/docs/tasks/task_detail_2026_07_05.md @@ -0,0 +1,143 @@ +# 任务执行摘要 + +## 会话 ID: local-20260705193723 +- [2026-07-05 19:37:23] +- **执行原因**: 用户询问“执行生成”功能当前是如何实现文档导出的,希望梳理从提交生成到导出 Word 的实际代码链路。 +- **执行过程**: + 1. 检查执行生成页 `GeneratePage.vue`,确认前端提交任务的入口与跳转路径。 + 2. 检查后端 `generate.py` 与 `generation_runtime.py`,确认生成任务创建、后台执行和段落结果落库方式。 + 3. 检查预览页 `PreviewEdit.vue`、导出 API `export.py` 与 `document_export.py`,确认导出 Word 的触发点、模板读取方式和内容写回逻辑。 +- **执行结果**: 已确认“执行生成”页本身只负责创建后台生成任务;真正的 DOCX 导出发生在任务详情页点击“导出 Word”后,由后端从 MinIO 拉取原始模板、读取 `generation_logs`、按标题锚点写回内容,再上传导出文件并返回预签名下载链接。 + +## 会话 ID: local-20260705194530 +- [2026-07-05 19:45:30] +- **执行原因**: 用户要求先输出“在线编辑 + AI 选区 + 导出重构”的整体方案、任务清单,并评估是否可行实现。 +- **执行过程**: + 1. 参考现有 `03-任务拆解清单.md` 与 `04-后续迭代任务拆解清单.md` 的拆解风格,整理适合当前项目的阶段方案。 + 2. 结合现有模板解析、模板编辑与导出实现,识别当前架构与目标方案之间的差距。 + 3. 输出分阶段实施建议,并评估技术可行性、实现难点与优先级。 +- **执行结果**: 已形成可执行的重构方案:以“块级在线编辑 + AI 选区标记 + 模板源同步写回 + 精确导出”为主线,建议分为编辑器重构、块模型升级、写回引擎重构、生成链路适配与联调验收五个阶段推进;整体可行,但不建议一步直追腾讯文档式完整协同编辑。 + +## 会话 ID: local-20260705195210 +- [2026-07-05 19:52:10] +- **执行原因**: 用户要求将该方案整理成与 `03-任务拆解清单.md` 同风格的正式任务清单文档。 +- **执行过程**: + 1. 对照 `03-任务拆解清单.md` 的结构,统一“阶段 -> 子模块 -> 勾选项 -> 交付物”的表达方式。 + 2. 将“在线编辑模式、AI 选区模式、模板源同步写回、导出引擎重构”等内容拆成可执行任务项。 + 3. 新增正式文档到 `docs/需求与设计/`,便于后续按清单逐步实施。 +- **执行结果**: 已新增 `05-模板在线编辑重构任务拆解清单.md`,内容结构与 `03-任务拆解清单.md` 保持一致,可直接作为后续实施清单使用。 + +## 会话 ID: local-20260705200540 +- [2026-07-05 20:05:40] +- **执行原因**: 用户要求开始按 `05-模板在线编辑重构任务拆解清单.md` 落实代码。 +- **执行过程**: + 1. 先从第一阶段“数据模型升级”入手,新增 `template_blocks` 数据模型、初始化 DDL 和启动时自动建表逻辑。 + 2. 改造模板接口返回结构,在保留旧 `paragraphs` 兼容的同时,新增 `blocks` 序列化输出。 + 3. 改造模板上传与保存逻辑,使新上传模板会自动同步生成块数据;保存段落时若前端尚未显式传块,则自动从段落重建块,确保兼容过渡。 + 4. 更新前端 `types/store`,接入 `blocks` 字段;补充本轮增量 SQL 文档,并将任务清单中已完成的数据模型项勾选。 + 5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit` 校验,确认本轮改动通过编译检查。 +- **执行结果**: 已完成“模板在线编辑重构”第一阶段中的数据模型升级基础设施,系统现已具备 `template_blocks` 持久化能力,并能在不破坏现有模板编辑流程的前提下为后续块级编辑器改造提供后端承载。 + +## 会话 ID: local-20260705202018 +- [2026-07-05 20:20:18] +- **执行原因**: 用户要求继续推进模板在线编辑重构,优先落实第二阶段的模板块级解析能力。 +- **执行过程**: + 1. 改造 `template_parser.py`,将导入解析从“按 Heading 聚合大段”调整为“标题块 + 正文块 + 表格块”的细粒度块流。 + 2. 为解析结果补充块元信息,包括 `block_type`、`placeholder_key`、`variable_key`、`edit_mode`、`output_format`,并对显式 `{{ xxx }}` 占位做初步分类。 + 3. 改造模板上传逻辑,创建 `Paragraph` 时同步写入更细粒度的导入结果,并基于解析结果直接生成 `template_blocks`。 + 4. 更新任务清单勾选状态,标记已完成的“标题块/正文块/表格块/块级 JSON 结构”等子项。 + 5. 再次执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认本轮解析器重构未引入编译错误。 +- **执行结果**: 当前模板导入阶段已具备初步块级解析能力,导入后的结构不再只是一整段聚合文本,而是更接近后续在线编辑所需的块流模型,为第一页内容拆分和 AI 选区改造打下了基础。 + +## 会话 ID: local-20260705203240 +- [2026-07-05 20:32:40] +- **执行原因**: 用户继续推进,要求将模板编辑页的“手动编辑模板”模式真正切换到块模型上。 +- **执行过程**: + 1. 改造 `TemplateEditor.vue` 的选择与渲染逻辑,引入 `blocks` 本地状态以及 `currentItems / selectedConfigItem` 计算属性。 + 2. 保留原“段落配置”模式兼容现有流程,同时让“手动编辑模板”模式改为基于 `blocks` 渲染左侧列表、中间编辑画布和右侧配置区。 + 3. 改造移动、删除、插入、自动保存与保存模板逻辑,使其在手动模式下可针对块结构生效,并将 `blocks` 一并提交到模板保存接口。 + 4. 为块编辑模式补充块类型标签、变量键/AI 占位键配置,以及块级测试时对 `source_paragraph_id` 的兼容校验。 + 5. 执行前端 `vue-tsc --noEmit` 与后端 `python3 -m py_compile`,确认本轮页面改造与保存链路通过编译检查。 +- **执行结果**: 模板编辑页当前已实现“段落模式 / 块模式”双轨运行;其中手动编辑模板模式已开始基于 `template_blocks` 工作,块列表、块画布与右侧配置面板能够联动,为下一步实现 AI 选区模式奠定了前端基础。 + +## 会话 ID: local-20260705204055 +- [2026-07-05 20:40:55] +- **执行原因**: 用户反馈模板编辑页保存时报 `PUT /templates/{id}/paragraphs 500`,并且进入模板编辑时页面空白。 +- **执行过程**: + 1. 根据报错 SQL 定位到 `template_blocks.content_json` 为 `TEXT` 字段,但上传/同步块时误将 Python `dict` 直接写入数据库。 + 2. 修正模板块构建逻辑,在 `_build_block_from_paragraph` 与 `_build_block_from_parsed_item` 中统一将 `content_json` 序列化为 JSON 字符串。 + 3. 为旧模板补充兼容逻辑:读取模板详情时若尚无 `blocks` 数据,则自动根据现有 `paragraphs` 重建块数据并写回数据库。 + 4. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认修复后无编译错误。 +- **执行结果**: 已修复模板保存时的 500 报错来源;老模板在没有 `blocks` 数据时也会自动补齐,模板编辑页不应再因块数据缺失而显示空白。 + +## 会话 ID: local-20260705204820 +- [2026-07-05 20:48:20] +- **执行原因**: 用户指出当前改造“页面上完全没区别”,要求明确已完成内容与可见效果之间的差距。 +- **执行过程**: + 1. 复盘本轮已落地内容,区分数据层/接口层改造与实际可见的界面改造。 + 2. 明确当前仍未完成的“可见功能”项,包括 AI 选区、块级专属工具条、变量块突出展示、块级样式差异等。 + 3. 准备将后续工作重心从底层铺设切换到用户可见的编辑体验改造。 +- **执行结果**: 已确认当前阶段主要完成了块模型、解析器与保存链路等基础设施,前端交互层仍缺少足够显著的视觉与操作变化;后续需优先补齐用户可感知的块级编辑与 AI 选区功能。 + +## 会话 ID: local-20260705205630 +- [2026-07-05 20:56:30] +- **执行原因**: 用户要求继续实施,并优先看到模板编辑页中“手动编辑模板”模式的可见变化。 +- **执行过程**: + 1. 改造 `TemplateEditor.vue` 顶部工具条,在手动模式下新增“新增标题块 / 正文块 / AI 块 / 变量块”操作入口。 + 2. 改造中间块画布的卡片样式,为标题块、正文块、AI 块、变量块、表格块提供不同的边框、背景和标识信息。 + 3. 调整左侧列表标签与右侧配置项,使块类型、AI 占位键、变量键、固定块信息在界面上可直接感知。 + 4. 补齐块模式下新增、删除、上移、下移的界面交互,并修正新增块时误写入旧 `paragraphs` 数组的问题。 + 5. 执行前端 `vue-tsc --noEmit` 与后端 `python3 -m py_compile`,确认可见层改造通过编译检查。 +- **执行结果**: 模板编辑页的手动模式现在已有明显的块级编辑视觉效果与块工具条,页面不再只是“底层换数据源但外观几乎不变”;用户可直接看到并操作标题块、正文块、AI 块和变量块。 + +## 会话 ID: local-20260705210520 +- [2026-07-05 21:05:20] +- **执行原因**: 用户追问当前是否真正完成“在线编辑文档效果”、执行生成为何未按模板段落顺序导出,以及模板编辑是否已直接影响源 `docx` 文件。 +- **执行过程**: + 1. 复核任务清单与当前代码链路,区分“块级编辑器界面改造”与“模板源写回 / 生成导出主链路改造”两个层面。 + 2. 核对 `generation_runtime.py` 与 `export.py`,确认当前执行生成和导出仍然基于旧 `paragraphs + generation_logs + template.file_path` 工作。 + 3. 核对 `templates.py`,确认当前模板编辑保存主要写入 `paragraphs` 与 `template_blocks`,尚未把编辑后的块结构回写到模板源 `docx`。 +- **执行结果**: 已明确当前“在线编辑文档效果”只完成了块级编辑器的可见前端基础,未完成模板源 `docx` 写回;执行生成和导出仍走旧段落链路,因此不会完全按新块顺序导出。这也是用户感知为“编辑后没有真正影响模板导出”的根本原因。 + +## 会话 ID: local-20260705211340 +- [2026-07-05 21:13:40] +- **执行原因**: 用户要求继续实施,优先打通“保存模板影响源 docx”和“执行生成/导出顺序跟块走”的主链路。 +- **执行过程**: + 1. 在 `templates.py` 中新增 `blocks -> paragraphs` 同步逻辑,使块顺序、块内容、AI/人工属性会反向更新旧 `Paragraph` 数据。 + 2. 在同一文件中新增模板快照写回逻辑:读取当前模板源 `docx`,按当前块顺序组装导出日志,通过 `export_document_bytes` 生成新的模板内容并覆盖回模板源文件。 + 3. 保留现有 `generation_runtime.py` 与 `export.py` 的旧段落链路不变,但通过同步段落顺序与内容,让执行生成和导出开始间接受到块顺序影响。 + 4. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认主链路改造通过编译检查。 +- **执行结果**: 当前保存模板后,后端已开始将 `template_blocks` 反向同步回 `paragraphs`,并尝试把当前块快照回写到模板源 `docx`;这为后续完全切换到 blocks 导出奠定了主链路基础,也开始让保存后的顺序和内容影响执行生成与导出。 + +## 会话 ID: local-20260705212155 +- [2026-07-05 21:21:55] +- **执行原因**: 用户反馈“调整段落顺序后,点击保存会恢复之前的段落”。 +- **执行过程**: + 1. 复核前后端保存链路,确认问题出在“段落模式调整了 `paragraphs`,但保存时仍把旧 `blocks` 一并提交,后端又按旧 `blocks` 覆盖回段落顺序”。 + 2. 为模板保存请求新增 `save_mode` 字段,明确区分 `paragraph` 与 `manual` 两种保存语义。 + 3. 调整后端保存逻辑:只有在 `manual` 模式下才按 `blocks` 覆盖段落;在 `paragraph` 模式下则以 `paragraphs` 为准重建块数据,避免旧块顺序反向覆盖。 + 4. 调整前端 `TemplateEditor.vue` 与 `template` store,在自动保存与保存模板时按当前编辑模式传递 `save_mode`,并在段落模式下不再提交旧块数组。 + 5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认修复通过编译检查。 +- **执行结果**: 已修复段落模式下“调整顺序后保存又恢复原顺序”的直接覆盖问题;当前段落模式保存会以 `paragraphs` 为准,不再被旧 `blocks` 顺序反向改写。 + +## 会话 ID: local-20260705213040 +- [2026-07-05 21:30:40] +- **执行原因**: 用户反馈“执行生成里还是旧顺序,并且每一段都会重复导出”。 +- **执行过程**: + 1. 通过容器内 MySQL 查询模板 5 的 `paragraphs`、`template_blocks` 与最近生成记录,确认数据库中的最新顺序实际上已同步为用户调整后的顺序。 + 2. 定位重复导出的根因:当前模板下每个块都是单独段落,但历史保存逻辑将所有非标题段统一标为 `append_after_heading`,导出时会保留旧正文再追加一次新正文,导致每段重复。 + 3. 在 `templates.py` 中新增块级写入方式解析逻辑:同一锚点下首个内容块使用 `replace_section`,后续同锚点块才使用 `append_after_heading`;标题块仍使用 `replace_heading_only`。 + 4. 立刻用当前模板详情数据回调一次 `PUT /templates/5/paragraphs`,触发模板 5 重新保存,使新的写入方式同步落库并回写模板快照。 + 5. 再次查询数据库确认模板 5 的 `paragraphs.write_mode` 已全部从错误的 `append_after_heading` 切换为 `replace_section`。 +- **执行结果**: 已修复模板 5 当前“每一段重复导出”的直接根因;数据库和最新生成链路所使用的模板顺序现已与用户调整后的顺序一致。后续需要重新发起新的生成任务,旧的历史生成记录不会自动变成新顺序与新导出结果。 + +## 会话 ID: local-20260705213910 +- [2026-07-05 21:39:10] +- **执行原因**: 用户要求继续完善,进一步降低导出链路对旧 `paragraphs` 顺序的依赖,减少排序与重复类 bug。 +- **执行过程**: + 1. 改造 `export.py`,让 DOCX 导出优先按 `template_blocks` 顺序组织导出日志,而不是完全依赖 `GenerationLog + Paragraph` 的旧顺序。 + 2. 在导出组装阶段加入块级写入方式解析逻辑:同锚点首块使用 `replace_section`,后续同锚点块才使用 `append_after_heading`,标题块使用 `replace_heading_only`。 + 3. 在 `document_export.py` 中新增章节重排逻辑,根据导出日志中的锚点顺序,先调整 Word 文档中各个 Heading section 的物理顺序,再执行正文替换与插入。 + 4. 保留无块数据时的旧段落导出回退逻辑,避免历史模板直接失效。 + 5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认主链路改造通过编译检查。 +- **执行结果**: 当前 DOCX 导出已开始优先按 `template_blocks` 顺序工作,并且在写回前会尝试重排 Word 标题区块顺序;这比之前仅替换原位置内容更接近“模板编辑后导出顺序真实变化”的目标。 diff --git a/docs/原型v3-HTML/index.html b/docs/原型v3-HTML/index.html new file mode 100644 index 0000000..9273a62 --- /dev/null +++ b/docs/原型v3-HTML/index.html @@ -0,0 +1,56 @@ + + + + + +AI 文档模板生成系统 · 原型 v3 + + + +
+

AI 文档模板生成系统

+

原型 v3 · 共 5 个独立页面,点击进入各功能模块

+ + +
+ + diff --git a/docs/原型v3-HTML/执行生成.html b/docs/原型v3-HTML/执行生成.html new file mode 100644 index 0000000..0540694 --- /dev/null +++ b/docs/原型v3-HTML/执行生成.html @@ -0,0 +1,727 @@ + + + + + +执行生成 · AI 文档模板生成系统 + + + + + +
+
+ + 模板管理 + 模型管理 + 执行生成 + 生成记录 +
+ v3.0 +
周文涛
+
+
+ +
+
+
+ +
+ +
+
+
选择模板
+
+
+ +
+
+
季度经济活动分析报告
+
8 个段落 · 3 个需上传参考文件 · 5 个可直接生成
+
+ +
+
+
8
+
总段落
+
+
+
5
+
自动生成
+
+
+
3
+
需要文件
+
+
+
+
+ + + + +
+ +
+
+
段落文件配置根据模板配置,以下段落需要上传参考文件
+
+ +
+
+
+ 1 + 一、主要经营指标完成情况 + Claude 3.5 +
+
+ 已上传 + +
+
+
备注:需上传财务部提供的季度财务数据报表(Excel格式,含各指标完成值)
+
+ +
+
+
+ 2 + 主要经营指标完成情况表 + GPT-4o +
+
+ 已上传 + +
+
+
备注:需上传各部门经营数据汇总表(Excel格式,含指标明细)
+
+ +
+
+
+ 3 + 附图:各季度营收趋势图 + DeepSeek-V3 +
+
+ 已上传 + +
+
+
备注:需上传营收数据季度表(Excel格式)
+
+ +
+
+ 4 + 二、成本费用分析 + Claude 3.5 + 无需上传文件 +
+
+
+
+ 5 + 三、存在的主要问题 + Claude 3.5 + 无需上传文件 +
+
+
+
+ 6 + 四、下一步工作措施 + Claude 3.5 + 无需上传文件 +
+
+
+ +
+ 3/3 个需上传文件的段落已准备就绪 · 3 个自动生成 + +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/docs/原型v3-HTML/模型管理.html b/docs/原型v3-HTML/模型管理.html new file mode 100644 index 0000000..e93ad6a --- /dev/null +++ b/docs/原型v3-HTML/模型管理.html @@ -0,0 +1,656 @@ + + + + + +模型管理 · AI 文档模板生成系统 + + + + + +
+
+ + 模板管理 + 模型管理 + 执行生成 + 生成记录 +
+ v3.0 +
周文涛
+
+
+ +
+
+
+ +
+
可用模型列表
+
+
C
Claude 3.5 Sonnet
Anthropic · api.anthropic.com
已启用
+
G
GPT-4o
OpenAI · api.openai.com
已启用
+
D
DeepSeek-V3
DeepSeek · api.deepseek.com
已启用
+
Q
通义千问 Max
阿里云 · dashscope.aliyuncs.com
已禁用
+
+
+
+
系统默认模型分配
+
+
+
+
+
+
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/docs/原型v3-HTML/模板管理.html b/docs/原型v3-HTML/模板管理.html new file mode 100644 index 0000000..b0d127f --- /dev/null +++ b/docs/原型v3-HTML/模板管理.html @@ -0,0 +1,792 @@ + + + + + +模板管理 · AI 文档模板生成系统 + + + + + +
+
+ + 模板管理 + 模型管理 + 执行生成 + 生成记录 +
+ v3.0 +
周文涛
+
+
+ +
+
+ +
+
+ +
+ 共 3 个模板 + +
+
+
+
季度经济活动分析报告 已编辑
+
含经营指标、成本分析、问题总结、工作措施等 8 个段落。3 个段落需上传参考文件。
+
最后编辑:2026-07-02 段落:8
+
+ + +
+
+
+
年度工作总结模板 编辑中
+
含工作概况、主要成绩、存在问题、明年计划等 6 个段落。
+
最后编辑:2026-06-28 段落:6
+
+ + +
+
+
+
新建模板 未开始
+
点击创建新模板,上传 Word 文件,配置段落。
+
段落:0
+
+
+
+
+
+ + + +
+ + + + + + + \ No newline at end of file diff --git a/docs/原型v3-HTML/生成记录.html b/docs/原型v3-HTML/生成记录.html new file mode 100644 index 0000000..424c34c --- /dev/null +++ b/docs/原型v3-HTML/生成记录.html @@ -0,0 +1,692 @@ + + + + + +生成记录 · AI 文档模板生成系统 + + + + + +
+
+ + 模板管理 + 模型管理 + 执行生成 + 生成记录 +
+ v3.0 +
周文涛
+
+
+ +
+
+
+ +
+
+
12
+
总生成次数
+
+
+
10
+
成功
+
+
+
1
+
中断/取消
+
+
+
1
+
失败
+
+
+
+ 全部生成记录 +
+ + +
+
+ +
+
+
+
+
2025年Q1经济活动分析报告_v3
+
季度经济活动分析报告 · 2026-07-02 14:35 · 6/6 段落
+
+ 成功 +
+ + +
+
+
+
+
+
2025年Q1经济活动分析报告_v2
+
季度经济活动分析报告 · 2026-07-01 17:20 · 4/6 段落
+
+ 失败 +
+ 生成中断,无法预览 +
+
+
+
+
+
2025年Q1经济活动分析报告_v1
+
季度经济活动分析报告 · 2026-06-30 10:30 · 6/6 段落
+
+ 成功 +
+ + +
+
+
+
+
+
年度工作总结_2026H1
+
年度工作总结模板 · 2026-06-28 14:30 · 5/5 段落
+
+ 成功 +
+ + +
+
+
+
+
+
2025年Q1经济活动分析报告_初稿
+
季度经济活动分析报告 · 2026-06-25 09:15 · 3/6 段落
+
+ 中断 + 用户手动取消 +
+
+
+
+
2024年度工作总结
+
年度工作总结模板 · 2026-06-20 16:00 · 5/5 段落
+
+ 成功 +
+ + +
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/docs/原型v3-HTML/预览编辑.html b/docs/原型v3-HTML/预览编辑.html new file mode 100644 index 0000000..42b9415 --- /dev/null +++ b/docs/原型v3-HTML/预览编辑.html @@ -0,0 +1,648 @@ + + + + + +预览编辑 · AI 文档模板生成系统 + + + + + +
+
+ + 模板管理 + 模型管理 + 执行生成 + 生成记录 +
+ v3.0 +
周文涛
+
+
+ +
+
+
+ +
+
+
+
+ 文档编辑 — 季度经济活动分析报告 +
+ + + +
+
+
+ BIU + + + + + + + + + + + + +
+
+
2025年第一季度经济活动分析报告
+
某某集团有限公司
+
一、主要经营指标完成情况 AI 生成 · Claude 3.5
+
+
AI 生成内容
+

本季度营业收入完成12.35亿元,同比上升8.7%;利润总额1.25亿元,同比上升5.3%;净利润1.02亿元,同比上升4.8%。主要原因是电量增长、成本控制有效以及市场销售情况改善。

+
+
主要经营指标完成情况表 AI 生成 · GPT-4o
+ + + + +
指标名称完成值同比完成率责任部门
营业收入(亿元)12.35+8.7%102.3%营销部
利润总额(亿元)1.25+5.3%100.8%财务部
发电量(亿度)45.67+6.2%101.2%运行部
+
二、成本费用分析 AI 生成 · Claude 3.5
+

本季度营业总成本为9.87亿元,同比上升6.2%。其中燃料成本4.23亿元,占比42.9%;人工成本1.86亿元,占比18.8%。

+
三、存在的主要问题 AI 生成 · DeepSeek-V3
+

一是部分指标完成质量不高;二是成本控制压力较大;三是设备老化问题突出。

+
四、下一步工作措施 AI 生成 · Claude 3.5
+

一是加强成本管控;二是强化设备管理;三是落实安全生产责任制。

+
+
+ 字数:1,023 字 · 最后保存:刚刚 +
+ + + +
+
+
+
+
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/提示词库/提示词模板.md b/docs/提示词库/提示词模板.md new file mode 100644 index 0000000..b586ce1 --- /dev/null +++ b/docs/提示词库/提示词模板.md @@ -0,0 +1,158 @@ +# AI 提示词库 + +本文档包含所有 AI 调用时使用的提示词模板。提示词分为系统级和段落级两层。 + +## 一、系统提示词 + +### 1.1 默认系统提示词(通用) + +``` +你是一个专业的企业文档撰写助手。你的任务是按照给定的段落标题和参考内容, +生成符合中文正式报告风格的段落内容。 + +要求: +1. 语言正式、客观、严谨,使用第三人称 +2. 逻辑清晰,层次分明 +3. 数据准确,引用上传文件中的实际数据 +4. 字数控制在 300-800 字之间 +5. 不要输出标题本身,只输出段落正文内容 +6. 如果正文需要分点描述,使用 1. 2. 3. 编号,不要使用无序列表 + +输出格式必须为 JSON: +{ + "content": [ + {"type": "text", "text": "正文内容..."} + ] +} + +如果需要输出表格,使用: +{ + "content": [ + {"type": "text", "text": "表格说明文字"}, + {"type": "table", "headers": ["列1","列2","列3"], "rows": [["数据1","数据2","数据3"]]} + ] +} +``` + +### 1.2 表格生成专用提示词 + +``` +请根据上传的数据文件,生成以下表格内容: +段落标题:{title} + +要求: +1. 表格列名清晰,数据准确 +2. 只输出表格内容,不要文字说明 +3. 如果有多组数据,优先合并到一张表中 + +输出格式: +{ + "content": [ + {"type": "table", "headers": ["列名1","列名2","..."], "rows": [["值1","值2","..."]]} + ] +} +``` + +### 1.3 报告摘要专用提示词 + +``` +请根据以下参考内容,生成一段简洁的摘要。 + +要求: +1. 概括核心要点,不超过 200 字 +2. 突出关键数据和结论 +3. 使用总分结构 + +输出格式: +{ + "content": [ + {"type": "text", "text": "摘要内容..."} + ] +} +``` + +## 二、段落预设提示词 + +### 2.1 经营指标分析 +``` +请根据上传的财务数据,生成"{title}"章节内容。 +要求包括以下方面: +1. 各核心指标的完成值 +2. 与上期/同期的同比变化 +3. 变化原因分析 +4. 存在的主要风险点 + +参考上下文: +{context} +``` + +### 2.2 成本费用分析 +``` +请根据上传的数据,生成成本费用分析内容。 +要求包括: +1. 各项成本的构成及占比 +2. 同比变化情况及原因 +3. 成本管控措施及成效 + +参考数据: +{context} +``` + +### 2.3 问题总结 +``` +请基于以下数据和背景,分析当前存在的主要问题和风险。 +要求: +1. 问题描述要具体,有数据支撑 +2. 分析问题产生的原因 +3. 指出风险等级和影响范围 + +参考内容: +{context} +``` + +### 2.4 工作措施 +``` +请针对上述问题,生成下一步工作措施。 +要求: +1. 措施具体可执行 +2. 明确责任主体 +3. 设定完成时限或目标值 +4. 措施之间逻辑递进 + +参考内容: +{context} +``` + +## 三、提示词拼接规则 + +### 3.1 最终 prompt 构成 +``` +[系统提示词] +--- +段落标题:{paragraph.title} +编辑方式:{paragraph.edit_mode} +输出格式:{paragraph.output_format} +--- +{paragraph.prompt_text} +--- +参考文件摘要: +{file_summary} +--- +参考上下文: +{paragraph.content} +``` + +### 3.2 参考文件摘要生成规则 +``` +读取上传的 Excel 文件: +1. 提取列名 + 前 10 行数据作为样本 +2. 统计数值列的和/均值/最大最小值 +3. 生成文本摘要 + +Excel 摘要示例: +"文件:财务数据报表.xlsx +包含 3 个工作表: +- Sheet1(使用中):列 [月份, 营业收入, 利润总额, 净利润],共 12 行数据 + 营业收入合计:148.2 亿元,月均 12.35 亿元 + 利润总额合计:15.0 亿元,月均 1.25 亿元 +``` diff --git a/docs/规范与约束/开发规范.md b/docs/规范与约束/开发规范.md new file mode 100644 index 0000000..ec86588 --- /dev/null +++ b/docs/规范与约束/开发规范.md @@ -0,0 +1,105 @@ +# AI 文档模板生成系统 · 开发规范 + +## 一、代码规范 + +### 1.1 Python 后端 +- Python 3.11+,使用类型注解 +- 文件命名:snake_case.py +- 类命名:PascalCase +- 函数/变量:snake_case +- 数据库表:小写复数(templates, paragraphs) +- 异步优先:async/await 贯穿全栈 + +### 1.2 TypeScript 前端 +- TypeScript 5.x,strict 模式 +- 文件命名:PascalCase.vue(组件),camelCase.ts(工具/API) +- 组件命名:多单词 PascalCase +- 变量/函数:camelCase +- 接口命名:I 开头或 PascalCase +- 使用 ` \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..d00f79b --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "ai-doc-template-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0", + "pinia": "^2.1.0", + "axios": "^1.6.0", + "ant-design-vue": "^4.1.0", + "@ant-design/icons-vue": "^7.0.0", + "dayjs": "^1.11.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.1.0", + "vue-tsc": "^2.0.0" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..2c9b242 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1384 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ant-design/icons-vue': + specifier: ^7.0.0 + version: 7.0.1(vue@3.5.39(typescript@5.9.3)) + ant-design-vue: + specifier: ^4.1.0 + version: 4.2.6(vue@3.5.39(typescript@5.9.3)) + axios: + specifier: ^1.6.0 + version: 1.18.1 + dayjs: + specifier: ^1.11.0 + version: 1.11.21 + pinia: + specifier: ^2.1.0 + version: 2.3.1(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)) + vue: + specifier: ^3.4.0 + version: 3.5.39(typescript@5.9.3) + vue-router: + specifier: ^4.3.0 + version: 4.6.4(vue@3.5.39(typescript@5.9.3)) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.0.0 + version: 5.2.4(vite@5.4.21)(vue@3.5.39(typescript@5.9.3)) + typescript: + specifier: ^5.3.0 + version: 5.9.3 + vite: + specifier: ^5.1.0 + version: 5.4.21 + vue-tsc: + specifier: ^2.0.0 + version: 2.2.12(typescript@5.9.3) + +packages: + + '@ant-design/colors@6.0.0': + resolution: {integrity: sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==} + + '@ant-design/icons-svg@4.5.0': + resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==} + + '@ant-design/icons-vue@7.0.1': + resolution: {integrity: sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==} + peerDependencies: + vue: '>=3.0.3' + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@3.6.1': + resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} + engines: {node: '>=10'} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/unitless@0.8.1': + resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@simonwep/pickr@1.8.2': + resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.39': + resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + + '@vue/compiler-dom@3.5.39': + resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + + '@vue/compiler-sfc@3.5.39': + resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + + '@vue/compiler-ssr@3.5.39': + resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.39': + resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + + '@vue/runtime-core@3.5.39': + resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==} + + '@vue/runtime-dom@3.5.39': + resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==} + + '@vue/server-renderer@3.5.39': + resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==} + peerDependencies: + vue: 3.5.39 + + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ant-design-vue@4.2.6: + resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==} + engines: {node: '>=12.22.0'} + peerDependencies: + vue: '>=3.2.0' + + array-tree-filter@2.1.0: + resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + compute-scroll-into-view@1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dom-align@1.12.4: + resolution: {integrity: sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==} + + dom-scroll-into-view@2.0.1: + resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + is-plain-object@3.0.1: + resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanopop@2.4.2: + resolution: {integrity: sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scroll-into-view-if-needed@2.2.31: + resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} + + shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue-types@3.0.2: + resolution: {integrity: sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==} + engines: {node: '>=10.15.0'} + peerDependencies: + vue: ^3.0.0 + + vue@3.5.39: + resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + +snapshots: + + '@ant-design/colors@6.0.0': + dependencies: + '@ctrl/tinycolor': 3.6.1 + + '@ant-design/icons-svg@4.5.0': {} + + '@ant-design/icons-vue@7.0.1(vue@3.5.39(typescript@5.9.3))': + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-svg': 4.5.0 + vue: 3.5.39(typescript@5.9.3) + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@ctrl/tinycolor@3.6.1': {} + + '@emotion/hash@0.9.2': {} + + '@emotion/unitless@0.8.1': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@simonwep/pickr@1.8.2': + dependencies: + core-js: 3.49.0 + nanopop: 2.4.2 + + '@types/estree@1.0.9': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.39(typescript@5.9.3))': + dependencies: + vite: 5.4.21 + vue: 3.5.39(typescript@5.9.3) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.39 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.39': + dependencies: + '@vue/compiler-core': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-sfc@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.39 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.16 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.39': + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.39 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.39': + dependencies: + '@vue/shared': 3.5.39 + + '@vue/runtime-core@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/runtime-dom@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/runtime-core': 3.5.39 + '@vue/shared': 3.5.39 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.39(vue@3.5.39(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + vue: 3.5.39(typescript@5.9.3) + + '@vue/shared@3.5.39': {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + alien-signals@1.0.13: {} + + ant-design-vue@4.2.6(vue@3.5.39(typescript@5.9.3)): + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-vue': 7.0.1(vue@3.5.39(typescript@5.9.3)) + '@babel/runtime': 7.29.7 + '@ctrl/tinycolor': 3.6.1 + '@emotion/hash': 0.9.2 + '@emotion/unitless': 0.8.1 + '@simonwep/pickr': 1.8.2 + array-tree-filter: 2.1.0 + async-validator: 4.2.5 + csstype: 3.2.3 + dayjs: 1.11.21 + dom-align: 1.12.4 + dom-scroll-into-view: 2.0.1 + lodash: 4.18.1 + lodash-es: 4.18.1 + resize-observer-polyfill: 1.5.1 + scroll-into-view-if-needed: 2.2.31 + shallow-equal: 1.2.1 + stylis: 4.4.0 + throttle-debounce: 5.0.2 + vue: 3.5.39(typescript@5.9.3) + vue-types: 3.0.2(vue@3.5.39(typescript@5.9.3)) + warning: 4.0.3 + + array-tree-filter@2.1.0: {} + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.2: {} + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + compute-scroll-into-view@1.0.20: {} + + core-js@3.49.0: {} + + csstype@3.2.3: {} + + dayjs@1.11.21: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + dom-align@1.12.4: {} + + dom-scroll-into-view@2.0.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + is-plain-object@3.0.1: {} + + js-tokens@4.0.0: {} + + lodash-es@4.18.1: {} + + lodash@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.15: {} + + nanopop@2.4.2: {} + + path-browserify@1.0.1: {} + + picocolors@1.1.1: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.39(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.39(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@2.1.0: {} + + resize-observer-polyfill@1.5.1: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + scroll-into-view-if-needed@2.2.31: + dependencies: + compute-scroll-into-view: 1.0.20 + + shallow-equal@1.2.1: {} + + source-map-js@1.2.1: {} + + stylis@4.4.0: {} + + throttle-debounce@5.0.2: {} + + typescript@5.9.3: {} + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.16 + rollup: 4.62.2 + optionalDependencies: + fsevents: 2.3.3 + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.39(typescript@5.9.3)): + dependencies: + vue: 3.5.39(typescript@5.9.3) + + vue-router@4.6.4(vue@3.5.39(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.39(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue-types@3.0.2(vue@3.5.39(typescript@5.9.3)): + dependencies: + is-plain-object: 3.0.1 + vue: 3.5.39(typescript@5.9.3) + + vue@3.5.39(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-sfc': 3.5.39 + '@vue/runtime-dom': 3.5.39 + '@vue/server-renderer': 3.5.39(vue@3.5.39(typescript@5.9.3)) + '@vue/shared': 3.5.39 + optionalDependencies: + typescript: 5.9.3 + + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..5799512 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/web/src/api/generate.ts b/web/src/api/generate.ts new file mode 100644 index 0000000..d8c0614 --- /dev/null +++ b/web/src/api/generate.ts @@ -0,0 +1,19 @@ +import http from './index' + +export const generateApi = { + test: (data: any) => http.post('/generate/test', data), + testStream: () => '/api/v1/generate/test-stream', + upload: (formData: FormData) => http.post('/generate/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }), + referenceFiles: (params?: any) => http.get('/generate/reference-files', { params }), + updateReferenceFile: (id: number, data: any) => http.put(`/generate/reference-files/${id}`, data), + deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`), + downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`), + full: (data: any) => http.post('/generate/full', data), + progress: (id: number) => `/api/v1/generate/progress/${id}`, + cancel: (id: number) => http.post(`/generate/cancel/${id}`), + documents: (params?: any) => http.get('/generate/documents', { params }), + getDocument: (id: number) => http.get(`/generate/documents/${id}`), + deleteDocument: (id: number) => http.delete(`/generate/documents/${id}`), + exportDocx: (id: number) => `/api/v1/export/${id}/docx`, + exportPdf: (id: number) => `/api/v1/export/${id}/pdf`, +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..d3cfd41 --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,14 @@ +import axios from 'axios' + +const http = axios.create({ baseURL: '/api/v1', timeout: 30000 }) + +http.interceptors.response.use( + res => res.data, + err => { + const msg = err.response?.data?.message || err.message || '网络错误' + console.error('[API Error]', msg) + return Promise.reject({ code: -1, message: msg }) + } +) + +export default http diff --git a/web/src/api/model.ts b/web/src/api/model.ts new file mode 100644 index 0000000..8e188ce --- /dev/null +++ b/web/src/api/model.ts @@ -0,0 +1,10 @@ +import http from './index' + +export const modelApi = { + list: () => http.get('/models'), + create: (data: any) => http.post('/models', data), + update: (id: number, data: any) => http.put(`/models/${id}`, data), + delete: (id: number) => http.delete(`/models/${id}`), + test: (id: number) => http.post(`/models/${id}/test`), + balance: (id: number) => http.get(`/models/${id}/balance`), +} diff --git a/web/src/api/template.ts b/web/src/api/template.ts new file mode 100644 index 0000000..f05c173 --- /dev/null +++ b/web/src/api/template.ts @@ -0,0 +1,9 @@ +import http from './index' + +export const templateApi = { + list: (params?: any) => http.get('/templates', { params }), + get: (id: number) => http.get(`/templates/${id}`), + upload: (formData: FormData) => http.post('/templates/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }), + saveParagraphs: (id: number, data: any) => http.put(`/templates/${id}/paragraphs`, data), + delete: (id: number) => http.delete(`/templates/${id}`), +} diff --git a/web/src/components/DocPreview.vue b/web/src/components/DocPreview.vue new file mode 100644 index 0000000..770b666 --- /dev/null +++ b/web/src/components/DocPreview.vue @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/web/src/components/FileUploader.vue b/web/src/components/FileUploader.vue new file mode 100644 index 0000000..5d56dd4 --- /dev/null +++ b/web/src/components/FileUploader.vue @@ -0,0 +1,6 @@ + + diff --git a/web/src/components/ModelModal.vue b/web/src/components/ModelModal.vue new file mode 100644 index 0000000..b3473a5 --- /dev/null +++ b/web/src/components/ModelModal.vue @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/web/src/components/ParagraphConfig.vue b/web/src/components/ParagraphConfig.vue new file mode 100644 index 0000000..8d9c8aa --- /dev/null +++ b/web/src/components/ParagraphConfig.vue @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/web/src/components/ParagraphList.vue b/web/src/components/ParagraphList.vue new file mode 100644 index 0000000..e8612c1 --- /dev/null +++ b/web/src/components/ParagraphList.vue @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/web/src/components/ReferenceFileSelector.vue b/web/src/components/ReferenceFileSelector.vue new file mode 100644 index 0000000..0f15401 --- /dev/null +++ b/web/src/components/ReferenceFileSelector.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/web/src/components/TestModal.vue b/web/src/components/TestModal.vue new file mode 100644 index 0000000..cf27bc6 --- /dev/null +++ b/web/src/components/TestModal.vue @@ -0,0 +1,13 @@ + + diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..2e2e29f --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,2 @@ +/// +declare module "*.vue" { import type { DefineComponent } from "vue"; const comp: DefineComponent<{}, {}, any>; export default comp; } diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..d7bf28e --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import Antd from 'ant-design-vue' +import 'ant-design-vue/dist/reset.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(Antd) +app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..f9af17a --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,15 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { path: '/', redirect: '/templates' }, + { path: '/templates', name: 'TemplateList', component: () => import('@/views/TemplateList.vue') }, + { path: '/templates/:id/edit', name: 'TemplateEditor', component: () => import('@/views/TemplateEditor.vue') }, + { path: '/models', name: 'ModelManage', component: () => import('@/views/ModelManage.vue') }, + { path: '/generate', name: 'GeneratePage', component: () => import('@/views/GeneratePage.vue') }, + { path: '/attachments', name: 'AttachmentHistoryPage', component: () => import('@/views/AttachmentHistoryPage.vue') }, + { path: '/history', name: 'HistoryPage', component: () => import('@/views/HistoryPage.vue') }, + { path: '/preview/:id', name: 'PreviewEdit', component: () => import('@/views/PreviewEdit.vue') }, +] + +const router = createRouter({ history: createWebHistory(), routes }) +export default router diff --git a/web/src/stores/document.ts b/web/src/stores/document.ts new file mode 100644 index 0000000..11f3f01 --- /dev/null +++ b/web/src/stores/document.ts @@ -0,0 +1,17 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { generateApi } from '@/api/generate' +import type { Document } from '@/types' + +export const useDocumentStore = defineStore('document', () => { + const documents = ref([]) + const currentDoc = ref(null) + const loading = ref(false) + + async function fetchList(params?: any) { loading.value = true; try { const r: any = await generateApi.documents(params); documents.value = r.data?.items || r.data || [] } finally { loading.value = false } } + async function generateFull(data: any) { const r: any = await generateApi.full(data); return r.data } + async function cancel(id: number) { await generateApi.cancel(id) } + async function removeDoc(id: number) { await generateApi.deleteDocument(id); await fetchList() } + + return { documents, currentDoc, loading, fetchList, generateFull, cancel, removeDoc } +}) diff --git a/web/src/stores/model.ts b/web/src/stores/model.ts new file mode 100644 index 0000000..e2ea128 --- /dev/null +++ b/web/src/stores/model.ts @@ -0,0 +1,18 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { modelApi } from '@/api/model' +import type { AiModel } from '@/types' + +export const useModelStore = defineStore('model', () => { + const models = ref([]) + const loading = ref(false) + + async function fetchList() { loading.value = true; try { const r: any = await modelApi.list(); models.value = r.data || [] } finally { loading.value = false } } + async function create(data: any) { await modelApi.create(data); await fetchList() } + async function update(id: number, data: any) { await modelApi.update(id, data); await fetchList() } + async function remove(id: number) { await modelApi.delete(id); await fetchList() } + async function test(id: number) { return await modelApi.test(id) } + async function balance(id: number) { return await modelApi.balance(id) } + + return { models, loading, fetchList, create, update, remove, test, balance } +}) diff --git a/web/src/stores/template.ts b/web/src/stores/template.ts new file mode 100644 index 0000000..efd4e99 --- /dev/null +++ b/web/src/stores/template.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { templateApi } from '@/api/template' +import type { Template, Paragraph, TemplateBlock } from '@/types' + +export const useTemplateStore = defineStore('template', () => { + const templates = ref([]) + const currentTemplate = ref