Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ff51ac2f3 | |||
| be4000b774 |
+6
-1
@@ -1,4 +1,4 @@
|
||||
# Logs
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -39,3 +39,8 @@ env/
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
|
||||
# IDE directories
|
||||
.kilocode/
|
||||
.codex/
|
||||
@@ -0,0 +1,52 @@
|
||||
# 导出按钮缺失修复计划
|
||||
|
||||
## 问题分析
|
||||
当前 `action-buttons` 区域只有以下按钮可见:
|
||||
- 上传文件
|
||||
- 导入 Markdown
|
||||
- 导出 Markdown
|
||||
- 上传图片
|
||||
- AI 切换按钮
|
||||
|
||||
**缺失功能**:DOCX 和 PDF 导出按钮
|
||||
|
||||
## 调查结果
|
||||
1. ✅ 翻译文件中已存在 `exportDocx` 和 `exportPdf` 键名(src/utils/i18n.js)
|
||||
2. ❌ 模板中**完全缺失**这两个按钮的 HTML 代码
|
||||
3. ❓ 导出功能后端已实现,前端只需要添加调用接口的按钮
|
||||
4. ✅ 相关 CSS 样式已存在,按钮外观无需额外调整
|
||||
|
||||
## 实施计划
|
||||
|
||||
### 1. 添加 UI 按钮
|
||||
在 `src/components/MilkdownEditor.vue:79` 之后添加两个新按钮:
|
||||
- DOCX 导出按钮
|
||||
- PDF 导出按钮
|
||||
|
||||
按钮位置:
|
||||
```
|
||||
导出 Markdown → 导出 DOCX → 导出 PDF → 上传图片
|
||||
```
|
||||
|
||||
### 2. 实现前端导出功能
|
||||
使用已安装的依赖库:
|
||||
- `docx` 库:用于 DOCX 导出
|
||||
- `html2pdf.js` 库:用于 PDF 导出
|
||||
|
||||
需要添加的函数:
|
||||
```javascript
|
||||
const exportDocx = async () => {
|
||||
// 使用 docx 库实现导出
|
||||
}
|
||||
|
||||
const exportPdf = async () => {
|
||||
// 使用 html2pdf.js 实现导出
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 按钮图标
|
||||
- DOCX:使用文档图标
|
||||
- PDF:使用 PDF 专用图标
|
||||
|
||||
### 4. 状态管理
|
||||
添加加载状态和错误处理,与现有按钮保持一致风格
|
||||
@@ -1,4 +1,19 @@
|
||||
# 仓库指南
|
||||
# rules.md
|
||||
|
||||
在构建这个LLM应用网页时,你需要基于VUE3开发。我需要前端只运行渲染和数据回传,后端负责llm api调用,类似copilet的auto inline suggustions实现和数据解析。
|
||||
|
||||
# **重要** : 在回复用户消息时,一定要使用中文
|
||||
|
||||
## 指导原则
|
||||
|
||||
- 不要擅自用npm或者yarn运行网页,你既看不到网页的内容,也无法阻止命令暂停。但是,你可以用npm run build检查代码。
|
||||
- 应该保证代码效率,不多定义变量,不写冗余注释,把降低延迟放在第一位。
|
||||
- 每次完成任务前都要反复阅读检查代码,确保代码准确无误。
|
||||
- 尽量不要搜索关键字,而是了解代码结构后查询整个问题代码明确问题所在。
|
||||
- @/milkdown-docs/ 代表milkdown的最新官方文档,不要修改,涉及到前端编辑器的指令时要核对官方文档。
|
||||
|
||||
|
||||
# 仓库指南
|
||||
|
||||
## 语言约定
|
||||
项目文档、日志、错误提示以及对外返回的文字信息统一使用 **中文**。前端 UI 默认展示中文,若需多语言支持请在相应模块实现。
|
||||
@@ -48,5 +63,3 @@ dist/ # 构建产出(生成文件)
|
||||
- 按照 `backend/main.py` 中的实现,对上传文件的大小和类型进行校验,防止滥用。
|
||||
- 定期审计依赖安全(`npm audit`、`pip-audit`)。
|
||||
|
||||
---
|
||||
以上指南旨在保持贡献一致性并维护代码库健康,欢迎通过 Pull Request 提出改进。
|
||||
|
||||
@@ -1,814 +0,0 @@
|
||||
# llm-in-text 修复清单(匿名可用版)
|
||||
|
||||
## 说明
|
||||
|
||||
这不是审计报告。
|
||||
|
||||
这份文档只回答三件事:
|
||||
|
||||
1. 现在具体哪里有问题
|
||||
2. 问题为什么会发生
|
||||
3. 应该怎么改
|
||||
|
||||
前提按你的要求处理:
|
||||
|
||||
- 网站是匿名可用的
|
||||
- 不做用户登录
|
||||
- 不做用户身份体系
|
||||
- 但仍然要防止接口被滥用、站点被刷爆、服务被恶意调用
|
||||
|
||||
匿名可用不等于完全不做保护。
|
||||
|
||||
对于这种网站,正确做法通常是:
|
||||
|
||||
- 不做用户登录
|
||||
- 不在前端放任何真正的服务端秘密
|
||||
- 用服务端限流、来源限制、请求大小限制、网关策略保护接口
|
||||
- 必要时用站点级防刷手段,而不是用户级登录
|
||||
|
||||
---
|
||||
|
||||
## 1. 前端硬编码了服务端 API Key
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [src/utils/api.js:4](/C:/Users/ydy/Desktop/llm-in-text/src/utils/api.js#L4)
|
||||
- [src/utils/convert.js:3](/C:/Users/ydy/Desktop/llm-in-text/src/utils/convert.js#L3)
|
||||
|
||||
代码里把:
|
||||
|
||||
```js
|
||||
const API_KEY = 'your-secret-key-here'
|
||||
```
|
||||
|
||||
直接写进了前端源码。
|
||||
|
||||
### 错误原因
|
||||
|
||||
前端代码最终会发到浏览器里。
|
||||
|
||||
只要用户能打开网站,就一定能在浏览器开发者工具、打包产物、网络请求里看到这个 key。
|
||||
所以前端里的“密钥”根本不是密钥,只是公开字符串。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 任何人都可以绕过你的网站,直接写脚本刷你的后端
|
||||
- 这个 key 一旦被复制,就等于后端公开可调用
|
||||
|
||||
### 整改方式
|
||||
|
||||
你的场景不做登录,所以最简单、正确的做法是:
|
||||
|
||||
1. 删除前端里的 `API_KEY`
|
||||
2. 后端不要再要求前端传固定共享 key
|
||||
3. 改成下面这套匿名保护方案:
|
||||
- 只允许来自你站点域名的浏览器请求
|
||||
- 网关层限流
|
||||
- 接口级限流
|
||||
- 请求体大小限制
|
||||
- 必要时加站点级验证码或 challenge,而不是登录
|
||||
|
||||
### 你应该改成什么
|
||||
|
||||
- `src/utils/api.js` 不再发 `X-API-Key`
|
||||
- `src/utils/convert.js` 不再发 `X-API-Key`
|
||||
- `backend/main.py` 删除固定 `API_KEY` 和对应校验逻辑
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 全仓库搜不到 `your-secret-key-here`
|
||||
- 前端请求头中不再包含固定共享 key
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端 CORS 过宽
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [backend/main.py:34](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L34)
|
||||
- [backend/main.py:35](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L35)
|
||||
- [backend/main.py:36](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L36)
|
||||
- [backend/main.py:37](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L37)
|
||||
|
||||
现在配置是:
|
||||
|
||||
- `allow_origins=["*"]`
|
||||
- `allow_credentials=True`
|
||||
- `allow_methods=["*"]`
|
||||
- `allow_headers=["*"...]`
|
||||
|
||||
### 错误原因
|
||||
|
||||
这是开发期常见的“先全开让它跑起来”的写法。
|
||||
但生产里这样做会让任何站点都能更容易发起跨域调用。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 其他网站更容易借你的浏览器接口能力
|
||||
- 以后一旦加 cookie、session、任何凭据,会立刻放大风险
|
||||
|
||||
### 整改方式
|
||||
|
||||
既然你是匿名站点,不做登录,那就更应该把跨域收紧:
|
||||
|
||||
1. 只允许你的正式域名和本地开发域名
|
||||
2. 不要开 `allow_credentials=True`,匿名站一般不需要
|
||||
3. 只开放需要的方法和头
|
||||
|
||||
### 建议改法
|
||||
|
||||
把:
|
||||
|
||||
```python
|
||||
allow_origins=["*"]
|
||||
allow_credentials=True
|
||||
allow_methods=["*"]
|
||||
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"]
|
||||
```
|
||||
|
||||
改成类似:
|
||||
|
||||
```python
|
||||
allow_origins=[
|
||||
"https://your-domain.com",
|
||||
"https://www.your-domain.com",
|
||||
"http://localhost:5173",
|
||||
]
|
||||
allow_credentials=False
|
||||
allow_methods=["POST", "OPTIONS"]
|
||||
allow_headers=["Content-Type", "X-Request-Id"]
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 非你自己域名的网页无法直接跨域调用你的接口
|
||||
- 不再开放无用头和无用方法
|
||||
|
||||
---
|
||||
|
||||
## 3. 默认会去拿用户公网 IP,并发送给后端
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [src/stores/settings.js:16](/C:/Users/ydy/Desktop/llm-in-text/src/stores/settings.js#L16)
|
||||
- [src/utils/api.js:54](/C:/Users/ydy/Desktop/llm-in-text/src/utils/api.js#L54)
|
||||
- [src/utils/api.js:100](/C:/Users/ydy/Desktop/llm-in-text/src/utils/api.js#L100)
|
||||
- [backend/main.py:110](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L110)
|
||||
|
||||
流程是:
|
||||
|
||||
1. 前端默认 `privacyMode = false`
|
||||
2. 前端请求 `https://api.ipify.org?format=json`
|
||||
3. 获取公网 IP
|
||||
4. 放进 `X-Client-IP`
|
||||
5. 后端再做地理位置推断
|
||||
|
||||
### 错误原因
|
||||
|
||||
这是把“个性化上下文”做成了默认行为。
|
||||
但对匿名站点来说,这不是必要信息。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 页面会额外访问第三方服务
|
||||
- 用户 IP 会进入你的请求链路
|
||||
- 模型上下文中会混入地理位置信息
|
||||
|
||||
### 整改方式
|
||||
|
||||
如果你不需要真正的按地理位置个性化,就最简单:
|
||||
|
||||
1. 删除 `getClientIP()`
|
||||
2. 删除调用 `api.ipify.org`
|
||||
3. 删除 `X-Client-IP`
|
||||
4. 后端删除 GeoIP 逻辑
|
||||
5. `privacyMode` 可以保留,但默认应是更安全的行为
|
||||
|
||||
### 你应该删什么
|
||||
|
||||
- `src/utils/api.js` 中的 `getClientIP`
|
||||
- `headers['X-Client-IP'] = clientIP`
|
||||
- `backend/main.py` 中 `get_client_ip`
|
||||
- `location = get_ip_location_text(client_ip)`
|
||||
- `geoip.py` 如果以后不用可以移除
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 前端网络面板中不再出现 `api.ipify.org`
|
||||
- 后端不再接收 `X-Client-IP`
|
||||
- prompt 不再包含用户位置
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端把内部异常原样返回给前端
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [backend/main.py:184](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L184)
|
||||
- [backend/main.py:251](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L251)
|
||||
- [backend/main.py:303](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L303)
|
||||
|
||||
现在写法是:
|
||||
|
||||
```python
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
```
|
||||
|
||||
### 错误原因
|
||||
|
||||
这是开发期为了调试方便常见的写法。
|
||||
但线上不应该把真实异常直接发给浏览器。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 暴露内部实现细节
|
||||
- 暴露依赖报错、路径、上游信息
|
||||
|
||||
### 整改方式
|
||||
|
||||
统一改成:
|
||||
|
||||
1. 前端只收到固定错误码和通用提示
|
||||
2. 后端日志里保留详细异常
|
||||
3. 返回 request id 方便排查
|
||||
|
||||
### 建议响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "UPSTREAM_TIMEOUT",
|
||||
"message": "Service temporarily unavailable",
|
||||
"request_id": "xxxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 前端不再收到 Python 原始报错
|
||||
- 日志可通过 request id 查到真实错误
|
||||
|
||||
---
|
||||
|
||||
## 5. `/v1/convert` 和 `/v1/ocr` 没有文件安全边界
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [backend/main.py:239](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L239)
|
||||
- [backend/main.py:268](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L268)
|
||||
- [backend/main.py:275](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L275)
|
||||
- [backend/main.py:281](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L281)
|
||||
- [src/utils/convert.js:22](/C:/Users/ydy/Desktop/llm-in-text/src/utils/convert.js#L22)
|
||||
|
||||
现在的问题是:
|
||||
|
||||
- 直接 base64 解码
|
||||
- 没有严格文件大小限制
|
||||
- 没有严格文件类型白名单
|
||||
- 没有魔数校验
|
||||
- 没有超时和并发保护
|
||||
|
||||
### 错误原因
|
||||
|
||||
当前实现是功能优先,默认相信前端传来的内容。
|
||||
但上传链路是最容易出问题的地方之一。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 大文件压垮内存
|
||||
- 恶意文件拖慢 CPU
|
||||
- 第三方库处理异常文件时出故障
|
||||
|
||||
### 整改方式
|
||||
|
||||
#### 对 `ocr`
|
||||
|
||||
1. 图片大小上限先改为 5MB 或 10MB
|
||||
2. 只允许 `jpg/png/webp`
|
||||
3. 服务端校验 MIME 和魔数
|
||||
4. 增加请求超时
|
||||
5. 增加并发限制
|
||||
|
||||
#### 对 `convert`
|
||||
|
||||
1. 只允许明确白名单格式
|
||||
2. 每种格式单独设大小上限
|
||||
3. 服务端检查扩展名和文件头
|
||||
4. `markitdown` 执行增加超时
|
||||
5. 临时文件放到独立目录
|
||||
6. 临时文件异常时也要清理
|
||||
|
||||
### 建议白名单
|
||||
|
||||
- `.pdf`
|
||||
- `.docx`
|
||||
- `.pptx`
|
||||
- `.xlsx`
|
||||
- `.md`
|
||||
- `.txt`
|
||||
|
||||
### 建议直接拒绝
|
||||
|
||||
- 可执行文件
|
||||
- 压缩包
|
||||
- 未知二进制
|
||||
- 超大图片
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 超限文件返回 413
|
||||
- 非法类型返回 415
|
||||
- OCR/convert 高并发下不会拖垮服务
|
||||
|
||||
---
|
||||
|
||||
## 6. 没有限流,匿名站点很容易被刷
|
||||
|
||||
### 具体问题
|
||||
|
||||
- 当前代码里没有 rate limit
|
||||
- 没有按 IP、UA、路径、时间窗做限制
|
||||
- `ACTIVE_COMPLETIONS` 只处理取消,不是限流器。[backend/main.py:29](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py#L29)
|
||||
|
||||
### 错误原因
|
||||
|
||||
因为现在的代码默认是“正常用户正常使用”。
|
||||
但匿名公网站点上线后,必须假设会被脚本反复调用。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 模型成本失控
|
||||
- CPU、内存、连接数被耗尽
|
||||
- 服务变慢甚至不可用
|
||||
|
||||
### 整改方式
|
||||
|
||||
匿名站点不做登录,标准保护方式是限流:
|
||||
|
||||
1. 反向代理层限流
|
||||
2. 应用层限流
|
||||
3. 高成本接口单独限流
|
||||
|
||||
### 建议策略
|
||||
|
||||
#### `/v1/completions`
|
||||
|
||||
- 单 IP 每分钟 20 到 60 次
|
||||
- 同时进行中的请求数限制 2 到 4 个
|
||||
|
||||
#### `/v1/ocr`
|
||||
|
||||
- 单 IP 每分钟 5 到 10 次
|
||||
- 同时进行中的 OCR 限制更低
|
||||
|
||||
#### `/v1/convert`
|
||||
|
||||
- 单 IP 每分钟 3 到 5 次
|
||||
- 强并发限制 1 到 2
|
||||
|
||||
### 还可以加什么
|
||||
|
||||
- Cloudflare Turnstile / hCaptcha 这类站点级防刷
|
||||
- 对明显机器人流量加 challenge
|
||||
|
||||
这不需要登录,也不需要用户体系。
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 连续脚本请求会命中 429
|
||||
- 单个来源无法无限刷接口
|
||||
|
||||
---
|
||||
|
||||
## 7. 模型调用没有明确超时与失败策略
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [backend/llm.py:15](/C:/Users/ydy/Desktop/llm-in-text/backend/llm.py#L15)
|
||||
|
||||
当前 `ollama.AsyncClient` 调用没有明显的统一超时和失败分类。
|
||||
|
||||
### 错误原因
|
||||
|
||||
开发阶段一般默认上游会正常返回。
|
||||
但生产里,上游模型服务经常会出现:
|
||||
|
||||
- 变慢
|
||||
- 卡住
|
||||
- 连接失败
|
||||
- 超时
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 请求挂很久
|
||||
- 连接被占住
|
||||
- 用户看起来像页面没响应
|
||||
|
||||
### 整改方式
|
||||
|
||||
1. completions 设置明确超时,例如 15 到 30 秒
|
||||
2. OCR 设置更短或更明确的处理时限
|
||||
3. convert 设置文件转换超时
|
||||
4. 把错误分成:
|
||||
- timeout
|
||||
- unavailable
|
||||
- bad response
|
||||
5. 前端对这些错误做不同提示
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 上游模型挂掉时,接口会快速失败而不是一直卡住
|
||||
|
||||
---
|
||||
|
||||
## 8. 缺少健康检查接口
|
||||
|
||||
### 具体问题
|
||||
|
||||
当前没有明确的:
|
||||
|
||||
- `/health/live`
|
||||
- `/health/ready`
|
||||
|
||||
### 错误原因
|
||||
|
||||
项目还是开发态,没有进入正式部署思路。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 你很难判断服务是否真的可用
|
||||
- 容器/进程平台无法正确探活
|
||||
|
||||
### 整改方式
|
||||
|
||||
增加两个接口:
|
||||
|
||||
#### `/health/live`
|
||||
|
||||
只表示“应用进程活着”
|
||||
|
||||
#### `/health/ready`
|
||||
|
||||
表示“应用准备好服务请求”
|
||||
|
||||
这个接口至少检查:
|
||||
|
||||
- 模型上游是否可连接
|
||||
- 关键配置是否存在
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 反向代理或容器平台能用它判断是否接流量
|
||||
|
||||
---
|
||||
|
||||
## 9. 预览组件有 XSS 风险
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [src/components/MarkdownPreview.vue:2](/C:/Users/ydy/Desktop/llm-in-text/src/components/MarkdownPreview.vue#L2)
|
||||
- [src/components/MarkdownPreview.vue:19](/C:/Users/ydy/Desktop/llm-in-text/src/components/MarkdownPreview.vue#L19)
|
||||
|
||||
现在做法是:
|
||||
|
||||
- `v-html`
|
||||
- `html: true`
|
||||
|
||||
### 错误原因
|
||||
|
||||
这意味着 markdown 里的原始 HTML 会被直接渲染。
|
||||
如果内容来源不完全可信,这就是典型 XSS 入口。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 恶意脚本执行
|
||||
- 页面被注入恶意 DOM
|
||||
|
||||
### 整改方式
|
||||
|
||||
你有两个选择:
|
||||
|
||||
#### 方案 A:最简单,直接关掉 HTML
|
||||
|
||||
把:
|
||||
|
||||
```js
|
||||
html: true
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```js
|
||||
html: false
|
||||
```
|
||||
|
||||
#### 方案 B:保留 HTML,但做净化
|
||||
|
||||
1. 引入 DOMPurify
|
||||
2. `md.render()` 后先 sanitize
|
||||
3. 再给 `v-html`
|
||||
|
||||
### 推荐
|
||||
|
||||
如果你不是必须支持原始 HTML,直接用方案 A。
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 恶意 markdown/HTML 不会执行脚本
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端默认配置会误连固定服务地址
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [src/utils/config.js:1](/C:/Users/ydy/Desktop/llm-in-text/src/utils/config.js#L1)
|
||||
- [.env.example:1](/C:/Users/ydy/Desktop/llm-in-text/.env.example#L1)
|
||||
- [backend/llm.py:12](/C:/Users/ydy/Desktop/llm-in-text/backend/llm.py#L12)
|
||||
|
||||
默认值里有固定公网域名和固定内网 IP。
|
||||
|
||||
### 错误原因
|
||||
|
||||
这是把“某次部署环境”写成了“代码默认值”。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 本地开发可能误连生产或旧环境
|
||||
- 新服务器部署时容易配错
|
||||
|
||||
### 整改方式
|
||||
|
||||
1. 默认值改成本地开发地址或空值
|
||||
2. 关键配置不存在时直接报错
|
||||
3. `.env.example` 只放模板,不放真实地址
|
||||
|
||||
### 建议
|
||||
|
||||
- `VITE_API_BASE_URL` 默认走同域,如 `''`
|
||||
- 前端优先使用 `/v1/...` 反代
|
||||
- 后端 `OLLAMA_HOST` 必须来自环境变量
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 不配置环境变量时,不会误连旧服务
|
||||
|
||||
---
|
||||
|
||||
## 11. 包版本和界面版本不一致
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [package.json:4](/C:/Users/ydy/Desktop/llm-in-text/package.json#L4) 是 `0.0.0`
|
||||
- [src/components/SettingsPanel.vue:275](/C:/Users/ydy/Desktop/llm-in-text/src/components/SettingsPanel.vue#L275) 写的是 `v0.1.0-beta`
|
||||
|
||||
### 错误原因
|
||||
|
||||
一个是包元数据,一个是手写展示文案,没人保证同步。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 发布后你都不确定线上到底是哪版
|
||||
|
||||
### 整改方式
|
||||
|
||||
1. 统一从 `package.json` 注入版本
|
||||
2. 前端不要手写版本号
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 页面显示版本和构建版本完全一致
|
||||
|
||||
---
|
||||
|
||||
## 12. `package.json` 缺少质量脚本
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [package.json:6](/C:/Users/ydy/Desktop/llm-in-text/package.json#L6)
|
||||
|
||||
当前只有:
|
||||
|
||||
- `dev`
|
||||
- `build`
|
||||
- `preview`
|
||||
|
||||
没有:
|
||||
|
||||
- `test`
|
||||
- `lint`
|
||||
- `check`
|
||||
|
||||
### 错误原因
|
||||
|
||||
项目还停留在“能运行”的阶段,没有建立质量门禁。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 任何改动都只能靠手工试
|
||||
- 回归问题容易漏
|
||||
|
||||
### 整改方式
|
||||
|
||||
至少补这些脚本:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "pytest backend/tests -q",
|
||||
"lint": "eslint src",
|
||||
"check": "npm run lint && npm run build && pytest backend/tests -q"
|
||||
}
|
||||
```
|
||||
|
||||
如果前端暂时没配 ESLint,也至少先把 `test` 和 `check` 建起来。
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 以后每次改代码前后都能统一执行 `check`
|
||||
|
||||
---
|
||||
|
||||
## 13. 测试覆盖不够,缺关键路径
|
||||
|
||||
### 具体问题
|
||||
|
||||
当前测试主要是:
|
||||
|
||||
- prompt 构造
|
||||
- 取消逻辑
|
||||
- LLM 消息结构
|
||||
|
||||
缺少:
|
||||
|
||||
- 匿名访问基本流程
|
||||
- 错误响应格式
|
||||
- OCR 文件限制
|
||||
- convert 文件限制
|
||||
- 限流
|
||||
- XSS
|
||||
|
||||
### 错误原因
|
||||
|
||||
现有测试更偏功能开发时的局部验证,不是上线前测试矩阵。
|
||||
|
||||
### 整改方式
|
||||
|
||||
补这些测试:
|
||||
|
||||
1. completions 正常返回
|
||||
2. completions 上游超时
|
||||
3. completions 请求超长
|
||||
4. OCR 非法类型
|
||||
5. OCR 超大图片
|
||||
6. convert 非法类型
|
||||
7. convert 超大文件
|
||||
8. 限流命中
|
||||
9. 未授权方案移除后,匿名访问可正常工作
|
||||
10. markdown 预览 XSS 样例
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 关键错误分支都有自动化测试
|
||||
|
||||
---
|
||||
|
||||
## 14. Service Worker 缓存策略还不够稳
|
||||
|
||||
### 具体问题
|
||||
|
||||
- [src/main.js:13](/C:/Users/ydy/Desktop/llm-in-text/src/main.js#L13)
|
||||
- [public/sw.js:1](/C:/Users/ydy/Desktop/llm-in-text/public/sw.js#L1)
|
||||
|
||||
当前是手写缓存逻辑,版本固定写死。
|
||||
|
||||
### 错误原因
|
||||
|
||||
这是一个能用的基础实现,但不适合长期生产维护。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 更新后可能缓存混乱
|
||||
- 老版本资源残留
|
||||
|
||||
### 整改方式
|
||||
|
||||
如果你不强依赖离线能力:
|
||||
|
||||
1. 先临时关闭 SW
|
||||
2. 等核心功能稳定后再重做 PWA
|
||||
|
||||
如果要保留:
|
||||
|
||||
1. 用成熟方案接管,如 Vite PWA / Workbox
|
||||
2. 资源按 hash 控制
|
||||
3. 做更新提示
|
||||
|
||||
### 推荐
|
||||
|
||||
如果现在重点是先上线稳定版,先停掉 SW 更省事。
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 用户刷新后不会出现随机旧资源
|
||||
|
||||
---
|
||||
|
||||
## 15. 构建体积偏大
|
||||
|
||||
### 具体问题
|
||||
|
||||
本次构建已经出现大 chunk 警告,尤其是 Mermaid 相关包比较重。
|
||||
|
||||
### 错误原因
|
||||
|
||||
图表、编辑器、语法高亮、数学渲染这类库本身就大。
|
||||
现在又没有足够按功能懒加载。
|
||||
|
||||
### 会导致什么
|
||||
|
||||
- 首屏慢
|
||||
- 弱网体验差
|
||||
|
||||
### 整改方式
|
||||
|
||||
1. Mermaid 按需加载
|
||||
2. 预览按需加载
|
||||
3. OCR / convert 相关 UI 按需加载
|
||||
4. 收敛 `manualChunks`
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 首页首次加载明显更轻
|
||||
|
||||
---
|
||||
|
||||
## 16. 匿名站点应该怎么做保护,而不是登录
|
||||
|
||||
这是你这个项目最关键的方向问题。
|
||||
|
||||
你不想做用户级网站,这完全可以。
|
||||
|
||||
那就按匿名站点的标准做:
|
||||
|
||||
### 必做
|
||||
|
||||
1. 去掉前端共享密钥
|
||||
2. 收紧 CORS
|
||||
3. 加 Nginx / Cloudflare / 网关限流
|
||||
4. 应用层再做限流
|
||||
5. 限制请求体大小
|
||||
6. 限制 OCR/convert 并发
|
||||
7. 错误信息脱敏
|
||||
8. 加健康检查
|
||||
9. 处理 XSS
|
||||
|
||||
### 可选
|
||||
|
||||
1. Cloudflare Turnstile
|
||||
2. 简单的人机验证 challenge
|
||||
3. 对高频匿名流量启用冷却时间
|
||||
|
||||
### 不必做
|
||||
|
||||
1. 登录
|
||||
2. 注册
|
||||
3. 用户系统
|
||||
4. JWT
|
||||
|
||||
只要你的目标是匿名工具站,而不是多租户平台,上面这套就够了。
|
||||
|
||||
---
|
||||
|
||||
## 最简修复顺序
|
||||
|
||||
如果你要最低成本把项目拉到“能较安全公开上线”的程度,建议顺序是:
|
||||
|
||||
1. 删除前端 API key 和后端固定 key 校验
|
||||
2. 删除 IP 获取和地理位置推断
|
||||
3. 收紧 CORS
|
||||
4. 统一错误响应
|
||||
5. 给 OCR/convert 加大小、类型、超时限制
|
||||
6. 加限流
|
||||
7. 修掉 `MarkdownPreview` 的 XSS 风险
|
||||
8. 增加 `/health/live` 和 `/health/ready`
|
||||
9. 补 `test` / `check` 脚本
|
||||
10. 视情况先关闭 service worker
|
||||
|
||||
---
|
||||
|
||||
## 这份清单对应的文件
|
||||
|
||||
- [backend/main.py](/C:/Users/ydy/Desktop/llm-in-text/backend/main.py)
|
||||
- [backend/llm.py](/C:/Users/ydy/Desktop/llm-in-text/backend/llm.py)
|
||||
- [src/utils/api.js](/C:/Users/ydy/Desktop/llm-in-text/src/utils/api.js)
|
||||
- [src/utils/convert.js](/C:/Users/ydy/Desktop/llm-in-text/src/utils/convert.js)
|
||||
- [src/components/MarkdownPreview.vue](/C:/Users/ydy/Desktop/llm-in-text/src/components/MarkdownPreview.vue)
|
||||
- [src/stores/settings.js](/C:/Users/ydy/Desktop/llm-in-text/src/stores/settings.js)
|
||||
- [src/components/SettingsPanel.vue](/C:/Users/ydy/Desktop/llm-in-text/src/components/SettingsPanel.vue)
|
||||
- [public/sw.js](/C:/Users/ydy/Desktop/llm-in-text/public/sw.js)
|
||||
- [package.json](/C:/Users/ydy/Desktop/llm-in-text/package.json)
|
||||
|
||||
@@ -1,579 +0,0 @@
|
||||
# llm-in-text 生产环境修复清单
|
||||
|
||||
## 文档目的
|
||||
|
||||
本文档是针对当前仓库在 2026-04-01 状态下基于代码的生产就绪性审查。
|
||||
|
||||
它回答三个问题:
|
||||
|
||||
1. 当前项目是否已准备好投入生产?
|
||||
2. 自上次审查以来已修复了哪些问题?
|
||||
3. 还有什么因素阻碍安全上线?
|
||||
|
||||
本次审查仅限于仓库中已有的内容和本地直接验证的内容,不包括外部基础设施、反向代理配置、云资源、CI 平台密钥或运行时运维的完整审计。
|
||||
|
||||
## 审查范围
|
||||
|
||||
- 前端构建和运行时入口
|
||||
- 后端 FastAPI 端点和模型集成
|
||||
- 补全、OCR 和文件转换请求路径
|
||||
- 隐私相关行为和本地存储
|
||||
- 基础测试覆盖率和构建验证
|
||||
- PWA/Service Worker 状态
|
||||
- 仓库中存在的部署和运维工件
|
||||
|
||||
## 已验证的事实
|
||||
|
||||
以下项目已针对当前仓库直接验证:
|
||||
|
||||
- 前端生产构建通过 `npm.cmd run build` 成功
|
||||
- 后端测试通过 `pytest backend/tests -q`
|
||||
- 当前后端测试结果:`8 passed, 1 skipped`
|
||||
- 存在健康检查端点:`/health/live` 和 `/health/ready`
|
||||
- 前端不再硬编码 API 密钥
|
||||
- 后端不再需要旧的 `X-API-Key` 头
|
||||
- 前端隐私模式现在默认为启用
|
||||
- 后端错误响应现在已规范化,不再返回原始异常字符串
|
||||
- OCR 和转换端点现在强制执行基本的文件大小和扩展名检查
|
||||
|
||||
## 当前评估
|
||||
|
||||
项目尚未准备好投入生产。
|
||||
|
||||
当前状态更接近于:
|
||||
|
||||
- 一个可用的原型
|
||||
- 内部演示
|
||||
- 具有部分加固的预发布候选版本
|
||||
|
||||
由于缺少几个核心生产基线,它尚未准备好面向互联网的生产使用:
|
||||
|
||||
- 真正的限流和并发保护
|
||||
- 正式的部署工件和运行时拓扑
|
||||
- CI/CD 和自动化质量门禁
|
||||
- 结构化的可观测性和告警
|
||||
- 更强的请求验证和更安全的文件处理隔离
|
||||
- 前端自动化测试和端到端验证
|
||||
|
||||
## 已修复的问题
|
||||
|
||||
与之前的清单相比,以下项目不再作为阻碍因素:
|
||||
|
||||
### 已修复:硬编码的前端/后端共享 API 密钥
|
||||
|
||||
- `src/utils/api.js` 不再发送 `X-API-Key`
|
||||
- `src/utils/convert.js` 不再发送 `X-API-Key`
|
||||
- `backend/main.py` 不再强制执行旧的静态密钥
|
||||
|
||||
这消除了上次审查中最严重的问题之一。
|
||||
|
||||
遗留问题:
|
||||
|
||||
- `backend/tests/test_main_cancel.py` 仍然包含过时的 `X-API-Key` 头,但它们目前是无效的,表明测试假设已过时而非活动中的认证逻辑
|
||||
|
||||
### 已修复:危险的通配符 CORS 配置
|
||||
|
||||
当前后端 CORS 限制为:
|
||||
|
||||
- `http://localhost:5173`
|
||||
- `http://localhost:3000`
|
||||
|
||||
并使用:
|
||||
|
||||
- `allow_credentials=False`
|
||||
- `allow_methods=["POST", "OPTIONS"]`
|
||||
- `allow_headers=["Content-Type", "X-Request-Id"]`
|
||||
|
||||
这比之前的通配符配置安全得多。
|
||||
|
||||
遗留问题:
|
||||
|
||||
- CORS 仍然针对本地开发硬编码,对于 staging/生产环境不是环境驱动的
|
||||
|
||||
### 已修复:默认收集前端公网 IP
|
||||
|
||||
- `src/stores/settings.js` 现在将 `privacyMode` 默认为 `true`
|
||||
- `src/utils/api.js` 不再调用 `ipify`
|
||||
- `src/utils/api.js` 不再发送 `X-Client-IP`
|
||||
- `backend/main.py` 不再将 IP 派生的位置注入提示词
|
||||
|
||||
遗留问题:
|
||||
|
||||
- `backend/geoip.py` 和 GeoLite 数据库仍然存在于仓库中,这可能会造成对当前隐私模型的混淆
|
||||
|
||||
### 已修复:向客户端暴露原始异常字符串
|
||||
|
||||
当前后端响应使用 `_error_response(...)` 和结构化载荷,例如:
|
||||
|
||||
- `INTERNAL_ERROR`
|
||||
- `OCR_FAILED`
|
||||
- `CONVERT_FAILED`
|
||||
- `FILE_TOO_LARGE`
|
||||
- `INVALID_FILE_TYPE`
|
||||
|
||||
这比直接返回 `str(exception)` 更好。
|
||||
|
||||
遗留问题:
|
||||
|
||||
- 日志仍然记录用户派生内容的预览,这是另一个隐私/可观测性问题
|
||||
|
||||
### 部分修复:上传和转换输入边界
|
||||
|
||||
`backend/main.py` 中的当前后端保护包括:
|
||||
|
||||
- OCR 大小上限:10 MB
|
||||
- 转换大小上限:50 MB
|
||||
- OCR 和转换的扩展名白名单
|
||||
- 在 `finally` 中清理临时转换文件
|
||||
|
||||
这是有意义的进展,但不足以投入生产。
|
||||
|
||||
## 生产阻碍因素
|
||||
|
||||
优先级含义:
|
||||
|
||||
- `P0`:必须在生产上线前完成
|
||||
- `P1`:应在公开发布或广泛推广前完成
|
||||
- `P2`:重要的后续加固和维护工作
|
||||
|
||||
---
|
||||
|
||||
## P0 阻碍因素
|
||||
|
||||
### P0-01 声明了限流和并发控制但未强制执行
|
||||
|
||||
当前状态:
|
||||
|
||||
- `backend/main.py` 定义了 `MAX_CONCURRENT_COMPLETIONS = 4`
|
||||
- `backend/main.py` 定义了 `COMPLETION_RATE_LIMIT = 60`
|
||||
- 没有任何实际的限流器使用这两个值
|
||||
- OCR 和转换端点也没有真正的每客户端节流
|
||||
|
||||
风险:
|
||||
|
||||
- 容易滥用昂贵的补全/OCR/转换端点
|
||||
- 可避免地对模型主机、CPU、内存和临时存储造成过载
|
||||
- 突发流量下无法控制降级
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 在应用或网关中强制执行真正的每路由限流
|
||||
2. 为补全作业添加真正的并发保护
|
||||
3. 为 `/v1/completions`、`/v1/ocr`、`/v1/convert` 添加单独预算
|
||||
4. 达到限制时返回明确的 `429`
|
||||
5. 为节流的请求和队列深度发出指标
|
||||
|
||||
验收标准:
|
||||
|
||||
- 重复的突发流量触发确定性的 `429`
|
||||
- 并发补全不能超过配置的预算
|
||||
- 负载测试下服务保持稳定
|
||||
|
||||
---
|
||||
|
||||
### P0-02 仓库中不存在生产部署基线
|
||||
|
||||
当前状态:
|
||||
|
||||
- 后端仅通过 `uvicorn.run(...)` 暴露开发式启动
|
||||
- 没有 `Dockerfile`
|
||||
- 没有 compose 文件
|
||||
- 没有 Kubernetes 清单或 Helm chart
|
||||
- 没有 systemd 单元
|
||||
- 没有反向代理参考配置
|
||||
- 没有记录的生产环境契约
|
||||
|
||||
风险:
|
||||
|
||||
- 没有可复现的部署路径
|
||||
- 没有明确的过程监督、重启或优雅的发布模型
|
||||
- 没有记录的 ingress/请求体大小/超时/TLS 姿态
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 定义一个官方部署目标
|
||||
2. 为该目标添加部署工件
|
||||
3. 记录所需的环境变量、端口、探针和存储
|
||||
4. 定义优雅关闭和发布行为
|
||||
5. 记录反向代理限制和信任边界
|
||||
|
||||
验收标准:
|
||||
|
||||
- 新环境可以从仓库文档和工件部署
|
||||
- 健康探针已接入所选运行时
|
||||
- 回滚路径已记录
|
||||
|
||||
---
|
||||
|
||||
### P0-03 缺少 CI/CD 和仓库质量门禁
|
||||
|
||||
当前状态:
|
||||
|
||||
- 仓库级别没有找到 `.github/workflows`
|
||||
- `package.json` 没有真正的前端测试脚本
|
||||
- 没有 lint 脚本
|
||||
- 没有类型检查脚本
|
||||
- 没有依赖扫描或密钥扫描工作流
|
||||
|
||||
风险:
|
||||
|
||||
- 回归只能手动捕获
|
||||
- 安全和打包漂移很可能发生
|
||||
- 生产就绪性取决于本地开发者的规范
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 为构建和测试添加 CI 工作流
|
||||
2. 添加前端自动化测试
|
||||
3. 在适用的情况下添加 lint 和类型检查门禁
|
||||
4. 添加依赖漏洞扫描
|
||||
5. 添加密钥扫描和基本 SAST
|
||||
|
||||
验收标准:
|
||||
|
||||
- 每个 PR 都运行构建、后端测试、前端测试和 lint
|
||||
- 失败的检查阻止合并
|
||||
|
||||
---
|
||||
|
||||
### P0-04 文件处理路径仍然缺乏生产级隔离
|
||||
|
||||
当前状态:
|
||||
|
||||
- 后端将完整 base64 载荷解码到内存中
|
||||
- 转换写入临时文件并将其传递给 `markitdown`
|
||||
- OCR 和转换主要依赖扩展名检查,而不是内容嗅探
|
||||
- 没有工作进程隔离或用于转换的单独沙箱
|
||||
- 转换任务没有队列或资源预算
|
||||
|
||||
风险:
|
||||
|
||||
- 大型或并发上传导致内存峰值
|
||||
- 畸形或对抗性文档会给解析器带来压力
|
||||
- 转换工作负载可能干扰核心补全可用性
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 明确验证 base64 解码失败
|
||||
2. 添加 MIME/内容嗅探,而不仅仅是扩展名检查
|
||||
3. 在入口和应用层添加更低的、路由特定的请求体限制
|
||||
4. 将转换隔离到单独的工作进程/进程边界
|
||||
5. 添加超时、并发上限和队列深度控制
|
||||
|
||||
验收标准:
|
||||
|
||||
- 畸形载荷失败并返回明确的 4xx 响应
|
||||
- 转换不能饿死补全服务
|
||||
- 压力下临时文件和内存增长保持有界
|
||||
|
||||
---
|
||||
|
||||
### P0-05 环境配置不一致且不安全
|
||||
|
||||
当前状态:
|
||||
|
||||
- 前端 `.env.example` 相当安全
|
||||
- 后端 `.env.example` 过时且与代码不一致
|
||||
- 代码读取 `OLLAMA_HOST`
|
||||
- 后端示例仍然使用 `OLLAMA_BASE_URL`
|
||||
- 后端示例仍然定义 `OPENAI_API_KEY=ollama`,这是误导性的
|
||||
|
||||
风险:
|
||||
|
||||
- 新环境配置不正确
|
||||
- 运营商可能假设不支持的认证/配置行为
|
||||
- staging/生产漂移很可能发生
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 用代码实际使用的变量替换后端环境示例
|
||||
2. 在启动时验证所需的环境变量
|
||||
3. 分离开发、staging 和生产环境契约
|
||||
4. 缺失关键配置时快速失败
|
||||
|
||||
验收标准:
|
||||
|
||||
- 示例环境文件匹配真实运行时行为
|
||||
- 无效或缺失关键配置导致启动失败
|
||||
|
||||
---
|
||||
|
||||
## P1 高优先级差距
|
||||
|
||||
### P1-01 日志仍然捕获用户派生内容预览
|
||||
|
||||
当前状态:
|
||||
|
||||
- `backend/main.py` 记录提示词派生的前缀和后缀预览
|
||||
- 补全结果记录包含内容预览
|
||||
- OCR 和转换记录文本预览长度和片段
|
||||
|
||||
风险:
|
||||
|
||||
- 日志可能包含敏感文档内容
|
||||
- 隐私姿态与应用可见的隐私设置不一致
|
||||
- 难以证明保留/合规姿态
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 默认情况下停止记录用户内容正文和预览
|
||||
2. 仅保留请求元数据:路由、请求 ID、状态、延迟、大小
|
||||
3. 引入结构化 JSON 日志
|
||||
4. 脱敏或哈希任何敏感标识符
|
||||
|
||||
验收标准:
|
||||
|
||||
- 默认日志不包含用户文档文本
|
||||
- 请求关联仍可通过请求 ID 和元数据工作
|
||||
|
||||
### P1-02 请求验证仍然过于宽松
|
||||
|
||||
当前状态:
|
||||
|
||||
- Pydantic 模型定义了字段但没有长度或枚举约束
|
||||
- `prefix`、`suffix`、`filename` 和 `reason` 受到极小约束
|
||||
- base64 字段在路由特定检查之前仍然可能非常大
|
||||
- 前端转换路径在将完整文件读入 base64 之前不执行预验证
|
||||
|
||||
风险:
|
||||
|
||||
- 过大或畸形的请求太容易到达昂贵的逻辑
|
||||
- 端点之间的 4xx 行为不一致
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 为 Pydantic 字段添加长度和枚举约束
|
||||
2. 明确验证 base64 格式
|
||||
3. 更防御性地规范化文件名处理
|
||||
4. 添加前端预检查大小/类型作为 UX
|
||||
|
||||
验收标准:
|
||||
|
||||
- 畸形请求尽早失败并返回确定性的 4xx 响应
|
||||
|
||||
### P1-03 前端自动化覆盖率基本缺失
|
||||
|
||||
当前状态:
|
||||
|
||||
- 后端有针对性的单元/集成风格测试
|
||||
- 前端没有配置测试运行器
|
||||
- 核心用户路径没有 E2E 覆盖率
|
||||
|
||||
重要说明:
|
||||
|
||||
- `backend/tests/test_main_cancel.py` 仍然发送过时的 `X-API-Key` 头;测试通过仅仅是因为后端忽略它们
|
||||
|
||||
风险:
|
||||
|
||||
- 编辑器、上传、OCR、转换和设置的回归将会遗漏
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 添加前端单元/组件测试
|
||||
2. 为补全、取消、上传、OCR 和转换添加 E2E 覆盖率
|
||||
3. 从后端测试中移除过时的认证假设
|
||||
|
||||
验收标准:
|
||||
|
||||
- 核心用户旅程在 CI 中自动覆盖
|
||||
|
||||
### P1-04 健康检查端点存在,但就绪性浅且缺少可观测性
|
||||
|
||||
当前状态:
|
||||
|
||||
- `/health/live` 存在
|
||||
- `/health/ready` 存在
|
||||
- 就绪性实际上不检查上游模型可用性
|
||||
- 没有指标端点
|
||||
- 没有追踪
|
||||
- 没有告警定义
|
||||
|
||||
风险:
|
||||
|
||||
- 运行时故障检测太晚
|
||||
- 平台探针可能报告健康而上游依赖不可用
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 使就绪性反映关键依赖状态
|
||||
2. 添加请求/延迟/错误指标
|
||||
3. 为上游故障和饱和添加告警阈值
|
||||
4. 为关键路由定义仪表板
|
||||
|
||||
验收标准:
|
||||
|
||||
- 运营商可以快速检测模型依赖故障
|
||||
- 请求成功率和延迟可观测
|
||||
|
||||
### P1-05 Service Worker 实现存在但被禁用
|
||||
|
||||
当前状态:
|
||||
|
||||
- `public/sw.js` 存在
|
||||
- `src/main.js` 用 `&& false` 硬禁用注册
|
||||
- Service Worker 策略是手写的并通过静态缓存名称版本化
|
||||
|
||||
风险:
|
||||
|
||||
- 当前仓库包含未被实际使用的休眠 PWA 逻辑
|
||||
- 如果随意重新启用,更新和缓存行为可能很脆弱
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 确定 PWA 是否在生产范围内
|
||||
2. 如果是,采用维护的策略如 Vite PWA/Workbox
|
||||
3. 如果不是,移除无用的 Service Worker 代码以减少混淆
|
||||
|
||||
验收标准:
|
||||
|
||||
- PWA 行为要么被有意支持和测试,要么被完全移除
|
||||
|
||||
### P1-06 背景图像持久化可能导致本地存储和内存膨胀
|
||||
|
||||
当前状态:
|
||||
|
||||
- 设置面板将上传的背景图像读取为 data URL
|
||||
- 背景图像数据存储在 localStorage 中
|
||||
- 没有对背景资产强制执行明确的大小上限
|
||||
|
||||
风险:
|
||||
|
||||
- 存储配额耗尽
|
||||
- 大图像导致 UI 缓慢
|
||||
- 跨浏览器的持久化行为脆弱
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 读取前在客户端添加大小上限
|
||||
2. 持久化前调整大小/压缩
|
||||
3. 对于较大的资产,优先使用 blob/object URL 或 IndexedDB
|
||||
4. 为存储溢出添加迁移/错误处理
|
||||
|
||||
验收标准:
|
||||
|
||||
- 大图像不能降低启动或破坏设置持久化
|
||||
|
||||
---
|
||||
|
||||
## P2 重要后续工作
|
||||
|
||||
### P2-01 构建成功,但 bundle/chunk 策略仍然粗糙
|
||||
|
||||
验证的构建输出显示:
|
||||
|
||||
- `manualChunks` 生成许多空 chunk
|
||||
- 一个与 Mermaid 相关的大型 chunk 超过 1 MB 压缩后
|
||||
|
||||
风险:
|
||||
|
||||
- 不必要的 chunk 开销
|
||||
- 较弱的设备上冷启动较慢
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 简化 `manualChunks`
|
||||
2. 延迟加载重型可选功能
|
||||
3. 清理 chunk 后重新测量首次加载成本
|
||||
|
||||
### P2-02 OCR 缓存和图像哈希缓存没有明确的驱逐策略
|
||||
|
||||
当前状态:
|
||||
|
||||
- OCR 数据存储在内存中的 `Map`
|
||||
- 哈希缓存也在内存中
|
||||
- 没有 TTL
|
||||
- 没有最大条目数
|
||||
|
||||
风险:
|
||||
|
||||
- 长时间会话会累积内存
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 添加 TTL 和条目边界
|
||||
2. 如需要,暴露缓存指标用于调试
|
||||
|
||||
### P2-03 仓库仍然包含过时和混淆的工件
|
||||
|
||||
示例:
|
||||
|
||||
- `backend/geoip.py` 和 GeoLite DB 仍然存在,尽管 IP 地理定位在请求流程中不再活跃
|
||||
- `backend/.env.example` 记录的变量不是代码使用的
|
||||
- 后端测试仍然包含过时的 `X-API-Key`
|
||||
|
||||
风险:
|
||||
|
||||
- 未来维护者可能无意中重新引入已移除的行为
|
||||
|
||||
必需的修复:
|
||||
|
||||
1. 移除死代码和过时配置
|
||||
2. 使测试和文档与当前实现保持一致
|
||||
|
||||
---
|
||||
|
||||
## 上线前必需的缺失证据
|
||||
|
||||
仓库目前不提供以下生产能力的证据:
|
||||
|
||||
- staging 部署管道
|
||||
- 回滚程序
|
||||
- 流量/负载测试结果
|
||||
- 故障注入或混沌测试
|
||||
- 备份/恢复程序
|
||||
- 事件响应运行手册
|
||||
- SLO/SLA 定义
|
||||
- 安全扫描基线
|
||||
- 依赖更新策略
|
||||
- 隐私/数据保留文档
|
||||
|
||||
目前应将证据缺失视为未就绪,而不是隐式完成。
|
||||
|
||||
## 推荐的修复顺序
|
||||
|
||||
### 第一阶段:解除生产上线阻碍
|
||||
|
||||
1. 实现真正的限流和并发强制执行
|
||||
2. 定义官方部署拓扑和工件
|
||||
3. 添加 CI/CD 质量门禁
|
||||
4. 加固和隔离文件处理工作负载
|
||||
5. 修复后端环境配置契约
|
||||
|
||||
### 第二阶段:稳定运维和隐私姿态
|
||||
|
||||
1. 移除承载内容的日志
|
||||
2. 加强请求验证
|
||||
3. 深化就绪检查和指标
|
||||
4. 添加前端和 E2E 自动化测试
|
||||
|
||||
### 第三阶段:性能和可维护性清理
|
||||
|
||||
1. 清理 chunk 策略
|
||||
2. 限制 OCR/图像缓存
|
||||
3. 移除过时代码和配置
|
||||
4. 决定 PWA 支持是保留还是移除
|
||||
|
||||
## 最低上线门槛
|
||||
|
||||
至少在以下所有条件都满足之前,不应称该项目为生产就绪:
|
||||
|
||||
- 所有 `P0` 项目都已完成
|
||||
- 日志不再捕获用户内容
|
||||
- 前端和端到端自动化测试存在并在 CI 中运行
|
||||
- 就绪性反映真实的上游依赖状态
|
||||
- 部署和回滚已记录且可重现
|
||||
- staging 环境已通过集成验证
|
||||
- 至少执行了一次受控负载测试并经过审查
|
||||
|
||||
## 最终评估
|
||||
|
||||
与之前的清单相比,该项目已有实质性改进。几个严重的早期发现不再成立,特别是:
|
||||
|
||||
- 硬编码的认证密钥暴露
|
||||
- 通配符式 CORS 姿态
|
||||
- 默认公网 IP 收集
|
||||
- 原始异常泄漏
|
||||
|
||||
然而,这一进展并不意味着已准备好投入生产。
|
||||
|
||||
当前仓库展示了有用的加固工作,但仍然缺乏生产服务预期的运维、测试、节流、部署和可观测性基线。
|
||||
@@ -1,264 +1,93 @@
|
||||
# LLM in Text - 智能写作助手
|
||||
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验。
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### Markdown 编辑器
|
||||
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
||||
- 支持完整 Markdown 语法和 LaTeX 公式
|
||||
- 支持 Markdown 语法和 LaTeX 公式
|
||||
- 支持 Mermaid 图表渲染
|
||||
- 导入/导出 Markdown 文件
|
||||
- 导出 DOCX 和 PDF 格式
|
||||
|
||||
### AI 智能补全
|
||||
- 实时生成文本补全建议(灰色显示)
|
||||
- 流式响应,低延迟体验
|
||||
- 多种交互方式:
|
||||
- **Tab 键**:接受建议
|
||||
- **Esc 键**:拒绝建议
|
||||
- **点击灰色文本**:接受建议
|
||||
- **继续输入**:自动拒绝建议
|
||||
- 多种交互方式:Tab接受、Esc拒绝、点击接受
|
||||
|
||||
### AI 开关控制
|
||||
- 右下角 AI 开关按钮
|
||||
- 白色 = AI 启用,黑色 = AI 禁用
|
||||
- 禁用时自动清除灰色文本并停止 API 调用
|
||||
### 文档处理
|
||||
- OCR 图片识别:上传图片自动识别文字
|
||||
- 文档转换:PDF、DOCX、PPTX、TXT 转 Markdown
|
||||
- 文档块嵌入:可折叠的文档预览块
|
||||
- 智能大小限制:32KB自动禁用AI
|
||||
|
||||
### 设置面板
|
||||
- 外观主题:亮色/暗色/跟随系统
|
||||
- 背景模式:默认/暖色/阅读灯/自定义图片
|
||||
- 模型智能:低/中/高思考级别
|
||||
- 隐私控制:隐私模式防止发送IP
|
||||
- 多语言界面:中英日韩德法
|
||||
|
||||
### 语音功能
|
||||
- TTS文字转语音(macOS)
|
||||
- STT语音转文字
|
||||
|
||||
## 技术架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Frontend["前端 (Vue3 + Vite)"]
|
||||
A[App.vue] --> B[MilkdownEditor.vue]
|
||||
B --> C[Crepe Editor]
|
||||
C --> D[ProseMirror]
|
||||
D --> E[copilotPlugin.ts]
|
||||
E --> F[copilotGhostMark]
|
||||
E --> G[api.js]
|
||||
end
|
||||
|
||||
subgraph Backend["后端 (FastAPI + Python)"]
|
||||
H[main.py<br/>FastAPI Server] --> I[prompt.py<br/>Prompt 构建]
|
||||
H --> J[llm.py<br/>Ollama 调用]
|
||||
J --> K[Ollama API]
|
||||
end
|
||||
|
||||
G -->|POST /v1/completions<br/>SSE 流式响应| H
|
||||
K -->|LLM 响应| J
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
llm-in-text/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ └── MilkdownEditor.vue # 主编辑器组件
|
||||
│ ├── plugins/
|
||||
│ │ ├── copilotPlugin.ts # ProseMirror AI 补全插件
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ └── index.ts # 插件导出
|
||||
│ ├── utils/
|
||||
│ │ ├── api.js # API 调用封装
|
||||
│ │ ├── config.js # 配置文件
|
||||
│ │ └── ocrCache.js # OCR 缓存管理
|
||||
│ ├── App.vue
|
||||
│ └── main.js
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI 服务器
|
||||
│ ├── llm.py # LLM API 调用
|
||||
│ ├── prompt.py # Prompt 构建
|
||||
│ └── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
前端: Vue3 + Vite + Milkdown + ProseMirror
|
||||
后端: FastAPI + Python + Ollama
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- Python 3.8+
|
||||
- Ollama 服务(或其他兼容 OpenAI API 的服务)
|
||||
环境: Node.js 18+、Python 3.8+、Ollama
|
||||
|
||||
### 安装
|
||||
安装:
|
||||
- 前端: npm install
|
||||
- 后端: pip install -r backend/requirements.txt
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
启动:
|
||||
- 后端: python backend/main.py (端口8001)
|
||||
- 前端: npm run dev (端口5173)
|
||||
|
||||
# 后端
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
## API接口
|
||||
|
||||
### 配置
|
||||
|
||||
在 `backend/.env` 中配置:
|
||||
|
||||
```env
|
||||
OLLAMA_MODEL=gpt-oss:20b
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
```
|
||||
|
||||
### 启动
|
||||
|
||||
```bash
|
||||
# 后端(端口 8000)
|
||||
cd backend
|
||||
python main.py
|
||||
|
||||
# 前端(端口 5173)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:5173
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /v1/completions
|
||||
|
||||
流式获取补全建议
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"prefix": "# Title\n\nContent ",
|
||||
"suffix": "",
|
||||
"languageId": "markdown"
|
||||
}
|
||||
```
|
||||
|
||||
**响应(SSE):**
|
||||
```
|
||||
data: {"content": "here"}
|
||||
data: {"content": "here is"}
|
||||
data: {"done": true}
|
||||
```
|
||||
- POST /v1/completions 流式补全建议
|
||||
- POST /v1/ocr 图片文字识别
|
||||
- POST /v1/convert 文档转换
|
||||
- POST /v1/completions/cancel 取消请求
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 后端设计
|
||||
### 后端
|
||||
- main.py: FastAPI服务器、SSE流式响应
|
||||
- llm.py: 异步Ollama调用、超时控制
|
||||
- prompt.py: 7条Prompt规则
|
||||
- tts_asr.py: macOS 语音处理
|
||||
|
||||
#### main.py - FastAPI 服务器
|
||||
- 定义 `/v1/completions` 端点
|
||||
- 使用 `StreamingResponse` 返回 SSE 流式响应
|
||||
- CORS 配置允许跨域请求
|
||||
|
||||
#### llm.py - LLM 调用封装
|
||||
- 使用 `ollama.AsyncClient` 异步调用
|
||||
- 支持 `think='high'` 思考模式
|
||||
- 返回 `content` 和 `thinking` 字段
|
||||
|
||||
#### prompt.py - Prompt 工程
|
||||
精心设计的 Prompt 模板,包含 7 条核心规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| RULE #1 | 无缝连接 - 不重复 suffix 内容,避免"复读机"错误 |
|
||||
| RULE #2 | 空白处理 - 避免双空格,正确对接标点 |
|
||||
| RULE #3 | 缩进对齐 - 匹配当前缩进级别和类型 |
|
||||
| RULE #4 | 列表维护 - 识别并继续任务列表、有序列表、无序列表 |
|
||||
| RULE #5 | 语法闭合 - 自动闭合未完成的 Markdown 语法 |
|
||||
| RULE #6 | 输出格式 - 仅输出续写文本,无解释无注释 |
|
||||
| RULE #7 | 必须输出 - 始终提供有用的续写建议 |
|
||||
|
||||
### 前端设计
|
||||
|
||||
#### ProseMirror Mark 系统
|
||||
|
||||
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
|
||||
|
||||
```typescript
|
||||
// 定义 ghost mark
|
||||
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||
excludes: '_',
|
||||
inclusive: true,
|
||||
toDOM: () => ['span', {
|
||||
'data-copilot-ghost': '',
|
||||
class: 'copilot-ghost-text'
|
||||
}, 0]
|
||||
}))
|
||||
|
||||
// CSS 样式
|
||||
.copilot-ghost-text {
|
||||
color: #999;
|
||||
opacity: 0.6;
|
||||
}
|
||||
```
|
||||
|
||||
#### copilotPlugin 核心逻辑
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[用户输入] --> B{文档变化?}
|
||||
B -->|是| C[清除旧建议]
|
||||
C --> D[防抖 1000ms]
|
||||
D --> E[发送 API 请求]
|
||||
E --> F[收到建议]
|
||||
F --> G[插入 Ghost Text]
|
||||
|
||||
G --> H{用户操作}
|
||||
H -->|Tab| I[接受建议<br/>移除 mark]
|
||||
H -->|Esc| J[拒绝建议<br/>删除文本]
|
||||
H -->|点击 Ghost| I
|
||||
H -->|继续输入| J
|
||||
```
|
||||
|
||||
#### 关键函数
|
||||
|
||||
| 函数 | 作用 |
|
||||
|------|------|
|
||||
| `scheduleFetch` | 防抖调度 API 请求 |
|
||||
| `insertGhostText` | 插入带 mark 的建议文本 |
|
||||
| `acceptSuggestion` | Tab 接受建议 |
|
||||
| `rejectSuggestion` | Esc 拒绝建议 |
|
||||
| `clearGhostText` | 清除当前建议 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant E as Editor (ProseMirror)
|
||||
participant P as copilotPlugin
|
||||
participant A as api.js
|
||||
participant B as Backend
|
||||
participant L as LLM
|
||||
|
||||
U->>E: 输入文本
|
||||
E->>P: view.update()
|
||||
P->>P: 清除旧建议
|
||||
P->>P: 防抖 1000ms
|
||||
P->>A: fetchSuggestion(prefix, suffix)
|
||||
A->>B: POST /v1/completions
|
||||
B->>B: build_prompt()
|
||||
B->>L: ollama.chat()
|
||||
L-->>B: {content, thinking}
|
||||
B-->>A: SSE stream
|
||||
A-->>P: suggestion text
|
||||
P->>E: insertGhostText()
|
||||
E-->>U: 显示灰色建议
|
||||
|
||||
alt Tab 键
|
||||
U->>P: Tab
|
||||
P->>E: acceptSuggestion()
|
||||
E-->>U: 建议变为正常文本
|
||||
else Esc 键
|
||||
U->>P: Esc
|
||||
P->>E: rejectSuggestion()
|
||||
E-->>U: 建议消失
|
||||
else 继续输入
|
||||
U->>E: 输入其他字符
|
||||
E->>P: handleKeyDown()
|
||||
P->>E: clearGhostText()
|
||||
end
|
||||
```
|
||||
### 前端
|
||||
- copilotPlugin.ts: ProseMirror Mark系统
|
||||
- 关键函数: scheduleFetch、insertGhostText
|
||||
- Pinia Store状态管理
|
||||
|
||||
## 设计亮点
|
||||
|
||||
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
|
||||
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求
|
||||
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
|
||||
4. **多种交互方式**:Tab/Esc/点击/输入,用户体验友好
|
||||
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能
|
||||
1. 前后端分离
|
||||
2. 低延迟优化:防抖+SSE+AbortController
|
||||
3. ProseMirror Mark系统
|
||||
4. 多种交互方式
|
||||
5. 智能大小限制
|
||||
6. 隐私保护
|
||||
7. 多语言支持
|
||||
8. 主题定制
|
||||
9. 文档处理
|
||||
10. 语音功能
|
||||
|
||||
## 开发指南
|
||||
|
||||
代码风格: Python(4空格,snake_case) JS/TS(2空格,camelCase)
|
||||
测试: pytest
|
||||
构建: npm run build
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const path = require('path')
|
||||
const { convert } = require('docx2pdf-converter')
|
||||
|
||||
function main() {
|
||||
const inputPath = process.argv[2]
|
||||
const outputPath = process.argv[3]
|
||||
|
||||
if (!inputPath || !outputPath) {
|
||||
throw new Error('缺少 DOCX 或 PDF 路径')
|
||||
}
|
||||
|
||||
convert(path.resolve(inputPath), path.resolve(outputPath))
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
+147
-92
@@ -1,17 +1,22 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, HTTPException, Request, Security, File, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse, Response
|
||||
from fastapi.security import APIKeyHeader
|
||||
from pydantic import BaseModel
|
||||
|
||||
from geoip import get_ip_location_text
|
||||
from llm import call_ollama, call_vlm_ocr
|
||||
from prompt import build_completion_prompts, prepare_prompt_context
|
||||
import markitdown
|
||||
@@ -24,33 +29,28 @@ logger = logging.getLogger("api")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"https://www.imageteach.tech",
|
||||
"https://chat.imageteach.tech",
|
||||
],
|
||||
allow_credentials=False,
|
||||
allow_methods=["POST", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-Request-Id"],
|
||||
)
|
||||
|
||||
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
|
||||
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
|
||||
|
||||
# Rate limiting
|
||||
MAX_CONCURRENT_COMPLETIONS = 4
|
||||
COMPLETION_RATE_LIMIT = 60 # per minute
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
|
||||
)
|
||||
|
||||
# File size limits (bytes)
|
||||
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
MAX_CONVERT_SIZE = 50 * 1024 * 1024 # 50MB
|
||||
API_KEY = "your-secret-key-here"
|
||||
api_key_header = APIKeyHeader(name="X-API-Key")
|
||||
|
||||
# Allowed file extensions
|
||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
ALLOWED_CONVERT_EXTENSIONS = {".pdf", ".docx", ".pptx", ".xlsx", ".md", ".txt"}
|
||||
|
||||
async def get_api_key(api_key: str = Security(api_key_header)):
|
||||
if api_key != API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
return api_key
|
||||
|
||||
|
||||
class UserPreferences(BaseModel):
|
||||
@@ -84,6 +84,32 @@ class ConvertRequest(BaseModel):
|
||||
filename: str = "document.pdf"
|
||||
|
||||
|
||||
ALLOWED_CONVERT_EXTENSIONS = {".txt", ".docx", ".pptx", ".pdf"}
|
||||
IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*]\([^)]+\)")
|
||||
IMAGE_HTML_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _convert_docx_to_pdf(input_path: str, output_path: str) -> None:
|
||||
node_executable = shutil.which("node")
|
||||
if not node_executable:
|
||||
raise RuntimeError("未找到 Node.js,无法转换 DOCX 为 PDF")
|
||||
|
||||
bridge_path = os.path.join(os.path.dirname(__file__), "docx2pdf_bridge.cjs")
|
||||
if not os.path.exists(bridge_path):
|
||||
raise RuntimeError("缺少 DOCX 转 PDF 桥接脚本")
|
||||
|
||||
result = subprocess.run(
|
||||
[node_executable, bridge_path, input_path, output_path],
|
||||
cwd=os.path.dirname(os.path.dirname(__file__)),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_text = (result.stderr or result.stdout or "DOCX 转 PDF 失败").strip()
|
||||
raise RuntimeError(error_text)
|
||||
|
||||
|
||||
def _preview(text: str, limit: int = 80) -> str:
|
||||
value = (text or "").replace("\n", "\\n")
|
||||
if len(value) <= limit:
|
||||
@@ -91,34 +117,45 @@ def _preview(text: str, limit: int = 80) -> str:
|
||||
return value[:limit] + "..."
|
||||
|
||||
|
||||
def _error_response(request_id: str, code: str, message: str, status_code: int = 500) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"request_id": request_id,
|
||||
}
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
def _sanitize_converted_markdown(text: str) -> str:
|
||||
value = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
value = IMAGE_MARKDOWN_RE.sub("", value)
|
||||
value = IMAGE_HTML_RE.sub("", value)
|
||||
value = re.sub(r"\n{3,}", "\n\n", value)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _sse_payload(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
if request.client:
|
||||
return request.headers.get("X-Client-IP") or request.client.host
|
||||
return request.headers.get("X-Client-IP") or "unknown"
|
||||
|
||||
|
||||
@app.post("/v1/completions")
|
||||
async def create_completion(request: Request, req: CompletionRequest):
|
||||
async def create_completion(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||
request_tag = request_id[:8]
|
||||
inference_task: Optional[asyncio.Task] = None
|
||||
|
||||
client_ip = "hidden"
|
||||
location = ""
|
||||
|
||||
if not req.privacy_mode:
|
||||
client_ip = get_client_ip(request)
|
||||
location = get_ip_location_text(client_ip)
|
||||
if location:
|
||||
logger.info("[%s] client_location=%s", request_tag, location)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
"[%s] /v1/completions request_id=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s",
|
||||
"[%s] /v1/completions request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s",
|
||||
request_tag,
|
||||
request_id,
|
||||
client_ip,
|
||||
len(req.prefix or ""),
|
||||
len(req.suffix or ""),
|
||||
req.languageId,
|
||||
@@ -134,6 +171,7 @@ async def create_completion(request: Request, req: CompletionRequest):
|
||||
req.prefix,
|
||||
req.suffix,
|
||||
req.languageId,
|
||||
location=location,
|
||||
thinking_level=req.model_thinking,
|
||||
preferences=req.user_preferences,
|
||||
)
|
||||
@@ -180,7 +218,7 @@ async def create_completion(request: Request, req: CompletionRequest):
|
||||
return StreamingResponse(cancelled(), media_type="text/event-stream")
|
||||
except Exception as e:
|
||||
logger.exception("[%s] /v1/completions failed request_id=%s: %s", request_tag, request_id, e)
|
||||
return _error_response(request_id, "INTERNAL_ERROR", "Service temporarily unavailable", 500)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
finally:
|
||||
async with ACTIVE_COMPLETIONS_LOCK:
|
||||
active = ACTIVE_COMPLETIONS.get(request_id)
|
||||
@@ -189,7 +227,7 @@ async def create_completion(request: Request, req: CompletionRequest):
|
||||
|
||||
|
||||
@app.post("/v1/completions/cancel")
|
||||
async def cancel_completion(req: CancelCompletionRequest):
|
||||
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
|
||||
request_tag = str(uuid.uuid4())[:8]
|
||||
request_id = req.request_id or ""
|
||||
|
||||
@@ -225,7 +263,7 @@ async def cancel_completion(req: CancelCompletionRequest):
|
||||
|
||||
|
||||
@app.post("/v1/ocr")
|
||||
async def ocr_image(request: OCRRequest):
|
||||
async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
try:
|
||||
logger.info(
|
||||
@@ -235,22 +273,7 @@ async def ocr_image(request: OCRRequest):
|
||||
request.language,
|
||||
len(request.image or ""),
|
||||
)
|
||||
|
||||
# Check file size before decoding
|
||||
if len(request.image or "") > MAX_IMAGE_SIZE * 4 // 3: # base64 overhead
|
||||
return _error_response(request_id, "FILE_TOO_LARGE", "Image exceeds 10MB limit", 413)
|
||||
|
||||
# Check extension
|
||||
ext = os.path.splitext(request.filename)[1].lower()
|
||||
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
||||
return _error_response(request_id, "INVALID_FILE_TYPE", "Only jpg/png/webp allowed", 415)
|
||||
|
||||
image_bytes = base64.b64decode(request.image)
|
||||
|
||||
# Check actual decoded size
|
||||
if len(image_bytes) > MAX_IMAGE_SIZE:
|
||||
return _error_response(request_id, "FILE_TOO_LARGE", "Image exceeds 10MB limit", 413)
|
||||
|
||||
logger.info("[%s] /v1/ocr decoded image_bytes=%d", request_id, len(image_bytes))
|
||||
result = await call_vlm_ocr(image_bytes, request.language)
|
||||
logger.info(
|
||||
@@ -262,12 +285,12 @@ async def ocr_image(request: OCRRequest):
|
||||
return {"text": result, "filename": request.filename}
|
||||
except Exception as e:
|
||||
logger.exception("[%s] /v1/ocr failed: %s", request_id, e)
|
||||
return _error_response(request_id, "OCR_FAILED", "Failed to process image", 500)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/v1/convert")
|
||||
async def convert_to_markdown(request: ConvertRequest):
|
||||
"""将文件转换为Markdown格式"""
|
||||
async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(get_api_key)):
|
||||
"""Convert file to markdown"""
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
|
||||
try:
|
||||
@@ -278,34 +301,33 @@ async def convert_to_markdown(request: ConvertRequest):
|
||||
len(request.file or ""),
|
||||
)
|
||||
|
||||
# Check file size before decoding
|
||||
if len(request.file or "") > MAX_CONVERT_SIZE * 4 // 3:
|
||||
return _error_response(request_id, "FILE_TOO_LARGE", "File exceeds 50MB limit", 413)
|
||||
|
||||
# Get file extension and validate
|
||||
ext = os.path.splitext(request.filename)[1].lower()
|
||||
if ext not in ALLOWED_CONVERT_EXTENSIONS:
|
||||
return _error_response(request_id, "INVALID_FILE_TYPE", "Only pdf/docx/pptx/xlsx/md/txt allowed", 415)
|
||||
|
||||
# 解码Base64文件内容
|
||||
# Decode base64
|
||||
file_bytes = base64.b64decode(request.file)
|
||||
|
||||
# Check actual decoded size
|
||||
if len(file_bytes) > MAX_CONVERT_SIZE:
|
||||
return _error_response(request_id, "FILE_TOO_LARGE", "File exceeds 50MB limit", 413)
|
||||
|
||||
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
|
||||
|
||||
# 创建临时文件
|
||||
# Get file extension
|
||||
ext = os.path.splitext(request.filename)[1].lower()
|
||||
|
||||
if ext not in ALLOWED_CONVERT_EXTENSIONS:
|
||||
raise ValueError("仅支持 txt、docx、pptx、pdf 格式")
|
||||
|
||||
if ext == ".txt":
|
||||
markdown_text = _sanitize_converted_markdown(file_bytes.decode("utf-8", errors="ignore"))
|
||||
return {
|
||||
"markdown": markdown_text,
|
||||
"filename": request.filename
|
||||
}
|
||||
|
||||
# Create temporary file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
||||
tmp.write(file_bytes)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# 使用MarkItDown转换为Markdown
|
||||
# Convert using MarkItDown
|
||||
md = markitdown.MarkItDown()
|
||||
result = md.convert(tmp_path)
|
||||
markdown_text = result.text_content
|
||||
markdown_text = _sanitize_converted_markdown(result.text_content)
|
||||
|
||||
logger.info(
|
||||
"[%s] /v1/convert success text_chars=%d text_preview='%s'",
|
||||
@@ -319,13 +341,56 @@ async def convert_to_markdown(request: ConvertRequest):
|
||||
"filename": request.filename
|
||||
}
|
||||
finally:
|
||||
# 清理临时文件
|
||||
# Clean up temporary file
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[%s] /v1/convert failed: %s", request_id, e)
|
||||
return _error_response(request_id, "CONVERT_FAILED", "Failed to convert file", 500)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post("/v1/export/pdf")
|
||||
async def export_pdf(file: UploadFile = File(...), api_key: str = Security(get_api_key)):
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
original_name = file.filename or "document.docx"
|
||||
base_name = os.path.splitext(original_name)[0] or "document"
|
||||
|
||||
try:
|
||||
file_bytes = await file.read()
|
||||
logger.info(
|
||||
"[%s] /v1/export/pdf filename=%s file_bytes=%d",
|
||||
request_id,
|
||||
original_name,
|
||||
len(file_bytes),
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
input_path = os.path.join(temp_dir, f"{base_name}.docx")
|
||||
output_path = os.path.join(temp_dir, f"{base_name}.pdf")
|
||||
|
||||
with open(input_path, "wb") as tmp_file:
|
||||
tmp_file.write(file_bytes)
|
||||
|
||||
await asyncio.to_thread(_convert_docx_to_pdf, input_path, output_path)
|
||||
|
||||
if not os.path.exists(output_path):
|
||||
raise RuntimeError("PDF 转换后未生成输出文件")
|
||||
|
||||
with open(output_path, "rb") as pdf_file:
|
||||
pdf_bytes = pdf_file.read()
|
||||
|
||||
logger.info("[%s] /v1/export/pdf success pdf_bytes=%d", request_id, len(pdf_bytes))
|
||||
headers = {
|
||||
"Content-Disposition": f'attachment; filename="{base_name}.pdf"',
|
||||
}
|
||||
return Response(content=pdf_bytes, media_type="application/pdf", headers=headers)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] /v1/export/pdf failed: %s", request_id, e)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -334,17 +399,7 @@ if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
|
||||
@app.get("/health/live")
|
||||
async def health_live():
|
||||
return {"status": "ok"}
|
||||
# TTS and ASR routes
|
||||
from tts_asr import register_tts_asr_routes
|
||||
register_tts_asr_routes(app)
|
||||
|
||||
|
||||
@app.get("/health/ready")
|
||||
async def health_ready():
|
||||
# Check if critical components are available
|
||||
try:
|
||||
# Could add more checks here (e.g., Ollama connectivity)
|
||||
return {"status": "ready"}
|
||||
except Exception as e:
|
||||
logger.warning("[health/ready] not ready: %s", e)
|
||||
return _error_response("health-check", "NOT_READY", "Service not ready", 503)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
fastapi
|
||||
fastapi
|
||||
uvicorn
|
||||
ollama
|
||||
pydantic
|
||||
@@ -10,3 +10,10 @@ python-docx
|
||||
python-pptx
|
||||
openpyxl
|
||||
pypdf
|
||||
|
||||
# TTS and ASR dependencies
|
||||
torch
|
||||
transformers
|
||||
soundfile
|
||||
numpy
|
||||
accelerate
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# TTS and ASR API for macOS Silicon with HuggingFace transformers
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
from pydantic import BaseModel
|
||||
import numpy as np
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("tts_asr")
|
||||
|
||||
_tts_pipeline = None
|
||||
_asr_pipeline = None
|
||||
_device = None
|
||||
|
||||
|
||||
def _get_device():
|
||||
global _device
|
||||
if _device is not None:
|
||||
return _device
|
||||
|
||||
import torch
|
||||
|
||||
if platform.system() == "Darwin" and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
_device = "mps"
|
||||
logger.info("[Device] 使用 MPS 加速")
|
||||
elif torch.cuda.is_available():
|
||||
_device = "cuda"
|
||||
logger.info("[Device] 使用 CUDA 加速")
|
||||
else:
|
||||
_device = "cpu"
|
||||
logger.info("[Device] 使用 CPU")
|
||||
return _device
|
||||
|
||||
|
||||
def _device_arg():
|
||||
device = _get_device()
|
||||
if device == "cuda":
|
||||
return "cuda:0"
|
||||
return device
|
||||
|
||||
|
||||
def _get_tts_pipeline():
|
||||
global _tts_pipeline
|
||||
if _tts_pipeline is not None:
|
||||
return _tts_pipeline
|
||||
|
||||
import torch
|
||||
from transformers import pipeline
|
||||
|
||||
logger.info("[TTS] 加载 Kokoro-82M 模型...")
|
||||
_tts_pipeline = pipeline(
|
||||
"text-to-speech",
|
||||
model="hexgrad/Kokoro-82M",
|
||||
trust_remote_code=True,
|
||||
device=_device_arg(),
|
||||
torch_dtype=torch.float16 if _get_device() != "cpu" else torch.float32,
|
||||
)
|
||||
logger.info("[TTS] Kokoro-82M 模型加载完成")
|
||||
return _tts_pipeline
|
||||
|
||||
|
||||
def _get_asr_pipeline():
|
||||
global _asr_pipeline
|
||||
if _asr_pipeline is not None:
|
||||
return _asr_pipeline
|
||||
|
||||
import torch
|
||||
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
||||
|
||||
logger.info("[ASR] 加载 Whisper large-v3-turbo 模型...")
|
||||
model_id = "openai/whisper-large-v3-turbo"
|
||||
model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
||||
model_id,
|
||||
torch_dtype=torch.float16 if _get_device() != "cpu" else torch.float32,
|
||||
low_cpu_mem_usage=True,
|
||||
use_safetensors=True,
|
||||
)
|
||||
processor = AutoProcessor.from_pretrained(model_id)
|
||||
_asr_pipeline = pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=model,
|
||||
tokenizer=processor.tokenizer,
|
||||
feature_extractor=processor.feature_extractor,
|
||||
torch_dtype=torch.float16 if _get_device() != "cpu" else torch.float32,
|
||||
device=_device_arg(),
|
||||
)
|
||||
logger.info("[ASR] Whisper large-v3-turbo 模型加载完成")
|
||||
return _asr_pipeline
|
||||
|
||||
|
||||
def _save_audio_to_wav(audio_data: bytes, sample_rate: int = 16000) -> str:
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False, mode="wb") as tmp:
|
||||
with wave.open(tmp.name, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(audio_data)
|
||||
return tmp.name
|
||||
|
||||
|
||||
def _tts_sync(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[bytes, int]:
|
||||
tts = _get_tts_pipeline()
|
||||
result = tts(text, voice=voice)
|
||||
audio = None
|
||||
sample_rate = 24000
|
||||
if isinstance(result, dict):
|
||||
audio = result.get("audio")
|
||||
sample_rate = int(result.get("sampling_rate", sample_rate))
|
||||
elif isinstance(result, (list, tuple)) and result:
|
||||
audio = result[0]
|
||||
|
||||
if audio is None:
|
||||
raise RuntimeError("Kokoro 未返回音频数据")
|
||||
|
||||
if hasattr(audio, "cpu"):
|
||||
audio = audio.cpu().numpy()
|
||||
|
||||
duration_ms = int(len(audio) * 1000 / sample_rate)
|
||||
|
||||
if audio.dtype != np.int16:
|
||||
audio = (audio * 32767).astype(np.int16)
|
||||
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
output_path = tmp.name
|
||||
try:
|
||||
with wave.open(output_path, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(audio.tobytes())
|
||||
with open(output_path, "rb") as f:
|
||||
return f.read(), duration_ms
|
||||
finally:
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
|
||||
|
||||
async def _text_to_speech(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[bytes, int]:
|
||||
return await asyncio.to_thread(_tts_sync, text, voice, rate)
|
||||
|
||||
|
||||
def _asr_sync(audio_data: bytes, language: str = "zh") -> str:
|
||||
import soundfile as sf
|
||||
|
||||
asr = _get_asr_pipeline()
|
||||
audio_path = _save_audio_to_wav(audio_data)
|
||||
try:
|
||||
audio_array, sample_rate = sf.read(audio_path)
|
||||
result = asr(
|
||||
audio_array,
|
||||
sampling_rate=sample_rate,
|
||||
generate_kwargs={"language": language, "task": "transcribe"},
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
return result.get("text", "").strip()
|
||||
return str(result).strip()
|
||||
finally:
|
||||
if os.path.exists(audio_path):
|
||||
os.unlink(audio_path)
|
||||
|
||||
|
||||
async def _speech_to_text(audio_data: bytes, language: str = "zh") -> str:
|
||||
return await asyncio.to_thread(_asr_sync, audio_data, language)
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
voice: str = "af_bella"
|
||||
rate: float = 1.0
|
||||
format: str = "wav"
|
||||
|
||||
|
||||
class TTSResponse(BaseModel):
|
||||
audio_base64: str
|
||||
format: str
|
||||
duration_ms: int
|
||||
|
||||
|
||||
class ASRRequest(BaseModel):
|
||||
audio_base64: str
|
||||
language: str = "zh-CN"
|
||||
|
||||
|
||||
class ASRResponse(BaseModel):
|
||||
text: str
|
||||
language: str
|
||||
|
||||
|
||||
def get_api_key(api_key: str):
|
||||
from backend.main import API_KEY
|
||||
|
||||
if api_key != API_KEY:
|
||||
raise HTTPException(status_code=403, detail="API Key 无效")
|
||||
return api_key
|
||||
|
||||
|
||||
@router.post("/tts", response_model=TTSResponse)
|
||||
async def text_to_speech(req: TTSRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = str(hash(req.text))[:8]
|
||||
try:
|
||||
logger.info("[TTS][%s] text_chars=%d voice=%s format=%s", request_id, len(req.text), req.voice, req.format)
|
||||
audio_data, duration_ms = await _text_to_speech(req.text, req.voice, req.rate)
|
||||
if req.format.lower() == "mp3":
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_in:
|
||||
tmp_in.write(audio_data)
|
||||
input_path = tmp_in.name
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp_out:
|
||||
output_path = tmp_out.name
|
||||
try:
|
||||
cmd = ["ffmpeg", "-i", input_path, "-acodec", "libmp3lame", "-ab", "128k", output_path]
|
||||
result = await asyncio.to_thread(lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=30))
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"MP3 转换失败: {result.stderr}")
|
||||
with open(output_path, "rb") as f:
|
||||
audio_data = f.read()
|
||||
finally:
|
||||
for path in [input_path, output_path]:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
logger.info("[TTS][%s] success duration_ms=%d", request_id, duration_ms)
|
||||
return TTSResponse(audio_base64=base64.b64encode(audio_data).decode(), format=req.format, duration_ms=duration_ms)
|
||||
except Exception as e:
|
||||
logger.exception("[TTS] failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/asr", response_model=ASRResponse)
|
||||
async def speech_to_text(req: ASRRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = str(hash(req.audio_base64))[:8]
|
||||
try:
|
||||
logger.info("[ASR][%s] audio_base64_chars=%d language=%s", request_id, len(req.audio_base64), req.language)
|
||||
audio_data = base64.b64decode(req.audio_base64)
|
||||
text = await _speech_to_text(audio_data, req.language[:2])
|
||||
logger.info("[ASR][%s] success text_chars=%d", request_id, len(text))
|
||||
return ASRResponse(text=text, language=req.language)
|
||||
except Exception as e:
|
||||
logger.exception("[ASR] failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def register_tts_asr_routes(app):
|
||||
app.include_router(router, prefix="/v1/tts-asr")
|
||||
Generated
+1543
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,17 @@
|
||||
"check": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/xl-docx-exporter": "^0.47.3",
|
||||
"@milkdown/core": "^7.18.0",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
"@milkdown/theme-nord": "^7.18.0",
|
||||
"@milkdown/vue": "^7.18.0",
|
||||
"docx": "^9.6.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"docx2pdf-converter": "^2.1.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"katex": "^0.16.9",
|
||||
"markdown-it": "^13.0.0",
|
||||
"markdown-it-math": "^3.0.2",
|
||||
|
||||
+192
-113
@@ -1,250 +1,329 @@
|
||||
<template>
|
||||
<div class="doc-block-crepe" :class="{ collapsed: isCollapsed }">
|
||||
<div class="doc-block-crepe" :class="{ collapsed: collapsedState }">
|
||||
<div class="doc-header">
|
||||
<div class="doc-accent"></div>
|
||||
<div class="doc-icon">
|
||||
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M9 15v-2h6v2"/>
|
||||
<path d="M12 13v4"/>
|
||||
<path d="M8 13h5"/>
|
||||
<path d="M8 17h8"/>
|
||||
</svg>
|
||||
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg v-else-if="docType === 'docx'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M16 13H8"/>
|
||||
<path d="M16 17H8"/>
|
||||
<path d="M10 9H8"/>
|
||||
<path d="m8 13 2 4 2-4 2 4 2-4"/>
|
||||
</svg>
|
||||
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<path d="M8 21h8"/>
|
||||
<path d="M12 17v4"/>
|
||||
<svg v-else-if="docType === 'pptx'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2"/>
|
||||
<path d="M8 20h8"/>
|
||||
<path d="M12 16v4"/>
|
||||
<path d="M9 8h3a2 2 0 0 1 0 4H9z"/>
|
||||
</svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M16 13H8"/>
|
||||
<path d="M16 17H8"/>
|
||||
<path d="M8 13h8"/>
|
||||
<path d="M8 17h5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
<div class="doc-name">{{ docName }}</div>
|
||||
<div class="doc-subline">{{ typeLabel }} · {{ displayTime }}</div>
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
<button @click="downloadDoc" class="action-btn" title="下载文档">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="toggleCollapse" class="action-btn collapse-btn" :title="isCollapsed ? '展开' : '折叠'">
|
||||
<svg v-if="isCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button type="button" class="action-btn" :title="collapsedState ? '展开文件' : '折叠文件'" @click="toggleCollapse">
|
||||
<svg v-if="collapsedState" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="action-btn action-btn-danger" title="删除文件" @click="props.onDelete?.()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M8 6V4h8v2"/>
|
||||
<path d="M19 6l-1 14H6L5 6"/>
|
||||
<path d="M10 11v6"/>
|
||||
<path d="M14 11v6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-editor" v-show="!isCollapsed">
|
||||
<div v-show="!collapsedState" class="doc-editor">
|
||||
<div ref="editorRoot" class="inner-crepe"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { replaceAll } from '@milkdown/kit/utils'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { copilotPlugin, copilotConfigCtx, setCopilotEnabled } from '../plugins/copilotPlugin'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
|
||||
const props = defineProps({
|
||||
docType: { type: String, default: 'text' },
|
||||
docType: { type: String, default: 'txt' },
|
||||
docName: { type: String, default: 'document.txt' },
|
||||
uploadTime: { type: String, default: '' },
|
||||
initialContent: { type: String, default: '' }
|
||||
content: { type: String, default: '' },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
resolveSuggestionRequest: { type: Function, default: null },
|
||||
onUpdateContent: { type: Function, default: null },
|
||||
onUpdateCollapsed: { type: Function, default: null },
|
||||
onDelete: { type: Function, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:content', 'delete'])
|
||||
|
||||
const editorRoot = ref(null)
|
||||
const isCollapsed = ref(false)
|
||||
const collapsedState = ref(Boolean(props.collapsed))
|
||||
const currentContent = ref(props.content || '')
|
||||
let crepe = null
|
||||
let internalChangeTimer = null
|
||||
let syncTimer = null
|
||||
let applyingExternalContent = false
|
||||
|
||||
const displayTime = computed(() => {
|
||||
if (!props.uploadTime) return '刚上传'
|
||||
const date = new Date(props.uploadTime)
|
||||
if (Number.isNaN(date.getTime())) return '刚上传'
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
})
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
if (props.docType === 'docx') return 'DOCX'
|
||||
if (props.docType === 'pptx') return 'PPTX'
|
||||
if (props.docType === 'pdf') return 'PDF'
|
||||
return 'TXT'
|
||||
})
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
const downloadDoc = () => {
|
||||
if (!crepe) return
|
||||
crepe.getMarkdown().then(markdown => {
|
||||
const blob = new Blob([markdown], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = props.docName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
collapsedState.value = !collapsedState.value
|
||||
props.onUpdateCollapsed?.(collapsedState.value)
|
||||
}
|
||||
|
||||
const syncContent = () => {
|
||||
if (!crepe) return
|
||||
if (internalChangeTimer) clearTimeout(internalChangeTimer)
|
||||
internalChangeTimer = setTimeout(async () => {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
syncTimer = setTimeout(async () => {
|
||||
if (!crepe || applyingExternalContent) return
|
||||
const markdown = await crepe.getMarkdown()
|
||||
emit('update:content', markdown)
|
||||
currentContent.value = markdown
|
||||
props.onUpdateContent?.(markdown)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const syncExternalContent = async (nextValue) => {
|
||||
if (!crepe) {
|
||||
currentContent.value = nextValue || ''
|
||||
return
|
||||
}
|
||||
if ((nextValue || '') === currentContent.value) return
|
||||
applyingExternalContent = true
|
||||
try {
|
||||
crepe.editor.action(replaceAll(nextValue || ''))
|
||||
currentContent.value = nextValue || ''
|
||||
} finally {
|
||||
applyingExternalContent = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.content, (nextValue) => {
|
||||
void syncExternalContent(nextValue || '')
|
||||
})
|
||||
|
||||
watch(() => props.collapsed, (nextValue) => {
|
||||
collapsedState.value = Boolean(nextValue)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!editorRoot.value) return
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
defaultValue: props.initialContent || '',
|
||||
defaultValue: props.content || '',
|
||||
features: {
|
||||
[Crepe.Feature.Latex]: true,
|
||||
[Crepe.Feature.ImageBlock]: true,
|
||||
[Crepe.Feature.Table]: true,
|
||||
[Crepe.Feature.ListCheck]: true,
|
||||
},
|
||||
config: { showLineNumber: false }
|
||||
config: {
|
||||
showLineNumber: false,
|
||||
},
|
||||
})
|
||||
|
||||
crepe.editor.config(ctx => {
|
||||
crepe.editor.config((ctx) => {
|
||||
ctx.set(copilotConfigCtx.key, {
|
||||
fetchSuggestion,
|
||||
debounceMs: 1000
|
||||
fetchSuggestion: async (prefix, suffix, languageId, signal) => {
|
||||
const payload = props.resolveSuggestionRequest
|
||||
? await props.resolveSuggestionRequest({ prefix, suffix, languageId })
|
||||
: { prefix, suffix, languageId, blocked: false }
|
||||
if (payload?.blocked) return ''
|
||||
return fetchSuggestion(payload?.prefix ?? prefix, payload?.suffix ?? suffix, payload?.languageId ?? languageId, signal)
|
||||
},
|
||||
debounceMs: 900,
|
||||
})
|
||||
})
|
||||
|
||||
crepe.editor.use(copilotConfigCtx)
|
||||
crepe.editor.use(copilotGhostMark)
|
||||
crepe.editor.use(copilotPlugin)
|
||||
|
||||
await crepe.create()
|
||||
|
||||
crepe.on(listener => {
|
||||
crepe.on((listener) => {
|
||||
listener.updated(() => {
|
||||
syncContent()
|
||||
})
|
||||
})
|
||||
|
||||
crepe.editor.action(ctx => {
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
setCopilotEnabled(view, true)
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.initialContent, (newVal) => {
|
||||
if (crepe && newVal !== undefined) {
|
||||
crepe.editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const currentPos = view.state.selection.from
|
||||
view.dispatch(view.state.tr.insertText(newVal))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (internalChangeTimer) clearTimeout(internalChangeTimer)
|
||||
if (syncTimer) {
|
||||
clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
}
|
||||
if (crepe) {
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearGhostSuggestion(view)
|
||||
})
|
||||
crepe.destroy()
|
||||
crepe = null
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
getContent: () => crepe ? crepe.getMarkdown() : Promise.resolve(''),
|
||||
getEditor: () => crepe
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-block-crepe {
|
||||
margin: 12px 0;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
margin: 14px 0;
|
||||
border: 1px solid color-mix(in srgb, var(--panel-border) 72%, transparent);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: var(--crepe-color-surface-low);
|
||||
border: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.doc-block-crepe.collapsed .doc-editor {
|
||||
display: none;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-bg) 82%, transparent) 0%, color-mix(in srgb, var(--crepe-color-surface-low) 88%, transparent) 100%);
|
||||
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 4px 24px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--crepe-color-surface);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--btn-bg) 76%, transparent) 0%, color-mix(in srgb, var(--crepe-color-surface) 78%, transparent) 100%);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--panel-border) 76%, transparent);
|
||||
}
|
||||
|
||||
.doc-accent {
|
||||
width: 4px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #4f8cff 0%, #7dc1ff 100%);
|
||||
box-shadow: 0 0 18px rgba(79, 140, 255, 0.32);
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--crepe-color-primary);
|
||||
color: var(--btn-fg);
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--crepe-color-on-surface);
|
||||
font-weight: 600;
|
||||
color: var(--app-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-subline {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid color-mix(in srgb, var(--panel-border) 72%, transparent);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--btn-bg) 84%, transparent);
|
||||
color: var(--btn-fg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--crepe-color-on-surface-variant);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 0.7;
|
||||
transition: transform 0.14s ease, background-color 0.14s ease, border-color 0.14s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--crepe-color-hover);
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
background: var(--btn-hover-bg);
|
||||
border-color: var(--btn-hover-bg);
|
||||
color: var(--btn-hover-fg);
|
||||
}
|
||||
|
||||
.action-btn-danger:hover {
|
||||
background: rgba(220, 38, 38, 0.12);
|
||||
border-color: rgba(220, 38, 38, 0.22);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.doc-editor {
|
||||
padding: 8px;
|
||||
background: var(--crepe-color-surface-low);
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.inner-crepe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--crepe-color-background) 78%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--panel-border) 68%, transparent);
|
||||
}
|
||||
|
||||
.inner-crepe :deep(.milkdown) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.inner-crepe :deep(.milkdown__main),
|
||||
.inner-crepe :deep(.milkdown__editor) {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.inner-crepe :deep(.ProseMirror) {
|
||||
min-height: 80px;
|
||||
padding: 8px !important;
|
||||
min-height: 92px;
|
||||
padding: 10px 12px 14px !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.inner-crepe :deep(.ProseMirror h1),
|
||||
.inner-crepe :deep(.ProseMirror h2),
|
||||
.inner-crepe :deep(.ProseMirror h3),
|
||||
.inner-crepe :deep(.ProseMirror p),
|
||||
.inner-crepe :deep(.ProseMirror li),
|
||||
.inner-crepe :deep(.ProseMirror blockquote),
|
||||
.inner-crepe :deep(.ProseMirror code) {
|
||||
font-size: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
+307
-416
@@ -1,20 +1,7 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div ref="root" class="milkdown-editor"></div>
|
||||
|
||||
<!-- 文档块渲染层 -->
|
||||
<div class="doc-blocks-overlay">
|
||||
<DocBlockCrepe
|
||||
v-for="(doc, index) in docBlocks"
|
||||
:key="index"
|
||||
:docType="doc.type"
|
||||
:docName="doc.name"
|
||||
:uploadTime="doc.time"
|
||||
:initialContent="doc.content"
|
||||
@update:content="(content) => updateDocBlockContent(index, content)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="history-buttons">
|
||||
<button
|
||||
type="button"
|
||||
@@ -44,34 +31,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 左下角:文档上传按钮 -->
|
||||
<div class="doc-upload-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn doc-upload-btn"
|
||||
:class="{ 'force-disabled': !canInsertDoc }"
|
||||
:disabled="!canInsertDoc"
|
||||
:aria-label="t('uploadDoc')"
|
||||
:title="!canInsertDoc ? t('uploadDocDisabled') : t('uploadDoc')"
|
||||
@click="triggerDocUpload"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M12 18v-6"/>
|
||||
<path d="m9 15 3 3 3-3"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ t('uploadDoc') || '上传文档' }}</span>
|
||||
</button>
|
||||
<input type="file" ref="docUploadInputRef" @change="handleDocUpload" accept=".txt,.docx,.pptx,.pdf" style="display:none">
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:class="{ 'force-disabled': isDocUploadDisabled }"
|
||||
:aria-label="t('uploadFile')"
|
||||
:title="t('uploadFile')"
|
||||
:title="docUploadButtonTitle"
|
||||
@click="triggerFileUpload"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -80,7 +46,7 @@
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ t('uploadFile') }}</span>
|
||||
</button>
|
||||
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept="image/*,.doc,.docx,.ppt,.pptx,.pdf,.txt,.json" style="display:none">
|
||||
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept=".txt,.docx,.pptx,.pdf,text/plain,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation" style="display:none">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -98,21 +64,29 @@
|
||||
</button>
|
||||
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md,text/markdown,text/x-markdown" style="display:none">
|
||||
|
||||
<div class="export-btn-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:aria-label="t('exportMd')"
|
||||
:title="t('exportMd')"
|
||||
@click="exportMarkdown"
|
||||
@click="toggleExportDropdown"
|
||||
@contextmenu.prevent="toggleExportDropdown"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
<path d="m19 9-4 4-4-4"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ t('exportMd') }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="showExportDropdown" class="export-dropdown">
|
||||
<button type="button" @click="() => { exportMarkdown(); showExportDropdown = false; }">{{ t('exportMd') }}</button>
|
||||
<button type="button" @click="() => { exportDocx(); showExportDropdown = false; }">{{ t('exportDocx') }}</button>
|
||||
<button type="button" @click="() => { exportPdf(); showExportDropdown = false; }">{{ t('exportPdf') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-btn-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
@@ -189,14 +163,14 @@ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { Selection } from '@milkdown/prose/state'
|
||||
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { docBlockNode, docBlockRemark, docBlockView } from '../plugins/docBlockPlugin'
|
||||
import { mermaidRenderPreview, codeBlockConfig } from '../plugins/mermaidPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { OCR_URL } from '../utils/config.js'
|
||||
import { OCR_URL, EXPORT_PDF_URL } from '../utils/config.js'
|
||||
import { convertFileToMarkdown } from '../utils/convert.js'
|
||||
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
|
||||
import DocumentBlock from './DocumentBlock.vue'
|
||||
import DocBlockCrepe from './DocBlockCrepe.vue'
|
||||
import { DOC_BLOCK_NODE_TYPE, buildLegacyDocBlock, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js'
|
||||
|
||||
const emit = defineEmits(['update:markdown'])
|
||||
const settings = useSettingsStore()
|
||||
@@ -205,25 +179,24 @@ const initialMarkdown = computed(() => settings.initialMarkdown)
|
||||
|
||||
const root = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const docUploadInputRef = ref(null)
|
||||
const uploadFileInputRef = ref(null)
|
||||
const imageInputRef = ref(null)
|
||||
const cameraInputRef = ref(null)
|
||||
const aiEnabled = ref(true)
|
||||
const contentSize = ref(0)
|
||||
const showImageDropdown = ref(false)
|
||||
const showExportDropdown = ref(false)
|
||||
const showUrlDialog = ref(false)
|
||||
const imageUrl = ref('')
|
||||
const canUndo = ref(false)
|
||||
const canRedo = ref(false)
|
||||
const canInsertDoc = ref(true)
|
||||
// 文档块列表 - 用于渲染 DocBlockCrepe 组件
|
||||
const docBlocks = ref([])
|
||||
const isDocUploadDisabled = ref(false)
|
||||
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
|
||||
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
|
||||
const undoLabel = computed(() => t('undo') || 'Undo')
|
||||
const redoLabel = computed(() => t('redo') || 'Redo')
|
||||
const cameraUploadLabel = computed(() => t('cameraUpload') || 'Use Camera')
|
||||
const API_KEY = 'your-secret-key-here'
|
||||
const supportsCameraCapture = computed(() => {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
const ua = navigator.userAgent || ''
|
||||
@@ -233,45 +206,111 @@ const aiButtonLabel = computed(() => {
|
||||
if (isOverLimit.value) return t('docTooLarge')
|
||||
return aiEnabled.value ? t('disableAI') : t('enableAI')
|
||||
})
|
||||
const docUploadButtonTitle = computed(() => {
|
||||
if (isDocUploadDisabled.value) return t('uploadDocInBlockWarning') || '当前光标位置不能插入文件'
|
||||
return t('uploadFile')
|
||||
})
|
||||
|
||||
let crepe = null
|
||||
let markdownSyncTimer = null
|
||||
let rootResizeObserver = null
|
||||
let editorCopyHandler = null
|
||||
const objectUrls = new Set()
|
||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
||||
const MARKDOWN_EXT_RE = /\.md$/i
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
|
||||
const CONVERT_EXT_RE = /\.(docx?|pptx?|pdf)$/i
|
||||
const TEXT_EXT_RE = /\.(txt|json)$/i
|
||||
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json'])
|
||||
const CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i
|
||||
const TEXT_EXT_RE = /\.txt$/i
|
||||
const TEXT_MIME_TYPES = new Set(['text/plain'])
|
||||
const CONVERT_MIME_TYPES = new Set([
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/pdf',
|
||||
])
|
||||
let lastInitialMarkdown = initialMarkdown.value
|
||||
let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)
|
||||
|
||||
const normalizeTrailingWhitespace = (value) => (value || '').replace(/\s+$/, '')
|
||||
|
||||
const padTimePart = (value) => String(value).padStart(2, '0')
|
||||
|
||||
const createExportName = () => {
|
||||
const now = new Date()
|
||||
const datePart = `${now.getFullYear()}${padTimePart(now.getMonth() + 1)}${padTimePart(now.getDate())}`
|
||||
const timePart = `${padTimePart(now.getHours())}${padTimePart(now.getMinutes())}${padTimePart(now.getSeconds())}`
|
||||
return `save${datePart}${timePart}`
|
||||
}
|
||||
|
||||
const buildDocxBlob = async (markdown) => {
|
||||
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
|
||||
const children = []
|
||||
|
||||
for (const line of markdown.split('\n')) {
|
||||
if (line.startsWith('# ')) {
|
||||
children.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
children.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('### ')) {
|
||||
children.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('---')) {
|
||||
children.push(new Paragraph({ text: '----------' }))
|
||||
continue
|
||||
}
|
||||
children.push(line.trim() === '' ? new Paragraph({}) : new Paragraph({ text: line }))
|
||||
}
|
||||
|
||||
return Packer.toBlob(new Document({ sections: [{ properties: {}, children }] }))
|
||||
}
|
||||
|
||||
const downloadBlob = (blob, filename) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
const getExportMarkdown = async () => {
|
||||
if (!crepe) {
|
||||
throw new Error('编辑器未初始化,请稍后重试')
|
||||
}
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearCurrentSuggestion(view)
|
||||
})
|
||||
|
||||
const markdown = await crepe.getMarkdown()
|
||||
return transformLegacyDocBlocksForExport(markdown)
|
||||
}
|
||||
|
||||
const syncInitialMarkdown = async (nextValue) => {
|
||||
if (!crepe) {
|
||||
lastInitialMarkdown = nextValue
|
||||
lastInitialMarkdown = transformSpecialDocBlocksToLegacy(nextValue)
|
||||
return
|
||||
}
|
||||
const normalizedNextValue = transformSpecialDocBlocksToLegacy(nextValue)
|
||||
|
||||
try {
|
||||
const current = await crepe.getMarkdown()
|
||||
const normalizedCurrent = normalizeTrailingWhitespace(current)
|
||||
const normalizedLast = normalizeTrailingWhitespace(lastInitialMarkdown)
|
||||
if (!normalizedCurrent || normalizedCurrent === normalizedLast) {
|
||||
crepe.editor.action(replaceAll(nextValue))
|
||||
crepe.editor.action(replaceAll(normalizedNextValue))
|
||||
}
|
||||
} catch {
|
||||
// Ignore sync errors
|
||||
} finally {
|
||||
lastInitialMarkdown = nextValue
|
||||
lastInitialMarkdown = normalizedNextValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +416,58 @@ const updateHistoryState = (view) => {
|
||||
canRedo.value = redoDepth(view.state) > 0
|
||||
}
|
||||
|
||||
const serializeSelectionToMarkdown = (view, from, to) => {
|
||||
const state = view.state
|
||||
const slice = state.doc.slice(from, to)
|
||||
const doc = state.schema.topNodeType.createAndFill(undefined, slice.content)
|
||||
if (!doc) return state.doc.textBetween(from, to, '\n', '\n')
|
||||
return crepe?.editor?.action((ctx) => {
|
||||
const serializer = ctx.get(serializerCtx)
|
||||
return serializer(doc)
|
||||
}) || state.doc.textBetween(from, to, '\n', '\n')
|
||||
}
|
||||
|
||||
const selectionIncludesDocBlock = (state) => {
|
||||
const { from, to } = state.selection
|
||||
let hasDocBlock = false
|
||||
state.doc.nodesBetween(from, to, (node) => {
|
||||
if (node.type?.name === DOC_BLOCK_NODE_TYPE) {
|
||||
hasDocBlock = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return hasDocBlock
|
||||
}
|
||||
|
||||
const getCursorContext = (view) => {
|
||||
const { $from } = view.state.selection
|
||||
let inDocBlock = false
|
||||
let fenceLanguage = ''
|
||||
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||
const node = $from.node(depth)
|
||||
const typeName = node.type?.name || ''
|
||||
if (typeName === DOC_BLOCK_NODE_TYPE) {
|
||||
inDocBlock = true
|
||||
break
|
||||
}
|
||||
if (typeName === 'code_block' || typeName === 'codeBlock' || typeName === 'code_fence' || typeName === 'fence') {
|
||||
fenceLanguage = String(node.attrs?.language || node.attrs?.lang || node.attrs?.info || '').trim().toLowerCase()
|
||||
break
|
||||
}
|
||||
}
|
||||
const disabledByFence = fenceLanguage === 'mermaid' || fenceLanguage === 'tex' || fenceLanguage === 'latex' || fenceLanguage === 'katex'
|
||||
return {
|
||||
disabled: inDocBlock || disabledByFence,
|
||||
inDocBlock,
|
||||
fenceLanguage,
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDocUploadState = (view) => {
|
||||
isDocUploadDisabled.value = getCursorContext(view).disabled
|
||||
}
|
||||
|
||||
const runHistoryCommand = (command) => {
|
||||
if (!crepe) return
|
||||
crepe.editor.action((ctx) => {
|
||||
@@ -463,6 +554,7 @@ const performOCR = async (file, cacheKey, imageHash = '') => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-secret-key-here'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image: base64,
|
||||
@@ -518,6 +610,15 @@ const prepareImageFile = async (file) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.export-btn-wrapper')) {
|
||||
showExportDropdown.value = false
|
||||
}
|
||||
if (!e.target.closest('.image-btn-wrapper')) {
|
||||
showImageDropdown.value = false
|
||||
}
|
||||
})
|
||||
|
||||
if (!root.value) throw new Error('root.value is null')
|
||||
updateEditorTailSpace()
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
@@ -529,7 +630,7 @@ onMounted(async () => {
|
||||
|
||||
crepe = new Crepe({
|
||||
root: root.value,
|
||||
defaultValue: initialMarkdown.value || '',
|
||||
defaultValue: transformSpecialDocBlocksToLegacy(initialMarkdown.value || ''),
|
||||
features: {
|
||||
[Crepe.Feature.Latex]: true,
|
||||
[Crepe.Feature.ImageBlock]: true,
|
||||
@@ -585,6 +686,9 @@ onMounted(async () => {
|
||||
crepe.editor.use(copilotConfigCtx)
|
||||
crepe.editor.use(copilotGhostMark)
|
||||
crepe.editor.use(copilotPlugin)
|
||||
crepe.editor.use(docBlockRemark)
|
||||
crepe.editor.use(docBlockNode)
|
||||
crepe.editor.use(docBlockView)
|
||||
|
||||
|
||||
await crepe.create()
|
||||
@@ -595,11 +699,8 @@ onMounted(async () => {
|
||||
syncObjectUrls(doc)
|
||||
refreshSizeAndLimit(ctx)
|
||||
updateHistoryState(view)
|
||||
refreshDocUploadState(view)
|
||||
scheduleMarkdownSync()
|
||||
// 解析文档块并渲染
|
||||
parseDocBlocks()
|
||||
// 检查是否可以插入文档
|
||||
checkCanInsertDoc()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -608,33 +709,79 @@ onMounted(async () => {
|
||||
setCopilotEnabled(view, aiEnabled.value)
|
||||
refreshSizeAndLimit(ctx)
|
||||
updateHistoryState(view)
|
||||
refreshDocUploadState(view)
|
||||
const editorDom = view.dom
|
||||
editorCopyHandler = (event) => {
|
||||
const state = view.state
|
||||
if (!selectionIncludesDocBlock(state)) return
|
||||
const { from, to } = state.selection
|
||||
const rawMarkdown = serializeSelectionToMarkdown(view, from, to)
|
||||
const clipboardMarkdown = transformDocBlockMarkdownForClipboard(rawMarkdown || '')
|
||||
if (!clipboardMarkdown) return
|
||||
event.preventDefault()
|
||||
event.clipboardData?.setData('text/plain', clipboardMarkdown)
|
||||
}
|
||||
editorDom.addEventListener('copy', editorCopyHandler)
|
||||
})
|
||||
scheduleMarkdownSync()
|
||||
})
|
||||
|
||||
const exportMarkdown = async () => {
|
||||
if (!crepe) return
|
||||
try {
|
||||
const markdown = await getExportMarkdown()
|
||||
const exportName = createExportName()
|
||||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
downloadBlob(blob, `${exportName}.md`)
|
||||
} catch (error) {
|
||||
console.error('Markdown export failed:', error)
|
||||
alert(`Markdown 导出失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearCurrentSuggestion(view)
|
||||
const exportDocx = async () => {
|
||||
try {
|
||||
console.log('Exporting DOCX...')
|
||||
const markdown = await getExportMarkdown()
|
||||
const blob = await buildDocxBlob(markdown)
|
||||
const exportName = createExportName()
|
||||
downloadBlob(blob, `${exportName}.docx`)
|
||||
console.log('DOCX export completed')
|
||||
} catch (error) {
|
||||
console.error('DOCX export failed:', error)
|
||||
alert(`DOCX导出失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const exportPdf = async () => {
|
||||
try {
|
||||
console.log('Exporting PDF via DOCX...')
|
||||
const markdown = await getExportMarkdown()
|
||||
const docxBlob = await buildDocxBlob(markdown)
|
||||
const exportName = createExportName()
|
||||
const formData = new FormData()
|
||||
formData.append('file', docxBlob, `${exportName}.docx`)
|
||||
|
||||
const res = await fetch(EXPORT_PDF_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': API_KEY,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
// 使用转换函数处理文档块格式
|
||||
const markdown = await transformDocBlocksForExport()
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
const now = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
||||
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
a.href = url
|
||||
a.download = `save${datePart}${timePart}.md`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const pdfBlob = await res.blob()
|
||||
downloadBlob(pdfBlob, `${exportName}.pdf`)
|
||||
console.log('PDF export completed successfully')
|
||||
alert('PDF导出成功!')
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error)
|
||||
alert(`PDF导出失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerUpload = () => {
|
||||
@@ -665,7 +812,7 @@ const handleFileUpload = async (event) => {
|
||||
try {
|
||||
const text = await file.text()
|
||||
if (crepe && crepe.editor) {
|
||||
crepe.editor.action(replaceAll(text))
|
||||
crepe.editor.action(replaceAll(transformSpecialDocBlocksToLegacy(text)))
|
||||
}
|
||||
} catch {
|
||||
// File upload error, ignore
|
||||
@@ -690,6 +837,12 @@ const toggleAI = async () => {
|
||||
|
||||
const toggleImageDropdown = () => {
|
||||
showImageDropdown.value = !showImageDropdown.value
|
||||
showExportDropdown.value = false
|
||||
}
|
||||
|
||||
const toggleExportDropdown = () => {
|
||||
showExportDropdown.value = !showExportDropdown.value
|
||||
showImageDropdown.value = false
|
||||
}
|
||||
|
||||
const triggerImageUpload = () => {
|
||||
@@ -732,13 +885,14 @@ const insertMarkdownAtCursor = (markdown) => {
|
||||
})
|
||||
}
|
||||
|
||||
const buildCodeBlock = (file, text) => {
|
||||
const name = (file?.name || '').toLowerCase()
|
||||
const lang = name.endsWith('.json') ? 'json' : 'text'
|
||||
return `\n\`\`\`${lang}\n${text}\n\`\`\`\n`
|
||||
const insertDocBlockAtCursor = (attrs) => {
|
||||
if (!crepe) return
|
||||
const markdown = buildLegacyDocBlock(attrs)
|
||||
insertMarkdownAtCursor(`\n${markdown}\n`)
|
||||
}
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
if (isDocUploadDisabled.value) return
|
||||
uploadFileInputRef.value?.click()
|
||||
}
|
||||
|
||||
@@ -747,42 +901,44 @@ const handleUploadFile = async (event) => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const convertible = isConvertibleFile(file)
|
||||
try {
|
||||
if (isImageFile(file)) {
|
||||
const objectUrl = await prepareImageFile(file)
|
||||
if (objectUrl) {
|
||||
clearCurrentGhost()
|
||||
insertImageAtCursor(objectUrl)
|
||||
}
|
||||
if (!isSupportedDocFile(file)) {
|
||||
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
|
||||
return
|
||||
}
|
||||
|
||||
if (isDocUploadDisabled.value || !crepe) {
|
||||
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
|
||||
return
|
||||
}
|
||||
|
||||
const docType = getDocTypeFromFilename(file.name)
|
||||
let content = ''
|
||||
|
||||
if (isTextFile(file)) {
|
||||
const text = await file.text()
|
||||
clearCurrentGhost()
|
||||
insertMarkdownAtCursor(buildCodeBlock(file, text))
|
||||
content = await file.text()
|
||||
} else if (isConvertibleFile(file)) {
|
||||
content = await convertFileToMarkdown(file)
|
||||
} else {
|
||||
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
|
||||
return
|
||||
}
|
||||
|
||||
if (convertible) {
|
||||
const markdown = await convertFileToMarkdown(file)
|
||||
if (!markdown) {
|
||||
throw new Error('No markdown returned')
|
||||
}
|
||||
clearCurrentGhost()
|
||||
insertMarkdownAtCursor(markdown)
|
||||
return
|
||||
if (!content) {
|
||||
throw new Error('文档解析结果为空')
|
||||
}
|
||||
|
||||
warnUnsupportedInsertType()
|
||||
clearCurrentGhost()
|
||||
insertDocBlockAtCursor({
|
||||
docType,
|
||||
docName: file.name || `document.${docType}`,
|
||||
uploadTime: new Date().toISOString(),
|
||||
collapsed: false,
|
||||
content,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
if (convertible) {
|
||||
warnConvertError(message)
|
||||
} else {
|
||||
warnUploadError(message)
|
||||
}
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
@@ -814,266 +970,6 @@ const insertImageFromUrl = () => {
|
||||
showUrlDialog.value = false
|
||||
}
|
||||
|
||||
// 文档上传相关功能
|
||||
// 检查光标是否在现有文档块内(防止重叠)
|
||||
const isCursorInDocumentBlock = async () => {
|
||||
if (!crepe) return false
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
let inDocBlock = false
|
||||
|
||||
crepe.editor.action(async (ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const { from } = view.state.selection
|
||||
|
||||
const markdown = await crepe.getMarkdown()
|
||||
|
||||
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>/g
|
||||
|
||||
let match
|
||||
while ((match = docBlockRegex.exec(markdown)) !== null) {
|
||||
const startPos = match.index
|
||||
const endPos = match.index + match[0].length
|
||||
|
||||
if (from >= startPos && from <= endPos) {
|
||||
inDocBlock = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
resolve(inDocBlock)
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 检测光标是否在代码块中
|
||||
const isCursorInFencedCodeBlock = () => {
|
||||
if (!crepe) return false
|
||||
let result = false
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const { $from } = view.state.selection
|
||||
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||
const node = $from.node(depth)
|
||||
const typeName = node.type?.name || ''
|
||||
if (typeName === 'code_block' || typeName === 'codeBlock' || typeName === 'code_fence' || typeName === 'fence') {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// 检测光标是否在 mermaid 上下文中
|
||||
const isCursorInMermaidContext = () => {
|
||||
if (!crepe) return false
|
||||
let result = false
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const { $from } = view.state.selection
|
||||
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||
const node = $from.node(depth)
|
||||
const typeName = node.type?.name || ''
|
||||
if (typeName === 'fence') {
|
||||
const lang = node.attrs?.language || ''
|
||||
if (lang.toLowerCase() === 'mermaid') {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// 检测光标是否在 latex 块中
|
||||
const isCursorInLatexBlock = () => {
|
||||
if (!crepe) return false
|
||||
let result = false
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const { $from } = view.state.selection
|
||||
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||
const node = $from.node(depth)
|
||||
const typeName = node.type?.name || ''
|
||||
if (typeName === 'math_inline' || typeName === 'math_block' || typeName === 'latex' || typeName === 'math') {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// 检查是否可以插入文档
|
||||
const checkCanInsertDoc = async () => {
|
||||
if (!crepe) {
|
||||
canInsertDoc.value = true
|
||||
return
|
||||
}
|
||||
const inDocBlock = await isCursorInDocumentBlock()
|
||||
const inCodeBlock = isCursorInFencedCodeBlock()
|
||||
const inMermaid = isCursorInMermaidContext()
|
||||
const inLatex = isCursorInLatexBlock()
|
||||
canInsertDoc.value = !inDocBlock && !inCodeBlock && !inMermaid && !inLatex
|
||||
}
|
||||
|
||||
// 更新文档块内容
|
||||
const updateDocBlockContent = async (index, content) => {
|
||||
if (!crepe || index < 0 || index >= docBlocks.value.length) return
|
||||
docBlocks.value[index].content = content
|
||||
await syncDocBlocksToMarkdown()
|
||||
}
|
||||
|
||||
// 将文档块同步到 markdown
|
||||
const syncDocBlocksToMarkdown = async () => {
|
||||
if (!crepe) return
|
||||
try {
|
||||
const markdown = await crepe.getMarkdown()
|
||||
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>\n*总字数:\d+/g
|
||||
let newMarkdown = markdown.replace(docBlockRegex, (match, docType, docName, uploadTime) => {
|
||||
const block = docBlocks.value.find(b => b.name === docName && b.type === docType)
|
||||
if (block) {
|
||||
const docTag = `<doc_type="${docType}" doc_name="${docName}" upload_time="${uploadTime}">`
|
||||
const totalChars = docTag.length + 1 + block.content.length + '</doc_end>'.length
|
||||
return `${docTag}\n${block.content}\n</doc_end>\n总字数:${totalChars}`
|
||||
}
|
||||
return match
|
||||
})
|
||||
if (newMarkdown !== markdown) {
|
||||
crepe.editor.action(replaceAll(newMarkdown))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to sync doc blocks:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件类型图标和名称
|
||||
const getFileTypeInfo = (filename) => {
|
||||
const ext = filename.toLowerCase().split('.').pop()
|
||||
const typeMap = {
|
||||
'txt': { icon: 'file-text', name: 'TXT 文档', code: 'text' },
|
||||
'docx': { icon: 'file-word', name: 'Word 文档', code: 'doc' },
|
||||
'doc': { icon: 'file-word', name: 'Word 文档', code: 'doc' },
|
||||
'pptx': { icon: 'file-powerpoint', name: 'PPT 演示', code: 'ppt' },
|
||||
'ppt': { icon: 'file-powerpoint', name: 'PPT 演示', code: 'ppt' },
|
||||
'pdf': { icon: 'file-pdf', name: 'PDF 文档', code: 'pdf' }
|
||||
}
|
||||
return typeMap[ext] || { icon: 'file', name: '文档', code: 'doc' }
|
||||
}
|
||||
|
||||
// 触发文档上传
|
||||
const triggerDocUpload = () => {
|
||||
docUploadInputRef.value?.click()
|
||||
}
|
||||
|
||||
// 处理文档上传
|
||||
const handleDocUpload = async (event) => {
|
||||
const input = event.target
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!canInsertDoc.value) {
|
||||
alert(t('uploadDocInBlockWarning') || '当前光标位置不允许插入文档')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(t('uploadDocSizeWarning') || '文件大小不能超过 10MB')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const allowedExts = ['.txt', '.docx', '.pptx', '.pdf']
|
||||
const ext = '.' + file.name.toLowerCase().split('.').pop()
|
||||
if (!allowedExts.includes(ext)) {
|
||||
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 转换文件为markdown
|
||||
const markdown = await convertFileToMarkdown(file)
|
||||
if (!markdown) {
|
||||
throw new Error('No markdown returned')
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
const fileInfo = getFileTypeInfo(file.name)
|
||||
const now = new Date()
|
||||
const uploadTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
||||
|
||||
// 构建文档块格式
|
||||
const escapedDocName = file.name.replace(/"/g, '"').replace(/</g, '<')
|
||||
const docTag = `<doc_type="${fileInfo.code}" doc_name="${escapedDocName}" upload_time="${uploadTime}">`
|
||||
const totalChars = docTag.length + 1 + markdown.length + '</doc_end>'.length
|
||||
const docBlock = `\n${docTag}\n${markdown}\n</doc_end>\n总字数:${totalChars}\n`
|
||||
|
||||
clearCurrentGhost()
|
||||
// 在光标位置插入
|
||||
insertMarkdownAtCursor(docBlock)
|
||||
parseDocBlocks()
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
alert(t('uploadDocError') || `文档转换失败: ${message}`)
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 从markdown中解析文档块
|
||||
const parseDocBlocks = async () => {
|
||||
if (!crepe) return
|
||||
|
||||
try {
|
||||
const markdown = await crepe.getMarkdown()
|
||||
|
||||
// 使用正则表达式匹配文档块
|
||||
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>/g
|
||||
|
||||
const blocks = []
|
||||
let match
|
||||
while ((match = docBlockRegex.exec(markdown)) !== null) {
|
||||
blocks.push({
|
||||
type: match[1],
|
||||
name: match[2],
|
||||
time: match[3],
|
||||
content: match[4]
|
||||
})
|
||||
}
|
||||
|
||||
docBlocks.value = blocks
|
||||
} catch (e) {
|
||||
console.error('Failed to parse doc blocks:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出时转换文档块格式
|
||||
const transformDocBlocksForExport = async () => {
|
||||
if (!crepe) return ''
|
||||
|
||||
let markdown = await crepe.getMarkdown()
|
||||
|
||||
// 使用正则表达式匹配文档块并转换格式
|
||||
// <doc_type="pdf" doc_name="xxx" upload_time="xxx">content</doc_end>
|
||||
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>\n*总字数:\d+/g
|
||||
|
||||
markdown = markdown.replace(docBlockRegex, (match, docType, docName, uploadTime, content) => {
|
||||
// 转换为 ```doc 格式
|
||||
return `\n\`\`\`${docType}\n${content.trim()}\n\`\`\`\n`
|
||||
})
|
||||
|
||||
return markdown
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (markdownSyncTimer) {
|
||||
clearTimeout(markdownSyncTimer)
|
||||
@@ -1091,6 +987,12 @@ onUnmounted(() => {
|
||||
|
||||
clearAllOcrCache()
|
||||
if (crepe) {
|
||||
if (editorCopyHandler) {
|
||||
crepe.editor.action((ctx) => {
|
||||
ctx.get(editorViewCtx).dom.removeEventListener('copy', editorCopyHandler)
|
||||
})
|
||||
editorCopyHandler = null
|
||||
}
|
||||
crepe.destroy()
|
||||
crepe = null
|
||||
}
|
||||
@@ -1102,53 +1004,6 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 文档块渲染层 */
|
||||
.doc-blocks-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 40px;
|
||||
right: 40px;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.doc-blocks-overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.doc-upload-buttons {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.doc-upload-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 10px;
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--btn-fg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--panel-shadow);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.doc-upload-btn:hover {
|
||||
background-color: var(--btn-hover-bg);
|
||||
color: var(--btn-hover-fg);
|
||||
border-color: var(--btn-hover-bg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.history-buttons {
|
||||
@@ -1199,7 +1054,8 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 9999;
|
||||
z-index: 99999;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -1326,6 +1182,40 @@ onUnmounted(() => {
|
||||
background: var(--crepe-color-hover);
|
||||
}
|
||||
|
||||
.export-btn-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.export-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--panel-shadow);
|
||||
overflow: hidden;
|
||||
z-index: 10000;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.export-dropdown button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.export-dropdown button:hover {
|
||||
background: var(--crepe-color-hover);
|
||||
}
|
||||
|
||||
.url-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -1547,3 +1437,4 @@ onUnmounted(() => {
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createApp, reactive } from 'vue'
|
||||
import { serializerCtx } from '@milkdown/kit/core'
|
||||
import { $node, $remark, $view } from '@milkdown/kit/utils'
|
||||
import type { Node as ProseNode, Schema } from '@milkdown/prose/model'
|
||||
import type { EditorView, NodeView } from '@milkdown/prose/view'
|
||||
import DocBlockCrepe from '../components/DocBlockCrepe.vue'
|
||||
import {
|
||||
DOC_BLOCK_FENCE_LANG,
|
||||
DOC_BLOCK_NODE_TYPE,
|
||||
DOC_CONTEXT_LIMIT,
|
||||
buildLegacyDocBlock,
|
||||
buildDocContextFence,
|
||||
normalizeDocType,
|
||||
parseLegacyDocBlock,
|
||||
parseDocBlockValue,
|
||||
stripDocBlockMarkdown,
|
||||
} from '../utils/docBlock.js'
|
||||
|
||||
function serializeRangeToMarkdown(
|
||||
doc: ProseNode,
|
||||
from: number,
|
||||
to: number,
|
||||
schema: Schema,
|
||||
serializer: (content: ProseNode) => string
|
||||
): string {
|
||||
if (from >= to) return ''
|
||||
const slice = doc.slice(from, to)
|
||||
if (slice.content.size <= 0) return ''
|
||||
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
|
||||
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, '\n', '\n')
|
||||
}
|
||||
|
||||
function buildDocContext(doc: ProseNode, excludePos?: number) {
|
||||
const blocks: string[] = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== DOC_BLOCK_NODE_TYPE) return true
|
||||
if (excludePos !== undefined && pos === excludePos) return false
|
||||
blocks.push(
|
||||
buildDocContextFence({
|
||||
docType: node.attrs.docType,
|
||||
content: node.attrs.content,
|
||||
})
|
||||
)
|
||||
return false
|
||||
})
|
||||
return blocks.join('\n\n')
|
||||
}
|
||||
|
||||
class DocBlockNodeView implements NodeView {
|
||||
node: ProseNode
|
||||
view: EditorView
|
||||
getPos: () => number | undefined
|
||||
dom: HTMLElement
|
||||
app: ReturnType<typeof createApp> | null = null
|
||||
props: Record<string, any>
|
||||
serializer: (content: ProseNode) => string
|
||||
|
||||
constructor(node: ProseNode, view: EditorView, getPos: () => number | undefined, serializer: (content: ProseNode) => string) {
|
||||
this.node = node
|
||||
this.view = view
|
||||
this.getPos = getPos
|
||||
this.serializer = serializer
|
||||
this.dom = document.createElement('div')
|
||||
this.dom.className = 'doc-block-node-view'
|
||||
this.props = reactive({
|
||||
docType: node.attrs.docType,
|
||||
docName: node.attrs.docName,
|
||||
uploadTime: node.attrs.uploadTime,
|
||||
content: node.attrs.content,
|
||||
collapsed: node.attrs.collapsed,
|
||||
onUpdateContent: (content: string) => this.updateAttrs({ content }),
|
||||
onUpdateCollapsed: (collapsed: boolean) => this.updateAttrs({ collapsed }),
|
||||
onDelete: () => this.deleteNode(),
|
||||
resolveSuggestionRequest: (payload: { prefix: string; suffix: string; languageId: string }) => this.resolveSuggestionRequest(payload),
|
||||
})
|
||||
this.mount()
|
||||
}
|
||||
|
||||
mount() {
|
||||
this.app = createApp(DocBlockCrepe, this.props)
|
||||
this.app.mount(this.dom)
|
||||
}
|
||||
|
||||
getPosValue() {
|
||||
const pos = this.getPos()
|
||||
return typeof pos === 'number' ? pos : undefined
|
||||
}
|
||||
|
||||
updateAttrs(patch: Record<string, any>) {
|
||||
const pos = this.getPosValue()
|
||||
if (pos === undefined) return
|
||||
const nextAttrs = { ...this.node.attrs, ...patch }
|
||||
this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, nextAttrs))
|
||||
}
|
||||
|
||||
deleteNode() {
|
||||
const pos = this.getPosValue()
|
||||
if (pos === undefined) return
|
||||
const tr = this.view.state.tr.delete(pos, pos + this.node.nodeSize).scrollIntoView()
|
||||
this.view.dispatch(tr)
|
||||
this.view.focus()
|
||||
}
|
||||
|
||||
resolveSuggestionRequest(payload: { prefix: string; suffix: string; languageId: string }) {
|
||||
const pos = this.getPosValue()
|
||||
if (pos === undefined) return payload
|
||||
const doc = this.view.state.doc
|
||||
const schema = this.view.state.schema
|
||||
const before = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer))
|
||||
const after = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, pos + this.node.nodeSize, doc.content.size, schema, this.serializer))
|
||||
const docContext = buildDocContext(doc, pos)
|
||||
const mergedPrefix = [docContext, before, payload.prefix].filter(Boolean).join('\n\n')
|
||||
const mergedSuffix = [payload.suffix, after].filter(Boolean).join('\n\n')
|
||||
if (mergedPrefix.length + mergedSuffix.length > DOC_CONTEXT_LIMIT) {
|
||||
return {
|
||||
prefix: mergedPrefix.slice(0, DOC_CONTEXT_LIMIT),
|
||||
suffix: '',
|
||||
languageId: payload.languageId,
|
||||
blocked: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
prefix: mergedPrefix,
|
||||
suffix: mergedSuffix,
|
||||
languageId: payload.languageId,
|
||||
blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
update(node: ProseNode) {
|
||||
if (node.type !== this.node.type) return false
|
||||
this.node = node
|
||||
this.props.docType = node.attrs.docType
|
||||
this.props.docName = node.attrs.docName
|
||||
this.props.uploadTime = node.attrs.uploadTime
|
||||
this.props.content = node.attrs.content
|
||||
this.props.collapsed = node.attrs.collapsed
|
||||
return true
|
||||
}
|
||||
|
||||
stopEvent(event: Event) {
|
||||
const target = event.target as Node | null
|
||||
return Boolean(target && this.dom.contains(target))
|
||||
}
|
||||
|
||||
ignoreMutation() {
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.app?.unmount()
|
||||
this.app = null
|
||||
}
|
||||
}
|
||||
|
||||
function visitChildren(node: any, visitor: (child: any) => any) {
|
||||
if (!node || !Array.isArray(node.children)) return
|
||||
node.children = node.children.map((child: any) => {
|
||||
const next = visitor(child)
|
||||
if (next && next !== child) return next
|
||||
visitChildren(child, visitor)
|
||||
return child
|
||||
})
|
||||
}
|
||||
|
||||
export const docBlockRemark = $remark('docBlockRemark', () => () => {
|
||||
return (tree: any) => {
|
||||
visitChildren(tree, (node) => {
|
||||
if (node?.type === 'code' && node.lang === DOC_BLOCK_FENCE_LANG) {
|
||||
return {
|
||||
type: 'docBlock',
|
||||
value: String(node.value || ''),
|
||||
sourceType: 'code',
|
||||
}
|
||||
}
|
||||
if (node?.type === 'html' && typeof node.value === 'string' && node.value.includes('<doc_type=')) {
|
||||
return {
|
||||
type: 'docBlock',
|
||||
value: String(node.value || ''),
|
||||
sourceType: 'html',
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const docBlockNode = $node(DOC_BLOCK_NODE_TYPE, () => ({
|
||||
group: 'block',
|
||||
atom: true,
|
||||
isolating: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
marks: '',
|
||||
attrs: {
|
||||
docType: { default: 'txt' },
|
||||
docName: { default: 'document.txt' },
|
||||
uploadTime: { default: '' },
|
||||
content: { default: '' },
|
||||
collapsed: { default: false },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'div[data-doc-block="true"]',
|
||||
getAttrs: (dom) => ({
|
||||
docType: normalizeDocType((dom as HTMLElement).getAttribute('data-doc-type') || ''),
|
||||
docName: (dom as HTMLElement).getAttribute('data-doc-name') || 'document.txt',
|
||||
uploadTime: (dom as HTMLElement).getAttribute('data-doc-upload-time') || '',
|
||||
collapsed: ((dom as HTMLElement).getAttribute('data-doc-collapsed') || '') === 'true',
|
||||
content: '',
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'div',
|
||||
{
|
||||
'data-doc-block': 'true',
|
||||
'data-doc-type': node.attrs.docType,
|
||||
'data-doc-name': node.attrs.docName,
|
||||
'data-doc-upload-time': node.attrs.uploadTime,
|
||||
'data-doc-collapsed': String(Boolean(node.attrs.collapsed)),
|
||||
},
|
||||
],
|
||||
parseMarkdown: {
|
||||
match: (node) => node.type === 'docBlock',
|
||||
runner: (state, node, type) => {
|
||||
const attrs = node.sourceType === 'code'
|
||||
? parseDocBlockValue(String(node.value || ''))
|
||||
: parseLegacyDocBlock(String(node.value || ''))
|
||||
if (!attrs) return
|
||||
state.addNode(type, attrs)
|
||||
},
|
||||
},
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === DOC_BLOCK_NODE_TYPE,
|
||||
runner: (state, node) => {
|
||||
state.addNode('html', undefined, buildLegacyDocBlock(node.attrs))
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export const docBlockView = $view(docBlockNode, (ctx) => {
|
||||
const serializer = ctx.get(serializerCtx)
|
||||
return (node, view, getPos) => new DocBlockNodeView(node, view, getPos, serializer)
|
||||
})
|
||||
|
||||
export function buildDocContextFromDoc(doc: ProseNode, excludePos?: number) {
|
||||
return buildDocContext(doc, excludePos)
|
||||
}
|
||||
+4
-4
@@ -273,7 +273,7 @@ body {
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: color-mix(in srgb, var(--crepe-color-background, #fff) 88%, transparent);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.mermaid-inner::-webkit-scrollbar {
|
||||
@@ -315,7 +315,7 @@ body {
|
||||
.mermaid-error {
|
||||
padding: 12px 16px;
|
||||
margin: 0;
|
||||
background: color-mix(in srgb, var(--danger-text, #dc2626) 8%, transparent);
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border: 1px solid var(--danger-text, #dc2626);
|
||||
border-radius: 6px;
|
||||
color: var(--danger-text, #dc2626);
|
||||
@@ -331,12 +331,12 @@ body {
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-editor,
|
||||
:root[data-theme='dark'] .milkdown .cm-scroller {
|
||||
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
|
||||
background-color: rgba(237, 237, 237, 0.86);
|
||||
color: var(--crepe-color-on-surface);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-gutters {
|
||||
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
|
||||
background-color: rgba(237, 237, 237, 0.86);
|
||||
color: var(--crepe-color-on-surface-variant);
|
||||
border-right-color: var(--panel-border);
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteac
|
||||
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
|
||||
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
|
||||
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
|
||||
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf'
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function convertFileToMarkdown(file) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-secret-key-here',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file: base64,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
export const DOC_BLOCK_NODE_TYPE = 'doc_block'
|
||||
export const DOC_BLOCK_FENCE_LANG = 'llm-file'
|
||||
export const DOC_CONTEXT_LIMIT = 32 * 1024
|
||||
|
||||
const IMAGE_MD_RE = /!\[[^\]]*]\([^)]+\)/g
|
||||
const IMAGE_HTML_RE = /<img\b[^>]*>/gi
|
||||
const HEADER_SEPARATOR = '\n---\n'
|
||||
|
||||
export function normalizeDocType(value = '') {
|
||||
const lower = String(value || '').trim().toLowerCase()
|
||||
if (lower === 'txt' || lower === 'text' || lower === 'plain') return 'txt'
|
||||
if (lower === 'doc' || lower === 'docx' || lower === 'word') return 'docx'
|
||||
if (lower === 'ppt' || lower === 'pptx' || lower === 'powerpoint') return 'pptx'
|
||||
if (lower === 'pdf') return 'pdf'
|
||||
return 'txt'
|
||||
}
|
||||
|
||||
export function getDocTypeFromFilename(name = '') {
|
||||
const lower = String(name || '').toLowerCase()
|
||||
if (lower.endsWith('.docx')) return 'docx'
|
||||
if (lower.endsWith('.pptx')) return 'pptx'
|
||||
if (lower.endsWith('.pdf')) return 'pdf'
|
||||
return 'txt'
|
||||
}
|
||||
|
||||
export function isSupportedDocFile(file) {
|
||||
if (!file) return false
|
||||
const name = String(file.name || '').toLowerCase()
|
||||
const type = String(file.type || '').toLowerCase()
|
||||
return (
|
||||
name.endsWith('.txt') ||
|
||||
name.endsWith('.docx') ||
|
||||
name.endsWith('.pptx') ||
|
||||
name.endsWith('.pdf') ||
|
||||
type === 'text/plain' ||
|
||||
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||
type === 'application/pdf'
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeDocContent(markdown = '') {
|
||||
return String(markdown || '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(IMAGE_MD_RE, '')
|
||||
.replace(IMAGE_HTML_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function quoteMeta(value = '') {
|
||||
return JSON.stringify(String(value ?? ''))
|
||||
}
|
||||
|
||||
function parseMetaLine(line = '') {
|
||||
const idx = line.indexOf(':')
|
||||
if (idx < 0) return null
|
||||
const key = line.slice(0, idx).trim()
|
||||
const rawValue = line.slice(idx + 1).trim()
|
||||
if (!key) return null
|
||||
try {
|
||||
return [key, JSON.parse(rawValue)]
|
||||
} catch {
|
||||
return [key, rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
function pickFence(content = '') {
|
||||
const matches = String(content || '').match(/`{3,}/g) || []
|
||||
const maxLen = matches.reduce((max, item) => Math.max(max, item.length), 2)
|
||||
return '`'.repeat(maxLen + 1)
|
||||
}
|
||||
|
||||
export function buildDocBlockValue(attrs = {}) {
|
||||
const docType = normalizeDocType(attrs.docType)
|
||||
const docName = String(attrs.docName || `document.${docType}`)
|
||||
const uploadTime = String(attrs.uploadTime || new Date().toISOString())
|
||||
const collapsed = Boolean(attrs.collapsed)
|
||||
const content = sanitizeDocContent(attrs.content || '')
|
||||
return [
|
||||
`type: ${quoteMeta(docType)}`,
|
||||
`name: ${quoteMeta(docName)}`,
|
||||
`uploadTime: ${quoteMeta(uploadTime)}`,
|
||||
`collapsed: ${collapsed ? 'true' : 'false'}`,
|
||||
'---',
|
||||
content,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function parseDocBlockValue(raw = '') {
|
||||
const normalized = String(raw || '').replace(/\r\n?/g, '\n')
|
||||
const separatorIndex = normalized.indexOf(HEADER_SEPARATOR)
|
||||
const headerText = separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : ''
|
||||
const bodyText = separatorIndex >= 0 ? normalized.slice(separatorIndex + HEADER_SEPARATOR.length) : normalized
|
||||
const attrs = {
|
||||
docType: 'txt',
|
||||
docName: 'document.txt',
|
||||
uploadTime: '',
|
||||
collapsed: false,
|
||||
content: sanitizeDocContent(bodyText),
|
||||
}
|
||||
|
||||
for (const line of headerText.split('\n')) {
|
||||
const parsed = parseMetaLine(line)
|
||||
if (!parsed) continue
|
||||
const [key, value] = parsed
|
||||
if (key === 'type') attrs.docType = normalizeDocType(value)
|
||||
if (key === 'name' && value) attrs.docName = String(value)
|
||||
if (key === 'uploadTime' && value) attrs.uploadTime = String(value)
|
||||
if (key === 'collapsed') attrs.collapsed = value === true || value === 'true'
|
||||
}
|
||||
|
||||
if (!attrs.docName) attrs.docName = `document.${attrs.docType}`
|
||||
return attrs
|
||||
}
|
||||
|
||||
export function buildDocBlockMarkdown(attrs = {}) {
|
||||
const value = buildDocBlockValue(attrs)
|
||||
const fence = pickFence(value)
|
||||
return `${fence}${DOC_BLOCK_FENCE_LANG}\n${value}\n${fence}`
|
||||
}
|
||||
|
||||
export function buildDocContextFence(attrs = {}) {
|
||||
const docType = normalizeDocType(attrs.docType)
|
||||
const content = sanitizeDocContent(attrs.content || '')
|
||||
const fence = pickFence(content)
|
||||
return `${fence}${docType}\n${content}\n${fence}`
|
||||
}
|
||||
|
||||
export function buildLegacyDocBlock(attrs = {}) {
|
||||
const docType = normalizeDocType(attrs.docType)
|
||||
const docName = String(attrs.docName || `document.${docType}`)
|
||||
const uploadTime = String(attrs.uploadTime || new Date().toISOString())
|
||||
const content = sanitizeDocContent(attrs.content || '')
|
||||
return `<doc_type="${docType}" doc_name="${docName}" upload_time="${uploadTime}" collapsed="${Boolean(attrs.collapsed)}">\n${content}\n</doc_end>`
|
||||
}
|
||||
|
||||
export function parseLegacyDocBlock(raw = '') {
|
||||
const match = String(raw || '').match(/^<doc_type="([^"]+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)"(?:\s+collapsed="([^"]+)")?>\n?([\s\S]*?)\n?<\/doc_end>$/)
|
||||
if (!match) return null
|
||||
return {
|
||||
docType: normalizeDocType(match[1]),
|
||||
docName: match[2] || 'document.txt',
|
||||
uploadTime: match[3] || '',
|
||||
collapsed: match[4] === 'true',
|
||||
content: sanitizeDocContent(match[5] || ''),
|
||||
}
|
||||
}
|
||||
|
||||
export function transformDocBlockMarkdownForClipboard(markdown = '') {
|
||||
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
|
||||
const replacedFence = String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
|
||||
const attrs = parseDocBlockValue(value)
|
||||
return `${prefix}${buildDocContextFence(attrs)}`
|
||||
})
|
||||
return replacedFence.replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
|
||||
const attrs = parseLegacyDocBlock(full)
|
||||
return attrs ? buildDocContextFence(attrs) : full
|
||||
})
|
||||
}
|
||||
|
||||
export function stripDocBlockMarkdown(markdown = '') {
|
||||
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g
|
||||
return String(markdown || '').replace(pattern, '$1').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
export function transformLegacyDocBlocksForExport(markdown = '') {
|
||||
return String(markdown || '').replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
|
||||
const attrs = parseLegacyDocBlock(full)
|
||||
return attrs ? buildDocBlockMarkdown(attrs) : full
|
||||
})
|
||||
}
|
||||
|
||||
export function transformSpecialDocBlocksToLegacy(markdown = '') {
|
||||
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
|
||||
return String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
|
||||
const attrs = parseDocBlockValue(value)
|
||||
return `${prefix}${buildLegacyDocBlock(attrs)}`
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user