Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05b1cbf80d |
-14
@@ -1,14 +0,0 @@
|
||||
[run]
|
||||
source = backend
|
||||
omit =
|
||||
backend/tests/*
|
||||
backend/test_*.py
|
||||
backend/__pycache__/*
|
||||
|
||||
[report]
|
||||
fail_under = 90
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
raise NotImplementedError
|
||||
if __name__ == .__main__.:
|
||||
+1
-7
@@ -1,4 +1,4 @@
|
||||
# Logs
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -39,9 +39,3 @@ env/
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
|
||||
# IDE directories
|
||||
.kilocode/
|
||||
.kilo/
|
||||
.codex/
|
||||
@@ -1,52 +0,0 @@
|
||||
# 导出按钮缺失修复计划
|
||||
|
||||
## 问题分析
|
||||
当前 `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,19 +1,4 @@
|
||||
# rules.md
|
||||
|
||||
在构建这个LLM应用网页时,你需要基于VUE3开发。我需要前端只运行渲染和数据回传,后端负责llm api调用,类似copilet的auto inline suggustions实现和数据解析。
|
||||
|
||||
# **重要** : 在回复用户消息时,一定要使用中文
|
||||
|
||||
## 指导原则
|
||||
|
||||
- 不要擅自用npm或者yarn运行网页,你既看不到网页的内容,也无法阻止命令暂停。但是,你可以用npm run build检查代码。
|
||||
- 应该保证代码效率,不多定义变量,不写冗余注释,把降低延迟放在第一位。
|
||||
- 每次完成任务前都要反复阅读检查代码,确保代码准确无误。
|
||||
- 尽量不要搜索关键字,而是了解代码结构后查询整个问题代码明确问题所在。
|
||||
- @/milkdown-docs/ 代表milkdown的最新官方文档,不要修改,涉及到前端编辑器的指令时要核对官方文档。
|
||||
|
||||
|
||||
# 仓库指南
|
||||
# 仓库指南
|
||||
|
||||
## 语言约定
|
||||
项目文档、日志、错误提示以及对外返回的文字信息统一使用 **中文**。前端 UI 默认展示中文,若需多语言支持请在相应模块实现。
|
||||
@@ -63,3 +48,5 @@ dist/ # 构建产出(生成文件)
|
||||
- 按照 `backend/main.py` 中的实现,对上传文件的大小和类型进行校验,防止滥用。
|
||||
- 定期审计依赖安全(`npm audit`、`pip-audit`)。
|
||||
|
||||
---
|
||||
以上指南旨在保持贡献一致性并维护代码库健康,欢迎通过 Pull Request 提出改进。
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
# 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)
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
# 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,147 +1,264 @@
|
||||
# LLM in Text - 智能写作助手
|
||||
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能。
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### Markdown 编辑器
|
||||
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
||||
- 支持 Markdown 语法和 LaTeX 公式
|
||||
- 支持 Mermaid 图表渲染
|
||||
- 支持完整 Markdown 语法和 LaTeX 公式
|
||||
- 导入/导出 Markdown 文件
|
||||
- 导出 DOCX 和 PDF 格式
|
||||
|
||||
### AI 智能补全
|
||||
- 实时生成文本补全建议(灰色显示)
|
||||
- 流式响应,低延迟体验
|
||||
- 多种交互方式:Tab接受、Esc拒绝、点击接受
|
||||
- 多种交互方式:
|
||||
- **Tab 键**:接受建议
|
||||
- **Esc 键**:拒绝建议
|
||||
- **点击灰色文本**:接受建议
|
||||
- **继续输入**:自动拒绝建议
|
||||
|
||||
### 文档处理
|
||||
- OCR 图片识别:上传图片自动识别文字
|
||||
- 文档转换:PDF、DOCX、PPTX、TXT 转 Markdown
|
||||
- 文档块嵌入:可折叠的文档预览块
|
||||
- 智能大小限制:32KB自动禁用AI
|
||||
|
||||
### 设置面板
|
||||
- 外观主题:亮色/暗色/跟随系统
|
||||
- 背景模式:默认/暖色/阅读灯/自定义图片
|
||||
- 模型智能:低/中/高思考级别
|
||||
- 隐私控制:隐私模式防止发送IP
|
||||
- 多语言界面:中英日韩德法
|
||||
|
||||
### 语音功能
|
||||
- TTS文字转语音(macOS优化,支持Apple Silicon M1/M2/M3)
|
||||
- STT语音转文字(支持多种模型大小和量化)
|
||||
- 自动设备检测(MPS/CUDA/CPU智能切换)
|
||||
- 离线模式支持(模型缓存检查)
|
||||
### AI 开关控制
|
||||
- 右下角 AI 开关按钮
|
||||
- 白色 = AI 启用,黑色 = AI 禁用
|
||||
- 禁用时自动清除灰色文本并停止 API 调用
|
||||
|
||||
## 技术架构
|
||||
|
||||
前端: Vue3 + Vite + Milkdown + ProseMirror
|
||||
后端: FastAPI + Python + Ollama
|
||||
```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
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
环境: Node.js 18+、Python 3.8+、Ollama
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- Python 3.8+
|
||||
- Ollama 服务(或其他兼容 OpenAI API 的服务)
|
||||
|
||||
安装:
|
||||
- 前端: npm install
|
||||
- 后端: pip install -r backend/requirements.txt
|
||||
### 安装
|
||||
|
||||
启动:
|
||||
- 后端: python backend/main.py (端口8001)
|
||||
- 前端: npm run dev (端口5173)
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
|
||||
## API接口
|
||||
# 后端
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- POST /v1/completions 流式补全建议
|
||||
- POST /v1/ocr 图片文字识别
|
||||
- POST /v1/convert 文档转换
|
||||
- POST /v1/completions/cancel 取消请求
|
||||
- GET /v1/tts-asr/status TTS/ASR模型状态
|
||||
- GET /v1/tts-asr/config TTS/ASR配置信息
|
||||
- POST /v1/tts-asr/warmup 模型预热
|
||||
- POST /v1/tts-asr/tts 文字转语音
|
||||
- POST /v1/tts-asr/asr 语音转文字
|
||||
### 配置
|
||||
|
||||
## TTS/ASR环境变量配置
|
||||
在 `backend/.env` 中配置:
|
||||
|
||||
支持以下环境变量来配置TTS/ASR模块:
|
||||
```env
|
||||
OLLAMA_MODEL=gpt-oss:20b
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
```
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `TTS_ASR_DEVICE` | 设备选择 (auto/mps/cuda/cpu) | auto |
|
||||
| `TTS_ASR_MODEL_SIZE` | ASR模型大小 (tiny/base/small/medium/large/turbo) | auto |
|
||||
| `TTS_ASR_QUANTIZE` | 是否使用INT8量化 (true/false) | false |
|
||||
| `TTS_ASR_OFFLINE_MODE` | 离线模式,仅使用缓存模型 (true/false) | false |
|
||||
| `TTS_ASR_WARMUP` | 启动时预热模型 (true/false) | true |
|
||||
| `TTS_ASR_WARMUP_TIMEOUT` | 预热超时时间(秒) | 120 |
|
||||
| `TTS_ASR_IDLE_TIMEOUT` | 空闲卸载时间(秒,0=不卸载) | 0 |
|
||||
| `TTS_ASR_MPS_MEMORY_LIMIT_MB` | MPS内存限制(MB) | 8192 |
|
||||
### 启动
|
||||
|
||||
**Apple Silicon优化建议**:
|
||||
- 系统自动检测Apple Silicon并推荐使用`small`模型
|
||||
- MPS内存限制默认为系统内存的60%
|
||||
- 建议使用`small`或`medium`模型以获得更好的性能
|
||||
- 可通过`TTS_ASR_MODEL_SIZE=medium`手动指定模型大小
|
||||
```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}
|
||||
```
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 后端
|
||||
- main.py: FastAPI服务器、SSE流式响应
|
||||
- llm.py: 异步Ollama调用、超时控制
|
||||
- prompt.py: 7条Prompt规则
|
||||
- tts_asr.py: macOS/Apple Silicon优化的TTS/ASR处理
|
||||
- 自动检测Apple Silicon (M1/M2/M3)
|
||||
- MPS/CUDA/CPU智能降级
|
||||
- 支持多种Whisper模型大小
|
||||
- INT8量化支持
|
||||
- 离线模式支持
|
||||
- 健壮的音频重采样
|
||||
### 后端设计
|
||||
|
||||
### 前端
|
||||
- copilotPlugin.ts: ProseMirror Mark系统
|
||||
- 关键函数: scheduleFetch、insertGhostText
|
||||
- Pinia Store状态管理
|
||||
#### 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
|
||||
```
|
||||
|
||||
## 设计亮点
|
||||
|
||||
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
|
||||
|
||||
### 运行测试
|
||||
|
||||
项目提供完整的测试套件,包括单元测试、集成测试和macOS环境模拟测试:
|
||||
|
||||
```bash
|
||||
# 快速运行单元测试
|
||||
python backend/tests/run_tests.py unit
|
||||
|
||||
# 运行集成测试(需要启动后端服务)
|
||||
python backend/tests/run_tests.py integration
|
||||
|
||||
# 运行macOS环境模拟测试(在非Mac环境测试)
|
||||
python backend/tests/run_tests.py simulate
|
||||
|
||||
# 运行所有测试
|
||||
python backend/tests/run_tests.py all
|
||||
```
|
||||
|
||||
详细测试说明请参考: [测试指南](backend/tests/TESTING_GUIDE.md)
|
||||
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
|
||||
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求
|
||||
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
|
||||
4. **多种交互方式**:Tab/Esc/点击/输入,用户体验友好
|
||||
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
# TTS/ASR模块修复完成总结
|
||||
|
||||
## 修复概览
|
||||
|
||||
本次修复彻底重构了`backend/tts_asr.py`,针对macOS和Apple Silicon (M1/M2/M3)进行了全面优化,并提供了完整的测试套件。
|
||||
|
||||
## 修复日期
|
||||
|
||||
**完成时间**: 2026-04-06
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### 核心修改
|
||||
- ✅ `backend/tts_asr.py` - 主要重构(~1150行)
|
||||
- ✅ `backend/requirements.txt` - 添加新依赖
|
||||
- ✅ `README.md` - 更新文档
|
||||
|
||||
### 测试脚本(新增)
|
||||
- ✅ `backend/tests/test_tts_asr_unit.py` - 单元测试
|
||||
- ✅ `backend/tests/test_tts_asr_integration.py` - 集成测试
|
||||
- ✅ `backend/tests/simulate_macos.py` - macOS环境模拟工具
|
||||
- ✅ `backend/tests/run_tests.py` - 测试运行器
|
||||
- ✅ `backend/tests/quick_verify.py` - 快速验证脚本
|
||||
|
||||
### 文档(新增)
|
||||
- ✅ `backend/TTS_ASR_MACOS_FIX.md` - 详细修复说明
|
||||
- ✅ `backend/tests/TESTING_GUIDE.md` - 测试指南
|
||||
|
||||
## 核心改进汇总
|
||||
|
||||
### 1. 设备检测系统(DeviceCapabilities)
|
||||
|
||||
**改进前**:
|
||||
- 简单的MPS/CUDA检测
|
||||
- 缺少内存管理
|
||||
- 无Apple Silicon特殊处理
|
||||
|
||||
**改进后**:
|
||||
- `DeviceCapabilities`数据类,结构化存储设备信息
|
||||
- 全面的MPS/CUDA可用性测试(1000x1000矩阵运算)
|
||||
- Apple Silicon自动识别(Darwin + arm64)
|
||||
- 动态内存管理(MPS内存限制为系统内存的60%)
|
||||
- 智能设备降级策略
|
||||
|
||||
### 2. 模型加载优化
|
||||
|
||||
**改进前**:
|
||||
- 固定使用large-v3-turbo模型
|
||||
- 无内存优化选项
|
||||
- 缺少离线模式支持
|
||||
|
||||
**改进后**:
|
||||
- 6种模型大小可选(tiny/base/small/medium/large/turbo)
|
||||
- Apple Silicon自动推荐`small`模型
|
||||
- INT8量化支持(减少内存占用)
|
||||
- 离线模式(检查模型缓存)
|
||||
- 环境变量驱动的配置
|
||||
|
||||
### 3. 音频处理鲁棒性
|
||||
|
||||
**改进前**:
|
||||
- librosa.resample无回退
|
||||
- 缺少音频验证
|
||||
|
||||
**改进后**:
|
||||
- `_validate_audio_data()`: 完整的音频数据验证
|
||||
- `_resample_audio_robust()`: 多重回退重采样
|
||||
- librosa.resample → torchaudio → NumPy线性插值
|
||||
- 所有音频操作都有完整的错误处理
|
||||
|
||||
### 4. 环境变量配置
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `TTS_ASR_DEVICE` | 设备选择 | `auto` |
|
||||
| `TTS_ASR_MODEL_SIZE` | ASR模型大小 | `auto` |
|
||||
| `TTS_ASR_QUANTIZE` | INT8量化 | `false` |
|
||||
| `TTS_ASR_OFFLINE_MODE` | 离线模式 | `false` |
|
||||
| `TTS_ASR_WARMUP` | 启动预热 | `true` |
|
||||
| `TTS_ASR_WARMUP_TIMEOUT` | 预热超时(秒) | `120` |
|
||||
| `TTS_ASR_IDLE_TIMEOUT` | 空闲卸载(秒) | `0` |
|
||||
| `TTS_ASR_MPS_MEMORY_LIMIT_MB` | MPS内存限制 | `8192` |
|
||||
|
||||
### 5. 新增API端点
|
||||
|
||||
- `GET /v1/tts-asr/config`: 获取完整配置信息
|
||||
- 增强`/v1/tts-asr/status`: 包含设备能力、模型大小等
|
||||
- 增强`/v1/tts-asr/warmup`: 返回详细预热结果
|
||||
|
||||
## 测试套件概览
|
||||
|
||||
### 单元测试(test_tts_asr_unit.py)
|
||||
|
||||
覆盖8个测试类,共20+测试用例:
|
||||
|
||||
- `TestAppleSiliconDetection`: Apple Silicon检测
|
||||
- `TestEnvironmentVariables`: 环境变量解析
|
||||
- `TestModelSizeSelection`: 模型大小选择
|
||||
- `TestAudioValidation`: 音频验证
|
||||
- `TestAudioResampling`: 音频重采样
|
||||
- `TestDeviceCapabilities`: 设备能力
|
||||
- `TestModelCacheCheck`: 模型缓存
|
||||
- `TestRequestResponseModels`: API模型
|
||||
|
||||
### 集成测试(test_tts_asr_integration.py)
|
||||
|
||||
需要运行后端服务,测试完整API流程:
|
||||
|
||||
- 配置端点测试
|
||||
- 状态端点测试
|
||||
- 预热端点测试
|
||||
- TTS功能测试
|
||||
- ASR功能测试
|
||||
- API密钥验证
|
||||
- 长文本处理
|
||||
- 性能基准测试
|
||||
|
||||
### macOS模拟测试(simulate_macos.py)
|
||||
|
||||
在非macOS环境下模拟Apple Silicon环境:
|
||||
|
||||
- Apple Silicon环境模拟
|
||||
- MPS设备模拟
|
||||
- CUDA设备模拟
|
||||
- 内存管理测试
|
||||
- 完整环境变量测试
|
||||
|
||||
## 使用建议
|
||||
|
||||
### Apple Silicon推荐配置
|
||||
|
||||
**8GB内存**:
|
||||
```bash
|
||||
export TTS_ASR_MODEL_SIZE=small
|
||||
export TTS_ASR_MPS_MEMORY_LIMIT_MB=4096
|
||||
```
|
||||
|
||||
**16GB+内存**:
|
||||
```bash
|
||||
export TTS_ASR_MODEL_SIZE=medium
|
||||
export TTS_ASR_MPS_MEMORY_LIMIT_MB=8192
|
||||
```
|
||||
|
||||
**内存紧张**:
|
||||
```bash
|
||||
export TTS_ASR_MODEL_SIZE=tiny
|
||||
export TTS_ASR_QUANTIZE=true
|
||||
```
|
||||
|
||||
### 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
# 2. 快速验证
|
||||
python backend/tests/quick_verify.py
|
||||
|
||||
# 3. 运行单元测试
|
||||
pytest backend/tests/test_tts_asr_unit.py -v
|
||||
|
||||
# 4. macOS模拟测试
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
|
||||
# 5. 启动后端服务
|
||||
python backend/main.py
|
||||
|
||||
# 6. 运行集成测试(另一终端)
|
||||
python backend/tests/test_tts_asr_integration.py
|
||||
```
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
所有改动保持100%向后兼容:
|
||||
|
||||
- ✅ 现有API端点未改变
|
||||
- ✅ 默认行为与原版一致
|
||||
- ✅ 新功能通过环境变量启用
|
||||
- ✅ 无需修改现有代码
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **MPS float16**: 默认使用float32以避免潜在问题
|
||||
2. **8-bit量化**: 仅在CPU和CUDA环境支持
|
||||
3. **Core ML**: 预留扩展点但未实现
|
||||
|
||||
## 性能影响
|
||||
|
||||
- **Apple Silicon**: 推荐使用small模型,性能更稳定
|
||||
- **MPS内存**: 自动限制为系统内存的60%,避免OOM
|
||||
- **模型加载**: 支持预热和空闲卸载,优化内存使用
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 模型加载失败
|
||||
1. 检查网络连接
|
||||
2. 关闭离线模式: `export TTS_ASR_OFFLINE_MODE=false`
|
||||
3. 使用预热端点: `POST /v1/tts-asr/warmup`
|
||||
|
||||
### MPS内存不足
|
||||
1. 使用更小模型: `export TTS_ASR_MODEL_SIZE=tiny`
|
||||
2. 启用量化: `export TTS_ASR_QUANTIZE=true`
|
||||
3. 降低内存限制: `export TTS_ASR_MPS_MEMORY_LIMIT_MB=4096`
|
||||
|
||||
### 音频处理失败
|
||||
1. 检查音频格式(支持WAV)
|
||||
2. 确保采样率≥8000Hz
|
||||
3. 查看详细日志
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
1. 集成Core ML作为备选推理后端
|
||||
2. 支持torch.compile (PyTorch 2.0+)
|
||||
3. 实现模型下载进度显示
|
||||
4. 添加更多音频格式支持
|
||||
|
||||
## 验证状态
|
||||
|
||||
✅ 所有文件已创建
|
||||
✅ 核心函数已实现
|
||||
✅ 环境变量已配置
|
||||
✅ 测试脚本已编写
|
||||
✅ 文档已更新
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请参考:
|
||||
- [修复详细说明](./TTS_ASR_MACOS_FIX.md)
|
||||
- [测试指南](./tests/TESTING_GUIDE.md)
|
||||
- [README更新](../README.md)
|
||||
|
||||
---
|
||||
|
||||
**修复完成确认**: 所有TTS/ASR模块修复已完成,代码已全面重构并优化,测试套件完整,文档齐全。
|
||||
@@ -1,319 +0,0 @@
|
||||
# TTS/ASR macOS适配修复说明
|
||||
|
||||
## 修复概述
|
||||
|
||||
本次修复彻底重构了`backend/tts_asr.py`,针对macOS和Apple Silicon (M1/M2/M3)进行了全面优化。
|
||||
|
||||
## 主要改进
|
||||
|
||||
### 1. 增强的设备检测 (`_detect_device_capabilities`)
|
||||
|
||||
**改进前问题**:
|
||||
- 简单的张量乘法测试不足以验证MPS设备实际可用性
|
||||
- 缺少内存限制检测
|
||||
- Apple Silicon没有特殊处理
|
||||
|
||||
**改进后**:
|
||||
- 使用`DeviceCapabilities`结构化存储设备信息
|
||||
- 更全面的MPS测试(1000x1000矩阵运算)
|
||||
- 自动检测Apple Silicon并调整内存限制
|
||||
- 根据系统内存动态设置MPS内存阈值(默认60%)
|
||||
- 支持设备能力降级(MPS→CPU, CUDA→CPU)
|
||||
|
||||
**验证方法**:
|
||||
```python
|
||||
# 在Python环境中测试
|
||||
from tts_asr import _detect_device_capabilities
|
||||
caps = _detect_device_capabilities()
|
||||
print(f"Device: {caps.device}")
|
||||
print(f"MPS Available: {caps.mps_available}")
|
||||
print(f"Apple Silicon: {_is_apple_silicon()}")
|
||||
```
|
||||
|
||||
### 2. 模型大小选择和量化支持
|
||||
|
||||
**新增环境变量**:
|
||||
- `TTS_ASR_MODEL_SIZE`: 选择Whisper模型大小
|
||||
- `tiny`: 最小模型,最快但准确度较低
|
||||
- `base`: 基础模型,平衡性能和准确度
|
||||
- `small`: 推荐用于Apple Silicon
|
||||
- `medium`: 中等模型
|
||||
- `large`: 大模型,最高准确度
|
||||
- `turbo`: large-v3-turbo (原默认模型)
|
||||
- `auto`: 自动选择(Apple Silicon默认small)
|
||||
|
||||
- `TTS_ASR_QUANTIZE`: 启用INT8量化(减少内存占用)
|
||||
|
||||
**Apple Silicon优化**:
|
||||
- 自动检测并推荐`small`模型
|
||||
- 考虑MPS内存限制选择合适模型
|
||||
|
||||
**验证方法**:
|
||||
```python
|
||||
# 查看推荐的模型大小
|
||||
from tts_asr import _get_recommended_model_size
|
||||
print(_get_recommended_model_size()) # Apple Silicon: "small"
|
||||
```
|
||||
|
||||
### 3. 离线模式支持
|
||||
|
||||
**新增环境变量**:
|
||||
- `TTS_ASR_OFFLINE_MODE`: 启用离线模式
|
||||
- 启动前检查模型是否已缓存
|
||||
- 缓存不存在时优雅失败而非崩溃
|
||||
|
||||
**验证方法**:
|
||||
```bash
|
||||
# 启用离线模式
|
||||
export TTS_ASR_OFFLINE_MODE=true
|
||||
python backend/main.py
|
||||
|
||||
# 检查模型缓存
|
||||
python -c "from tts_asr import _check_model_cached; print(_check_model_cached('openai/whisper-small'))"
|
||||
```
|
||||
|
||||
### 4. 健壮的音频处理
|
||||
|
||||
**改进前问题**:
|
||||
- `librosa.resample`失败时无回退
|
||||
- 缺少音频数据验证
|
||||
|
||||
**改进后**:
|
||||
- `_validate_audio_data()`: 验证音频数据有效性
|
||||
- `_resample_audio_robust()`: 多重回退重采样
|
||||
1. 优先使用`librosa.resample`
|
||||
2. 回退到`torchaudio.transforms.Resample`
|
||||
3. 最后使用NumPy线性插值
|
||||
|
||||
**验证方法**:
|
||||
```python
|
||||
import numpy as np
|
||||
from tts_asr import _resample_audio_robust
|
||||
|
||||
# 测试重采样
|
||||
audio = np.random.randn(16000).astype(np.float32)
|
||||
resampled = _resample_audio_robust(audio, 16000, 48000)
|
||||
print(f"Original: {len(audio)}, Resampled: {len(resampled)}")
|
||||
```
|
||||
|
||||
### 5. 改进的错误处理和降级
|
||||
|
||||
**降级路径**:
|
||||
```
|
||||
MPS推理失败 → 标记MPS不可用 → 清理MPS缓存 → 降级到CPU
|
||||
CUDA推理失败 → 标记CUDA不可用 → 清理CUDA缓存 → 降级到CPU
|
||||
```
|
||||
|
||||
**日志改进**:
|
||||
- 详细记录设备检测过程
|
||||
- 明确标注降级原因
|
||||
- 显示模型大小、量化状态、离线模式等配置
|
||||
|
||||
### 6. 新增API端点
|
||||
|
||||
**GET /v1/tts-asr/config**:
|
||||
```json
|
||||
{
|
||||
"environment": {
|
||||
"TTS_ASR_DEVICE": "auto",
|
||||
"TTS_ASR_MODEL_SIZE": "auto",
|
||||
"TTS_ASR_QUANTIZE": false,
|
||||
"TTS_ASR_OFFLINE_MODE": false,
|
||||
...
|
||||
},
|
||||
"device": {
|
||||
"current": "mps",
|
||||
"mps_available": true,
|
||||
"cuda_available": false,
|
||||
"is_apple_silicon": true,
|
||||
"mps_memory_limit_mb": 8192
|
||||
},
|
||||
"model": {
|
||||
"tts": "hexgrad/Kokoro-82M",
|
||||
"asr_current_size": "small",
|
||||
"asr_recommended_size": "small",
|
||||
"available_sizes": ["tiny", "base", "small", "medium", "large", "turbo"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 环境变量完整列表
|
||||
|
||||
| 变量名 | 说明 | 默认值 | 示例 |
|
||||
|--------|------|--------|------|
|
||||
| `TTS_ASR_DEVICE` | 设备选择 | `auto` | `mps`, `cuda`, `cpu` |
|
||||
| `TTS_ASR_MODEL_SIZE` | ASR模型大小 | `auto` | `tiny`, `base`, `small`, `medium`, `large`, `turbo` |
|
||||
| `TTS_ASR_QUANTIZE` | INT8量化 | `false` | `true`, `false` |
|
||||
| `TTS_ASR_OFFLINE_MODE` | 离线模式 | `false` | `true`, `false` |
|
||||
| `TTS_ASR_WARMUP` | 启动预热 | `true` | `true`, `false` |
|
||||
| `TTS_ASR_WARMUP_TIMEOUT` | 预热超时(秒) | `120` | `60`, `180` |
|
||||
| `TTS_ASR_IDLE_TIMEOUT` | 空闲卸载(秒) | `0` | `300`, `600` |
|
||||
| `TTS_ASR_MPS_MEMORY_LIMIT_MB` | MPS内存限制(MB) | `8192` | `4096`, `16384` |
|
||||
|
||||
## macOS使用建议
|
||||
|
||||
### 推荐配置
|
||||
|
||||
**Apple Silicon (M1/M2/M3) 8GB内存**:
|
||||
```bash
|
||||
export TTS_ASR_MODEL_SIZE=small
|
||||
export TTS_ASR_MPS_MEMORY_LIMIT_MB=4096
|
||||
```
|
||||
|
||||
**Apple Silicon (M1/M2/M3) 16GB+内存**:
|
||||
```bash
|
||||
export TTS_ASR_MODEL_SIZE=medium
|
||||
export TTS_ASR_MPS_MEMORY_LIMIT_MB=8192
|
||||
```
|
||||
|
||||
**内存紧张时**:
|
||||
```bash
|
||||
export TTS_ASR_MODEL_SIZE=tiny
|
||||
export TTS_ASR_QUANTIZE=true
|
||||
```
|
||||
|
||||
### 性能优化建议
|
||||
|
||||
1. **首次运行**: 建议不使用离线模式,让模型自动下载
|
||||
2. **后续运行**: 启用离线模式避免网络延迟
|
||||
```bash
|
||||
export TTS_ASR_OFFLINE_MODE=true
|
||||
```
|
||||
|
||||
3. **长期运行服务**: 设置空闲超时自动卸载模型
|
||||
```bash
|
||||
export TTS_ASR_IDLE_TIMEOUT=600 # 10分钟后卸载
|
||||
```
|
||||
|
||||
4. **调试模式**: 查看详细设备检测日志
|
||||
```python
|
||||
import logging
|
||||
logging.getLogger("tts_asr").setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
## 验证步骤(非Mac环境)
|
||||
|
||||
由于你不在Mac环境下,可以使用以下方法验证代码逻辑:
|
||||
|
||||
### 1. 代码静态检查
|
||||
```bash
|
||||
# 检查Python语法
|
||||
python -m py_compile backend/tts_asr.py
|
||||
|
||||
# 检查导入
|
||||
python -c "import backend.tts_asr"
|
||||
```
|
||||
|
||||
### 2. 单元测试模拟
|
||||
```python
|
||||
# 模拟Apple Silicon环境
|
||||
import os
|
||||
import platform
|
||||
|
||||
# 模拟Darwin/arm64
|
||||
original_system = platform.system
|
||||
original_machine = platform.machine
|
||||
|
||||
def mock_system():
|
||||
return "Darwin"
|
||||
|
||||
def mock_machine():
|
||||
return "arm64"
|
||||
|
||||
platform.system = mock_system
|
||||
platform.machine = mock_machine
|
||||
|
||||
# 测试Apple Silicon检测
|
||||
from tts_asr import _is_apple_silicon
|
||||
assert _is_apple_silicon() == True
|
||||
|
||||
# 恢复原始函数
|
||||
platform.system = original_system
|
||||
platform.machine = original_machine
|
||||
```
|
||||
|
||||
### 3. 环境变量测试
|
||||
```python
|
||||
import os
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'small'
|
||||
os.environ['TTS_ASR_QUANTIZE'] = 'true'
|
||||
|
||||
# 重新加载模块
|
||||
import importlib
|
||||
import backend.tts_asr
|
||||
importlib.reload(backend.tts_asr)
|
||||
|
||||
from backend.tts_asr import TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE
|
||||
assert TTS_ASR_MODEL_SIZE == 'small'
|
||||
assert TTS_ASR_QUANTIZE == True
|
||||
```
|
||||
|
||||
### 4. API端点测试(需要运行服务)
|
||||
```bash
|
||||
# 启动服务
|
||||
python backend/main.py
|
||||
|
||||
# 测试配置端点(需要API Key)
|
||||
curl -X GET "http://localhost:8001/v1/tts-asr/config" \
|
||||
-H "X-API-Key: your-secret-key-here"
|
||||
|
||||
# 测试状态端点
|
||||
curl -X GET "http://localhost:8001/v1/tts-asr/status" \
|
||||
-H "X-API-Key: your-secret-key-here"
|
||||
```
|
||||
|
||||
## 依赖更新
|
||||
|
||||
已在`backend/requirements.txt`中添加:
|
||||
- `psutil`: 系统内存检测
|
||||
- `torchaudio`: 音频重采样备选方案
|
||||
|
||||
安装新依赖:
|
||||
```bash
|
||||
pip install -r backend/requirements.txt
|
||||
```
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
所有改动保持向后兼容:
|
||||
- 现有API端点未改变
|
||||
- 默认行为与原版一致
|
||||
- 新功能通过环境变量启用
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **MPS float16**: 在某些操作上可能不稳定,代码默认使用float32
|
||||
2. **8-bit量化**: 仅在CPU和CUDA环境支持,MPS不支持
|
||||
3. **Core ML**: 预留了扩展点但未实现(需要额外依赖)
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
1. 集成Core ML作为备选推理后端
|
||||
2. 支持torch.compile (PyTorch 2.0+)
|
||||
3. 实现模型自动下载的进度显示
|
||||
4. 添加更多音频格式支持
|
||||
|
||||
## 问题排查
|
||||
|
||||
### 模型加载失败
|
||||
1. 检查网络连接
|
||||
2. 尝试关闭离线模式: `export TTS_ASR_OFFLINE_MODE=false`
|
||||
3. 查看详细日志: 设置`logging.getLogger("tts_asr").setLevel(logging.DEBUG)`
|
||||
|
||||
### MPS内存不足
|
||||
1. 使用更小的模型: `export TTS_ASR_MODEL_SIZE=tiny`
|
||||
2. 启用量化: `export TTS_ASR_QUANTIZE=true`
|
||||
3. 降低内存限制: `export TTS_ASR_MPS_MEMORY_LIMIT_MB=4096`
|
||||
|
||||
### 音频处理失败
|
||||
1. 检查音频格式(支持WAV)
|
||||
2. 确保音频采样率≥8000Hz
|
||||
3. 查看日志中的详细错误信息
|
||||
|
||||
---
|
||||
|
||||
**修复完成日期**: 2026-04-06
|
||||
**修改文件**:
|
||||
- `backend/tts_asr.py` (主要重构)
|
||||
- `backend/requirements.txt` (添加依赖)
|
||||
- `README.md` (更新文档)
|
||||
@@ -1,196 +0,0 @@
|
||||
# API Benchmarking Report (2026-04-06 13:45:31)
|
||||
|
||||
**Base URL:** `https://api.imageteach.tech:8002`
|
||||
|
||||
## Executive Summary
|
||||
| Task | Success Rate | Avg TTFB | Avg Latency | P95 Latency | TPS | RPS |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| Completion-Short | 90.0% | 9123.6ms | 9123.8ms | 20222.9ms | 7.9 | 0.08 |
|
||||
| Completion-Normal | 10.0% | 10559.3ms | 10559.6ms | 10559.6ms | 66.4 | 0.66 |
|
||||
| Completion-Long | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 8.97 |
|
||||
| OCR-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 6.75 |
|
||||
| TTS-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 10.17 |
|
||||
| ASR-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 13.02 |
|
||||
| Convert-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 5.98 |
|
||||
|
||||
## Stability & Context Analysis
|
||||
Detailed analysis of how context length affects TTFB and overall performance.
|
||||
|
||||
### Completion-Short Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 123.93s
|
||||
- **Top Errors:**
|
||||
- `[0]`
|
||||
|
||||
### Completion-Normal Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 15.21s
|
||||
- **Top Errors:**
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
### Completion-Long Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 1.11s
|
||||
- **Top Errors:**
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
### OCR-Concurrent Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 1.48s
|
||||
- **Top Errors:**
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
### TTS-Concurrent Details
|
||||
- **Total Samples:** 10
|
||||
- **Duration:** 0.98s
|
||||
- **Top Errors:**
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
|
||||
<body>
|
||||
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
|
||||
<hr><center>openresty</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
- `[502]` <html>
|
||||
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
@@ -1,20 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+31
-9
@@ -6,22 +6,44 @@ from datetime import datetime
|
||||
import ollama
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from prompts import get_vlm_ocr_prompt
|
||||
|
||||
load_dotenv()
|
||||
|
||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
||||
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
||||
|
||||
# Timeouts in seconds (10 minutes for large model loading)
|
||||
COMPLETION_TIMEOUT = 600
|
||||
OCR_TIMEOUT = 600
|
||||
CONVERT_TIMEOUT = 600
|
||||
# Timeouts in seconds
|
||||
COMPLETION_TIMEOUT = 30
|
||||
OCR_TIMEOUT = 60
|
||||
CONVERT_TIMEOUT = 30
|
||||
|
||||
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
||||
logger = logging.getLogger("llm")
|
||||
|
||||
VLM_OCR_CONTEXT_PROMPT = """You are an OCR and visual-context extractor for markdown writing assistance.
|
||||
|
||||
Your output will be embedded inside an HTML comment as hidden context for a text-completion model.
|
||||
|
||||
Requirements:
|
||||
- Keep output compact: maximum 120 words.
|
||||
- Use plain text only (no markdown code fences).
|
||||
- Never output <!-- or -->.
|
||||
- Do not invent unreadable text; mark uncertain characters with ?.
|
||||
- Preserve original script for recognized text (do not forcibly translate).
|
||||
|
||||
Return exactly this format:
|
||||
|
||||
TEXT:
|
||||
<exact transcription of visible text; use " | " for line breaks; write "(none)" if no readable text>
|
||||
|
||||
KEY_DETAILS:
|
||||
- <3-5 short factual bullets about relevant objects/layout>
|
||||
|
||||
LANGUAGE:
|
||||
<dominant language(s) in visible text, e.g. English / Chinese / Mixed>
|
||||
|
||||
SUMMARY:
|
||||
<one short sentence, <= 20 words>"""
|
||||
|
||||
def _extract_message(response) -> tuple[str, str]:
|
||||
content = ""
|
||||
@@ -41,10 +63,10 @@ def _extract_message(response) -> tuple[str, str]:
|
||||
async def call_ollama(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
system_prompt: str = None,
|
||||
tag: str = "default",
|
||||
temperature: float = 0.7,
|
||||
thinking: str | None = None,
|
||||
thinking: str = None,
|
||||
) -> dict:
|
||||
"""
|
||||
调用 Ollama API 并返回 content 和 thinking。
|
||||
@@ -144,7 +166,7 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||
model=VLM_MODEL,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
'content': get_vlm_ocr_prompt(),
|
||||
'content': VLM_OCR_CONTEXT_PROMPT,
|
||||
'images': [image_bytes]
|
||||
}],
|
||||
stream=False,
|
||||
|
||||
+116
-132
@@ -1,21 +1,17 @@
|
||||
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, HTTPException, Request, Security, File, UploadFile
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.security import APIKeyHeader
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
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
|
||||
@@ -26,48 +22,35 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
_markitdown_instance = None
|
||||
|
||||
|
||||
def _get_markitdown(): # pragma: no cover
|
||||
global _markitdown_instance
|
||||
if _markitdown_instance is None:
|
||||
_markitdown_instance = markitdown.MarkItDown()
|
||||
return _markitdown_instance
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.on_event("startup") # pragma: no cover
|
||||
async def startup_event():
|
||||
logger.info("Starting blocking preload for TTS and ASR models...")
|
||||
try:
|
||||
from tts_asr import _warmup_all
|
||||
await _warmup_all()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initiate model warmup: {e}")
|
||||
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()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
|
||||
)
|
||||
# Rate limiting
|
||||
MAX_CONCURRENT_COMPLETIONS = 4
|
||||
COMPLETION_RATE_LIMIT = 60 # per minute
|
||||
|
||||
API_KEY = "your-secret-key-here"
|
||||
api_key_header = APIKeyHeader(name="X-API-Key")
|
||||
# File size limits (bytes)
|
||||
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
MAX_CONVERT_SIZE = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
|
||||
async def get_api_key(api_key: str = Security(api_key_header)): # pragma: no cover
|
||||
if api_key != API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
return api_key
|
||||
# Allowed file extensions
|
||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
ALLOWED_CONVERT_EXTENSIONS = {".pdf", ".docx", ".pptx", ".xlsx", ".md", ".txt"}
|
||||
|
||||
|
||||
class UserPreferences(BaseModel):
|
||||
@@ -101,32 +84,6 @@ 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: # pragma: no cover
|
||||
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:
|
||||
@@ -134,41 +91,34 @@ def _preview(text: str, limit: int = 80) -> str:
|
||||
return value[:limit] + "..."
|
||||
|
||||
|
||||
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 _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 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"
|
||||
def _sse_payload(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
@app.post("/v1/completions")
|
||||
async def create_completion(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
|
||||
async def create_completion(request: Request, req: CompletionRequest):
|
||||
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: # pragma: no cover
|
||||
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 client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s",
|
||||
"[%s] /v1/completions request_id=%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,
|
||||
@@ -184,7 +134,6 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
||||
req.prefix,
|
||||
req.suffix,
|
||||
req.languageId,
|
||||
location=location,
|
||||
thinking_level=req.model_thinking,
|
||||
preferences=req.user_preferences,
|
||||
)
|
||||
@@ -199,10 +148,11 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
||||
)
|
||||
)
|
||||
|
||||
existing = ACTIVE_COMPLETIONS.get(request_id)
|
||||
if existing and not existing.done():
|
||||
existing.cancel()
|
||||
ACTIVE_COMPLETIONS[request_id] = inference_task
|
||||
async with ACTIVE_COMPLETIONS_LOCK:
|
||||
existing = ACTIVE_COMPLETIONS.get(request_id)
|
||||
if existing and not existing.done():
|
||||
existing.cancel()
|
||||
ACTIVE_COMPLETIONS[request_id] = inference_task
|
||||
|
||||
result = await inference_task
|
||||
content = result["content"] or ""
|
||||
@@ -216,21 +166,30 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
||||
_preview(content, 120),
|
||||
)
|
||||
|
||||
return JSONResponse(content={"content": content, "request_id": request_id})
|
||||
async def generate():
|
||||
yield _sse_payload({"content": content})
|
||||
yield _sse_payload({"done": True})
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[%s] /v1/completions cancelled request_id=%s", request_tag, request_id)
|
||||
return JSONResponse(content={"cancelled": True, "request_id": request_id}, status_code=499)
|
||||
|
||||
async def cancelled():
|
||||
yield _sse_payload({"cancelled": True, "request_id": request_id, "done": True})
|
||||
|
||||
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 JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
return _error_response(request_id, "INTERNAL_ERROR", "Service temporarily unavailable", 500)
|
||||
finally:
|
||||
active = ACTIVE_COMPLETIONS.get(request_id)
|
||||
if active is not None and active is inference_task:
|
||||
ACTIVE_COMPLETIONS.pop(request_id, None)
|
||||
async with ACTIVE_COMPLETIONS_LOCK:
|
||||
active = ACTIVE_COMPLETIONS.get(request_id)
|
||||
if active is not None and active is inference_task:
|
||||
ACTIVE_COMPLETIONS.pop(request_id, None)
|
||||
|
||||
|
||||
@app.post("/v1/completions/cancel")
|
||||
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
|
||||
async def cancel_completion(req: CancelCompletionRequest):
|
||||
request_tag = str(uuid.uuid4())[:8]
|
||||
request_id = req.request_id or ""
|
||||
|
||||
@@ -266,7 +225,7 @@ async def cancel_completion(req: CancelCompletionRequest, api_key: str = Securit
|
||||
|
||||
|
||||
@app.post("/v1/ocr")
|
||||
async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
||||
async def ocr_image(request: OCRRequest):
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
try:
|
||||
logger.info(
|
||||
@@ -276,7 +235,22 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
||||
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(
|
||||
@@ -288,12 +262,12 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
||||
return {"text": result, "filename": request.filename}
|
||||
except Exception as e:
|
||||
logger.exception("[%s] /v1/ocr failed: %s", request_id, e)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
return _error_response(request_id, "OCR_FAILED", "Failed to process image", 500)
|
||||
|
||||
|
||||
@app.post("/v1/convert")
|
||||
async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(get_api_key)):
|
||||
"""Convert file to markdown"""
|
||||
async def convert_to_markdown(request: ConvertRequest):
|
||||
"""将文件转换为Markdown格式"""
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
|
||||
try:
|
||||
@@ -304,33 +278,34 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
len(request.file or ""),
|
||||
)
|
||||
|
||||
# Decode base64
|
||||
# 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文件内容
|
||||
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:
|
||||
# Convert using MarkItDown
|
||||
md = _get_markitdown()
|
||||
result = await asyncio.to_thread(md.convert, tmp_path)
|
||||
markdown_text = _sanitize_converted_markdown(result.text_content)
|
||||
# 使用MarkItDown转换为Markdown
|
||||
md = markitdown.MarkItDown()
|
||||
result = md.convert(tmp_path)
|
||||
markdown_text = result.text_content
|
||||
|
||||
logger.info(
|
||||
"[%s] /v1/convert success text_chars=%d text_preview='%s'",
|
||||
@@ -344,23 +319,32 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
"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 JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
return _error_response(request_id, "CONVERT_FAILED", "Failed to convert file", 500)
|
||||
|
||||
# TTS and ASR routes (lazy loaded to avoid heavy import on startup)
|
||||
def _register_tts_asr_routes():
|
||||
from tts_asr import register_tts_asr_routes
|
||||
register_tts_asr_routes(app)
|
||||
|
||||
_register_tts_asr_routes()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
|
||||
@app.get("/health/live")
|
||||
async def health_live():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
+305
-60
@@ -1,15 +1,6 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import re
|
||||
from typing import Protocol, Tuple, runtime_checkable
|
||||
|
||||
from prompts import get_language_guidance_map, get_system_prompt_template, get_inline_examples
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UserPreferences(Protocol):
|
||||
language: str
|
||||
currency: str
|
||||
timezone: str
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def _get_current_datetime(timezone_pref: str = "auto") -> str:
|
||||
@@ -223,36 +214,304 @@ def _canonical_language_id(language_id: str) -> str:
|
||||
return LANGUAGE_SYNONYMS.get(safe, safe)
|
||||
|
||||
|
||||
_JS_LANGS = {"javascript", "typescript"}
|
||||
_CODE_LANGS = {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}
|
||||
|
||||
|
||||
def _language_guidance(language_id: str) -> str:
|
||||
canonical = _canonical_language_id(language_id)
|
||||
if canonical == "markdown":
|
||||
return ""
|
||||
guidance_map = get_language_guidance_map()
|
||||
guidance = guidance_map.get(canonical)
|
||||
if guidance:
|
||||
return guidance
|
||||
if canonical in _JS_LANGS:
|
||||
return guidance_map.get("_js_code", "").replace("{lang}", canonical)
|
||||
if canonical in _CODE_LANGS:
|
||||
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
|
||||
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
|
||||
if canonical == "mermaid":
|
||||
return """
|
||||
Language-specific guidance (mermaid):
|
||||
- Output valid Mermaid syntax only.
|
||||
- Prefer concise, syntactically correct diagram statements.
|
||||
- Avoid prose unless the user prompt explicitly requires it."""
|
||||
if canonical == "latex":
|
||||
return """
|
||||
Language-specific guidance (latex):
|
||||
- Output LaTeX math content only when completing LaTeX.
|
||||
- If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex:
|
||||
- Output raw LaTeX lines only.
|
||||
- Do not wrap with $ or $$."""
|
||||
if canonical == "json":
|
||||
return """
|
||||
Language-specific guidance (json):
|
||||
- Output strict JSON only (no comments, no trailing commas).
|
||||
- Ensure valid quotes and braces."""
|
||||
if canonical == "yaml":
|
||||
return """
|
||||
Language-specific guidance (yaml):
|
||||
- Output valid YAML only.
|
||||
- Use consistent indentation and avoid tabs."""
|
||||
if canonical == "toml":
|
||||
return """
|
||||
Language-specific guidance (toml):
|
||||
- Output valid TOML only.
|
||||
- Keep key types consistent."""
|
||||
if canonical == "ini":
|
||||
return """
|
||||
Language-specific guidance (ini):
|
||||
- Output valid INI only.
|
||||
- Keep section headers and key=value pairs consistent."""
|
||||
if canonical == "sql":
|
||||
return """
|
||||
Language-specific guidance (sql):
|
||||
- Output a single, valid SQL statement unless context requires multiple.
|
||||
- Prefer ANSI SQL when dialect is unclear."""
|
||||
if canonical == "bash":
|
||||
return """
|
||||
Language-specific guidance (bash):
|
||||
- Output POSIX-compatible shell when possible.
|
||||
- Avoid interactive prompts or destructive commands unless requested."""
|
||||
if canonical == "powershell":
|
||||
return """
|
||||
Language-specific guidance (powershell):
|
||||
- Output valid PowerShell commands.
|
||||
- Avoid destructive commands unless explicitly requested."""
|
||||
if canonical == "html":
|
||||
return """
|
||||
Language-specific guidance (html):
|
||||
- Output valid HTML only.
|
||||
- Keep markup minimal and well-formed."""
|
||||
if canonical == "css":
|
||||
return """
|
||||
Language-specific guidance (css):
|
||||
- Output valid CSS only.
|
||||
- Use concise, readable selectors."""
|
||||
if canonical == "diff":
|
||||
return """
|
||||
Language-specific guidance (diff):
|
||||
- Output a unified diff only.
|
||||
- Ensure @@ hunk headers and +/- lines are consistent."""
|
||||
if canonical == "regex":
|
||||
return """
|
||||
Language-specific guidance (regex):
|
||||
- Output the regex pattern only.
|
||||
- Avoid delimiters unless explicitly requested."""
|
||||
if canonical in {"javascript", "typescript"}:
|
||||
return f"""
|
||||
Language-specific guidance ({canonical}):
|
||||
- Output valid {canonical} code.
|
||||
- Prefer modern syntax and avoid prose unless comments are needed."""
|
||||
if canonical in {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}:
|
||||
return f"""
|
||||
Language-specific guidance ({canonical}):
|
||||
- Output valid {canonical} code.
|
||||
- Avoid prose unless context clearly expects comments or docstrings."""
|
||||
if canonical == "text":
|
||||
return """
|
||||
Language-specific guidance (text):
|
||||
- Output plain text only.
|
||||
- Avoid markdown formatting unless explicitly asked."""
|
||||
if canonical == "xml":
|
||||
return """
|
||||
Language-specific guidance (xml):
|
||||
- Output well-formed XML only.
|
||||
- Ensure matching tags and proper escaping."""
|
||||
if canonical == "dockerfile":
|
||||
return """
|
||||
Language-specific guidance (dockerfile):
|
||||
- Output valid Dockerfile instructions only.
|
||||
- Keep layers minimal and ordered logically."""
|
||||
if canonical == "makefile":
|
||||
return """
|
||||
Language-specific guidance (makefile):
|
||||
- Output valid Makefile syntax only.
|
||||
- Use tabs for recipe lines."""
|
||||
return f"""
|
||||
Language-specific guidance ({canonical}):
|
||||
- Output valid {canonical} code.
|
||||
- Avoid prose unless context clearly expects comments or docstrings."""
|
||||
|
||||
|
||||
def build_inline_system_prompt(language_id: str = "markdown") -> str:
|
||||
safe_language_id = _canonical_language_id(language_id)
|
||||
language_guidance = _language_guidance(safe_language_id)
|
||||
template = get_system_prompt_template()
|
||||
system_prompt = template.replace("{language_id}", safe_language_id)
|
||||
|
||||
system_prompt = f"""You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions.
|
||||
|
||||
Return only the insertion text that should be placed between PREFIX and SUFFIX.
|
||||
|
||||
Hard constraints you must follow:
|
||||
1) Output-only contract:
|
||||
- Output insertion text only.
|
||||
- No explanations, no meta labels, no wrapper quotes around the whole answer.
|
||||
|
||||
2) Strict math formatting (KaTeX):
|
||||
- If you output any math expression, it must be strict KaTeX-compatible math.
|
||||
- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block).
|
||||
- Never output bare formulas without $ or $$ wrappers.
|
||||
- Exception: If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex,
|
||||
output raw LaTeX without $ or $$ wrappers.
|
||||
|
||||
3) Strict code formatting:
|
||||
- Read CURSOR_IN_FENCED_CODE_BLOCK from the user prompt.
|
||||
- If CURSOR_IN_FENCED_CODE_BLOCK=true:
|
||||
- You are already inside a fenced code block.
|
||||
- Never output triple backticks.
|
||||
- Output code lines only.
|
||||
- If CURSOR_IN_FENCED_CODE_BLOCK=false:
|
||||
- Any code output must be in a fenced code block with a language tag:
|
||||
```{{language}}
|
||||
...
|
||||
```
|
||||
- Do not output code snippets as inline backticks.
|
||||
- Choose the language tag from context (no default fallback tag instruction).
|
||||
|
||||
4) Mermaid-specific completion rules:
|
||||
- Read CURSOR_FENCE_LANGUAGE and MERMAID_CONTEXT from the user prompt.
|
||||
- If CURSOR_FENCE_LANGUAGE=mermaid:
|
||||
- Output Mermaid statements only.
|
||||
- Never output triple backticks.
|
||||
- Never output prose explanations.
|
||||
- If CURSOR_IN_FENCED_CODE_BLOCK=false and MERMAID_CONTEXT=true:
|
||||
- Output a complete Mermaid fenced block:
|
||||
```mermaid
|
||||
...
|
||||
```
|
||||
- Keep Mermaid syntax valid and concise.
|
||||
- Never mix Mermaid code and explanatory narration in one output.
|
||||
|
||||
5) Boundary newline repair:
|
||||
- Read PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE from the user prompt.
|
||||
- Carefully reason about whether OUTPUT should start or end with a newline.
|
||||
- If PREFIX lacks a required boundary newline, add it at OUTPUT start.
|
||||
- If SUFFIX lacks a required boundary newline, add it at OUTPUT end.
|
||||
- Ensure PREFIX + OUTPUT + SUFFIX is structurally natural.
|
||||
|
||||
6) Context stitching:
|
||||
- Do not repeat text that already appears at the start of SUFFIX.
|
||||
- Preserve nearby language, tone, punctuation, indentation, and markdown structure.
|
||||
- Continue existing structures naturally (lists, tables, block quotes, headings).
|
||||
|
||||
7) OCR safety:
|
||||
- PREFIX may include hidden OCR metadata tags like <OCR:...>.
|
||||
- Never output any OCR tag.
|
||||
- Never output OCR tag fragments such as <OCR:...>."""
|
||||
|
||||
if language_guidance:
|
||||
system_prompt = f"{system_prompt.rstrip()}\n{language_guidance.strip()}"
|
||||
|
||||
return system_prompt.strip()
|
||||
|
||||
|
||||
_INLINE_EXAMPLES = get_inline_examples()
|
||||
INLINE_EXAMPLES = """[EX01] Prose continuation
|
||||
<PREFIX>The quick brown fox </PREFIX>
|
||||
<SUFFIX>jumps over the lazy dog.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
moved quietly and then
|
||||
|
||||
[EX02] Avoid repeating suffix beginning
|
||||
<PREFIX>Our launch plan starts with </PREFIX>
|
||||
<SUFFIX>phase one, followed by phase two.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
careful internal testing before
|
||||
|
||||
[EX03] Continue markdown checklist
|
||||
<PREFIX>## TODO
|
||||
- [ ] Buy milk
|
||||
- [ ] </PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
Write release notes and share draft with team
|
||||
|
||||
[EX04] Cursor outside code block, code must use fenced block
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
||||
<PREFIX>Parse this JSON payload in Python:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
```python
|
||||
import json
|
||||
data = json.loads(payload)
|
||||
```
|
||||
|
||||
[EX05] Cursor inside fenced code block, do not output fences
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=true
|
||||
<PREFIX>```python
|
||||
def add(a, b):
|
||||
return </PREFIX>
|
||||
<SUFFIX>
|
||||
```</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
a + b
|
||||
|
||||
[EX06] Inline math must use $...$
|
||||
<PREFIX>The derivative of x^2 is </PREFIX>
|
||||
<SUFFIX>.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$2x$
|
||||
|
||||
[EX07] Block math must use $$...$$
|
||||
<PREFIX>We can write the Gaussian integral as:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$$
|
||||
\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}
|
||||
$$
|
||||
|
||||
[EX08] Prefix misses boundary newline; add newline at output start
|
||||
PREFIX_ENDS_WITH_NEWLINE=false
|
||||
<PREFIX>Deployment steps:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
|
||||
- Build artifact
|
||||
- Deploy service
|
||||
|
||||
[EX09] Suffix misses boundary newline; add newline at output end
|
||||
SUFFIX_STARTS_WITH_NEWLINE=false
|
||||
<PREFIX>Summary paragraph complete.</PREFIX>
|
||||
<SUFFIX>## Next Section</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
|
||||
|
||||
[EX10] OCR metadata exists but must never be emitted
|
||||
<PREFIX> <OCR:equation y = mx + b>
|
||||
The relationship is </PREFIX>
|
||||
<SUFFIX>.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$y = mx + b$
|
||||
|
||||
[EX11] Continue markdown table with correct row shape
|
||||
<PREFIX>| Name | Score |
|
||||
| --- | --- |
|
||||
| Alice | 92 |
|
||||
| Bob | </PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
88 |
|
||||
|
||||
[EX12] Mixed text + math + code in one insertion
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
||||
<PREFIX>Use the area formula and provide a tiny JS helper.</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
The area is $A = \\pi r^2$.
|
||||
|
||||
```javascript
|
||||
const area = (r) => Math.PI * r * r;
|
||||
```
|
||||
|
||||
[EX13] Cursor inside mermaid fence: no backticks, mermaid lines only
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=true
|
||||
CURSOR_FENCE_LANGUAGE=mermaid
|
||||
<PREFIX>```mermaid
|
||||
flowchart TD
|
||||
A[Start] --> </PREFIX>
|
||||
<SUFFIX>
|
||||
```</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
B{Valid?}
|
||||
B -->|Yes| C[Done]
|
||||
|
||||
[EX14] Mermaid context outside fence: return full mermaid block
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
||||
MERMAID_CONTEXT=true
|
||||
<PREFIX>Please provide a simple release pipeline diagram.</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Build --> Test --> Deploy
|
||||
```"""
|
||||
|
||||
|
||||
def build_completion_prompts(
|
||||
@@ -261,7 +520,7 @@ def build_completion_prompts(
|
||||
language_id: str = "markdown",
|
||||
location: str = "",
|
||||
thinking_level: str = "low",
|
||||
preferences: UserPreferences | None = None,
|
||||
preferences: object = None,
|
||||
) -> Tuple[str, str]:
|
||||
safe_language_id = _canonical_language_id(language_id)
|
||||
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
|
||||
@@ -292,49 +551,35 @@ def build_completion_prompts(
|
||||
preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}"
|
||||
|
||||
user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction}
|
||||
Reasoning level: {thinking_level}
|
||||
Editor language: {safe_language_id}
|
||||
Reasoning hint: {thinking_level}
|
||||
Editor language id: {safe_language_id}
|
||||
|
||||
=== STATE FLAGS ===
|
||||
Completion state flags:
|
||||
- CURSOR_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"}
|
||||
- CURSOR_FENCE_LANGUAGE: {cursor_fence_language}
|
||||
- MERMAID_CONTEXT: {"true" if mermaid_context else "false"}
|
||||
- PREFIX_ENDS_WITH_NEWLINE: {"true" if prefix_ends_with_newline else "false"}
|
||||
- SUFFIX_STARTS_WITH_NEWLINE: {"true" if suffix_starts_with_newline else "false"}
|
||||
|
||||
=== TASK ===
|
||||
Produce the best insertion text between PREFIX and SUFFIX.
|
||||
Requirements:
|
||||
- Non-empty and meaningful
|
||||
- Concise unless structure needs more
|
||||
- Follows markdown rules in system prompt
|
||||
Task:
|
||||
- Produce the best insertion text at the cursor between PREFIX and SUFFIX.
|
||||
- Keep insertion meaningful and non-empty.
|
||||
- Keep insertion concise unless structure requires more content.
|
||||
|
||||
=== BOUNDARY DECISION GUIDE ===
|
||||
Context notes:
|
||||
- PREFIX may include OCR metadata after image markdown, e.g.  <OCR:description>.
|
||||
- OCR metadata is hidden context and must never be copied into output.
|
||||
- Preserve local style and formatting.
|
||||
|
||||
Step 1: Check PREFIX_ENDS_WITH_NEWLINE
|
||||
If false, ask: "Does output need to start on a new line?"
|
||||
- YES if PREFIX ends with: ":", "steps:", "items:", heading text, or complete sentence before heading
|
||||
- If YES: start output with \\n
|
||||
Decision policy:
|
||||
- Prioritize seamless join: PREFIX + OUTPUT + SUFFIX must read naturally.
|
||||
- Do not repeat SUFFIX-leading text.
|
||||
- If uncertain, prefer a complete short phrase/sentence with clear meaning.
|
||||
|
||||
Step 2: Check SUFFIX_STARTS_WITH_NEWLINE
|
||||
If false, ask: "Does output need to end with a newline?"
|
||||
- YES if SUFFIX starts with: heading (##), new paragraph, or list marker
|
||||
- If YES: end output with \\n
|
||||
Comprehensive examples:
|
||||
{INLINE_EXAMPLES}
|
||||
|
||||
Step 3: Choose newline type
|
||||
- Use \\n\\n for: new paragraphs, before headings, starting lists
|
||||
- Use \\n for: continuing within blocks, list items, table cells
|
||||
- Exception: inside code fences, use \\n freely
|
||||
|
||||
=== CONTEXT NOTES ===
|
||||
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output
|
||||
- Match PREFIX tone, style, and indentation
|
||||
- Do not repeat text from SUFFIX beginning
|
||||
|
||||
=== EXAMPLES BY CATEGORY ===
|
||||
{_INLINE_EXAMPLES}
|
||||
|
||||
=== NOW COMPLETE THE TASK ===
|
||||
Now produce the insertion.
|
||||
|
||||
<PREFIX>
|
||||
{recent_prefix}
|
||||
@@ -356,7 +601,7 @@ def build_prompt(
|
||||
language_id: str = "markdown",
|
||||
location: str = "",
|
||||
thinking_level: str = "low",
|
||||
preferences: UserPreferences | None = None,
|
||||
preferences: object = None,
|
||||
) -> str:
|
||||
"""
|
||||
Backward-compatible helper. Returns only the user prompt body.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_PROMPTS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
class PromptManager:
|
||||
_instance = None
|
||||
_data: dict[str, Any] = {}
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._load_all()
|
||||
return cls._instance
|
||||
|
||||
def _load_all(self):
|
||||
for json_file in _PROMPTS_DIR.glob("*.json"):
|
||||
key = json_file.stem
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
self._data[key] = json.load(f)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._data.get(key, default)
|
||||
|
||||
|
||||
_prompts = PromptManager()
|
||||
|
||||
|
||||
def get_system_prompt_template() -> str:
|
||||
return _prompts.get("system_prompt", {}).get("template", "")
|
||||
|
||||
|
||||
def get_language_guidance_map() -> dict[str, str]:
|
||||
return _prompts.get("language_guidance", {})
|
||||
|
||||
|
||||
def get_inline_examples() -> str:
|
||||
return _prompts.get("inline_examples", {}).get("content", "")
|
||||
|
||||
|
||||
def get_vlm_ocr_prompt() -> str:
|
||||
return _prompts.get("vlm_ocr", {}).get("prompt", "")
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"content": "=== CATEGORY A: PROSE CONTINUATION ===\n\n[EX01] Simple prose continuation\n<PREFIX>The quick brown fox </PREFIX>\n<SUFFIX>jumps over the lazy dog.</SUFFIX>\nExpected OUTPUT:\nmoved quietly and then\n\n[EX02] Avoid repeating suffix\n<PREFIX>Our launch plan starts with </PREFIX>\n<SUFFIX>phase one, followed by phase two.</SUFFIX>\nExpected OUTPUT:\ncareful internal testing before\nWRONG: phase one starts with (repeats suffix)\n\n=== CATEGORY B: MARKDOWN STRUCTURES ===\n\n[EX03] Continue checklist\n<PREFIX>## TODO\n- [ ] Buy milk\n- [ ] </PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\nWrite release notes and share draft with team\n\n[EX04] Start list after header (PREFIX lacks newline)\nPREFIX_ENDS_WITH_NEWLINE=false\n<PREFIX>Deployment steps:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\n- Build artifact\n- Deploy service\n\n[EX05] Continue table row\n<PREFIX>| Name | Score |\n| --- | --- |\n| Alice | 92 |\n| Bob | </PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n88 |\n\n[EX06] Start new paragraph\n<PREFIX>First paragraph ends.</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n\nSecond paragraph starts.\nWRONG: Second paragraph starts. (missing leading \\n\\n)\n\n[EX07] Add newline before heading\nPREFIX_ENDS_WITH_NEWLINE=false\n<PREFIX>End of previous section.</PREFIX>\n<SUFFIX>## Next Heading</SUFFIX>\nExpected OUTPUT:\n\nWRONG: (would join with heading without separation)\n\n=== CATEGORY C: CODE BLOCKS ===\n\n[EX08] Outside fence: wrap code in fence\nCURSOR_IN_FENCED_CODE_BLOCK=false\n<PREFIX>Parse this JSON payload in Python:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n```python\nimport json\ndata = json.loads(payload)\n```\nWRONG: import json\\ndata = json.loads(payload) (no fence)\n\n[EX09] Inside fence: output code only\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```python\ndef add(a, b):\nreturn </PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\na + b\nWRONG: ```python\\nreturn a + b\\n``` (duplicate fences)\n\n[EX10] Code inside fence uses single newline\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```python\ndef hello():</PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\nprint(\"Hello\")\nreturn True\n(Note: single \\n between code lines, no markdown rules)\n\n=== CATEGORY D: MATH ===\n\n[EX11] Inline math\n<PREFIX>The derivative of x^2 is </PREFIX>\n<SUFFIX>.</SUFFIX>\nExpected OUTPUT:\n$2x$\nWRONG: 2x (bare formula)\n\n[EX12] Block math\n<PREFIX>We can write the Gaussian integral as:</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}\n$$\nWRONG: \\int... (bare formula without $$)\n\n=== CATEGORY E: MERMAID ===\n\n[EX13] Inside mermaid fence\nCURSOR_FENCE_LANGUAGE=mermaid\nCURSOR_IN_FENCED_CODE_BLOCK=true\n<PREFIX>```mermaid\nflowchart TD\nA[Start] --> </PREFIX>\n<SUFFIX>\n```</SUFFIX>\nExpected OUTPUT:\nB{Valid?}\nB -->|Yes| C[Done]\nWRONG: ```mermaid\\nB{Valid?}... (duplicate fence)\n\n[EX14] Outside fence with mermaid context\nCURSOR_IN_FENCED_CODE_BLOCK=false\nMERMAID_CONTEXT=true\n<PREFIX>Please provide a simple release pipeline diagram.</PREFIX>\n<SUFFIX></SUFFIX>\nExpected OUTPUT:\n```mermaid\nflowchart LR\nBuild --> Test --> Deploy\n```\n\n=== CATEGORY F: OCR METADATA ===\n\n[EX15] Use OCR as context, never output\n<PREFIX> <OCR:equation y = mx + b>\nThe relationship is </PREFIX>\n<SUFFIX>.</SUFFIX>\nExpected OUTPUT:\n$y = mx + b$\nWRONG: <OCR:equation y = mx + b> (OCR tag in output)"
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"mermaid": "\nLanguage-specific guidance (mermaid):\n- Output valid Mermaid syntax only.\n- Prefer concise, syntactically correct diagram statements.\n- Avoid prose unless the user prompt explicitly requires it.",
|
||||
"latex": "\nLanguage-specific guidance (latex):\n- Output LaTeX math content only when completing LaTeX.\n- If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex:\n- Output raw LaTeX lines only.\n- Do not wrap with $ or $$.",
|
||||
"json": "\nLanguage-specific guidance (json):\n- Output strict JSON only (no comments, no trailing commas).\n- Ensure valid quotes and braces.",
|
||||
"yaml": "\nLanguage-specific guidance (yaml):\n- Output valid YAML only.\n- Use consistent indentation and avoid tabs.",
|
||||
"toml": "\nLanguage-specific guidance (toml):\n- Output valid TOML only.\n- Keep key types consistent.",
|
||||
"ini": "\nLanguage-specific guidance (ini):\n- Output valid INI only.\n- Keep section headers and key=value pairs consistent.",
|
||||
"sql": "\nLanguage-specific guidance (sql):\n- Output a single, valid SQL statement unless context requires multiple.\n- Prefer ANSI SQL when dialect is unclear.",
|
||||
"bash": "\nLanguage-specific guidance (bash):\n- Output POSIX-compatible shell when possible.\n- Avoid interactive prompts or destructive commands unless requested.",
|
||||
"powershell": "\nLanguage-specific guidance (powershell):\n- Output valid PowerShell commands.\n- Avoid destructive commands unless explicitly requested.",
|
||||
"html": "\nLanguage-specific guidance (html):\n- Output valid HTML only.\n- Keep markup minimal and well-formed.",
|
||||
"css": "\nLanguage-specific guidance (css):\n- Output valid CSS only.\n- Use concise, readable selectors.",
|
||||
"diff": "\nLanguage-specific guidance (diff):\n- Output a unified diff only.\n- Ensure @@ hunk headers and +/- lines are consistent.",
|
||||
"regex": "\nLanguage-specific guidance (regex):\n- Output the regex pattern only.\n- Avoid delimiters unless explicitly requested.",
|
||||
"text": "\nLanguage-specific guidance (text):\n- Output plain text only.\n- Avoid markdown formatting unless explicitly asked.",
|
||||
"xml": "\nLanguage-specific guidance (xml):\n- Output well-formed XML only.\n- Ensure matching tags and proper escaping.",
|
||||
"dockerfile": "\nLanguage-specific guidance (dockerfile):\n- Output valid Dockerfile instructions only.\n- Keep layers minimal and ordered logically.",
|
||||
"makefile": "\nLanguage-specific guidance (makefile):\n- Output valid Makefile syntax only.\n- Use tabs for recipe lines.",
|
||||
"_generic_code": "\nLanguage-specific guidance ({lang}):\n- Output valid {lang} code.\n- Avoid prose unless context clearly expects comments or docstrings.",
|
||||
"_js_code": "\nLanguage-specific guidance ({lang}):\n- Output valid {lang} code.\n- Prefer modern syntax and avoid prose unless comments are needed."
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"template": "You are an inline completion engine for a {language_id} editor with ghost-text suggestions.\n\nReturn only the insertion text that should be placed between PREFIX and SUFFIX.\n\nCORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.\n\nPRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)\n- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?\n- CURSOR_FENCE_LANGUAGE: What language is the current fence?\n- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?\n- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?\n- MERMAID_CONTEXT: Is this a Mermaid diagram context?\n\nPRIORITY 2: SPECIALIZED CONTENT RULES\n\n2.1 Code Block Handling:\nIf CURSOR_IN_FENCED_CODE_BLOCK=true:\n- You are inside a code fence\n- Output code lines ONLY (no triple backticks)\n- Use single \\n for code line separation\n\nIf CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:\n- Wrap code in fenced block with language tag:\n```{language}\ncode here\n```\n- Never use inline backticks for code snippets\n\n2.2 Math Formatting (KaTeX):\n- Inline math: wrap with $...$\n- Block math: wrap with $$...$$\n- Never output bare formulas\n- Exception: inside latex/tex/katex fence, output raw LaTeX\n\n2.3 Mermaid Diagrams:\nIf CURSOR_FENCE_LANGUAGE=mermaid:\n- Output Mermaid syntax ONLY\n- No backticks, no explanations\n\nIf MERMAID_CONTEXT=true and outside fence:\n- Output complete fenced block:\n```mermaid\ndiagram syntax\n```\n\nPRIORITY 3: MARKDOWN STRUCTURE\n\n3.1 Newline Semantics:\n- Single \\n: soft break (same paragraph, renders as space or <br>)\n- Double \\n\\n: hard break (new paragraph/block)\n- Use \\n\\n for: new paragraphs, before headings, starting lists/tables\n- Use \\n for: continuation within blocks (list items, table cells)\n- Exception: inside code blocks, use \\n freely for code lines\n\n3.2 Boundary Management:\nCheck PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:\n- If PREFIX lacks needed newline: start OUTPUT with \\n\n- If SUFFIX lacks needed newline: end OUTPUT with \\n\n- Common cases requiring leading \\n:\n* Starting a list after \"Steps:\"\n* Creating new paragraph after text\n* Adding heading after paragraph\n- Common cases requiring trailing \\n:\n* Before new heading\n* End of section\n\n3.3 Context Stitching:\n- Never repeat text from SUFFIX beginning\n- Match PREFIX tone, style, indentation\n- Continue structures: lists, tables, quotes, headings\n\nPRIORITY 4: HIDDEN CONTEXT\n- OCR metadata like <OCR:...> is hidden context\n- Never copy OCR tags to output\n- Use OCR content as semantic hint only"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"prompt": "You are an OCR and visual-context extractor for markdown writing assistance.\n\nYour output will be embedded inside an HTML comment as hidden context for a text-completion model.\n\nRequirements:\n- Keep output compact: maximum 120 words.\n- Use plain text only (no markdown code fences).\n- Never output <!-- or -->.\n- Do not invent unreadable text; mark uncertain characters with ?.\n- Preserve original script for recognized text (do not forcibly translate).\n\nReturn exactly this format:\n\nTEXT:\n<exact transcription of visible text; use \" | \" for line breaks; write \"(none)\" if no readable text>\n\nKEY_DETAILS:\n- <3-5 short factual bullets about relevant objects/layout>\n\nLANGUAGE:\n<dominant language(s) in visible text, e.g. English / Chinese / Mixed>\n\nSUMMARY:\n<one short sentence, <= 20 words>"
|
||||
}
|
||||
+12
-13
@@ -1,13 +1,12 @@
|
||||
fastapi>=0.95.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
pydantic>=1.10.0
|
||||
numpy>=1.23.0
|
||||
soundfile>=0.10.3
|
||||
torch>=1.12.0
|
||||
torchaudio>=0.12.0
|
||||
transformers>=4.25.0
|
||||
whisper>=1.0.0
|
||||
qwen-tts>=0.0.0
|
||||
|
||||
# testing
|
||||
pytest>=7.0.0
|
||||
fastapi
|
||||
uvicorn
|
||||
ollama
|
||||
pydantic
|
||||
python-dotenv
|
||||
httpx
|
||||
geoip2
|
||||
markitdown[all]
|
||||
python-docx
|
||||
python-pptx
|
||||
openpyxl
|
||||
pypdf
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import argparse
|
||||
import uuid
|
||||
import sys
|
||||
import statistics
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)]
|
||||
)
|
||||
logger = logging.getLogger("api_benchmarker")
|
||||
|
||||
# Constants
|
||||
DEFAULT_BASE_URL = "http://localhost:8001"
|
||||
DEFAULT_API_KEY = "your-secret-key-here"
|
||||
CHARS_PER_TOKEN = 4
|
||||
|
||||
# Data Generators
|
||||
def get_dummy_base64_image():
|
||||
return "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
||||
|
||||
def get_dummy_base64_audio():
|
||||
# A bit longer dummy audio to pass validation (44 bytes header + some data)
|
||||
return "UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQAAAAAAA" + "A" * 100 + "=="
|
||||
|
||||
def generate_context_text(tokens: int) -> str:
|
||||
"""Generate synthetic text of approximately 'tokens' tokens."""
|
||||
base_phrase = "The quick brown fox jumps over the lazy dog. "
|
||||
repeat_count = (tokens * CHARS_PER_TOKEN) // len(base_phrase) + 1
|
||||
return (base_phrase * repeat_count)[:tokens * CHARS_PER_TOKEN]
|
||||
|
||||
# Metric Models
|
||||
class RequestMetric(BaseModel):
|
||||
task_name: str
|
||||
endpoint: str
|
||||
status_code: int
|
||||
ttfb_ms: float
|
||||
total_ms: float
|
||||
success: bool
|
||||
tokens: int
|
||||
error: Optional[str] = None
|
||||
|
||||
class BenchStats:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.metrics: List[RequestMetric] = []
|
||||
self.start_time = 0.0
|
||||
self.end_time = 0.0
|
||||
|
||||
def add(self, m: RequestMetric):
|
||||
self.metrics.append(m)
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
if not self.metrics:
|
||||
return {}
|
||||
|
||||
total = len(self.metrics)
|
||||
successes = [m for m in self.metrics if m.success]
|
||||
success_count = len(successes)
|
||||
fail_count = total - success_count
|
||||
|
||||
total_latencies = [m.total_ms for m in successes] if successes else [0]
|
||||
ttfb_latencies = [m.ttfb_ms for m in successes] if successes else [0]
|
||||
|
||||
duration = self.end_time - self.start_time
|
||||
total_tokens = sum(m.tokens for m in successes)
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"total_requests": total,
|
||||
"success_rate": (success_count / total) * 100 if total > 0 else 0,
|
||||
"avg_latency": statistics.mean(total_latencies),
|
||||
"p50_latency": statistics.median(total_latencies),
|
||||
"p95_latency": sorted(total_latencies)[int(len(total_latencies)*0.95)] if total_latencies else 0,
|
||||
"avg_ttfb": statistics.mean(ttfb_latencies),
|
||||
"tps": total_tokens / duration if duration > 0 else 0,
|
||||
"rps": total / duration if duration > 0 else 0,
|
||||
"duration": duration
|
||||
}
|
||||
|
||||
# Benchmarking Engine
|
||||
class ApiBenchmarker:
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.headers = {"X-API-Key": api_key}
|
||||
self.semaphores = {
|
||||
"completions": asyncio.Semaphore(5),
|
||||
"ocr": asyncio.Semaphore(2),
|
||||
"convert": asyncio.Semaphore(2),
|
||||
"tts-asr": asyncio.Semaphore(3)
|
||||
}
|
||||
self.results: Dict[str, BenchStats] = {}
|
||||
|
||||
async def _execute_request(self, client: httpx.AsyncClient, name: str, method: str, path: str, **kwargs) -> RequestMetric:
|
||||
url = f"{self.base_url}{path}"
|
||||
start = time.perf_counter()
|
||||
ttfb = 0.0
|
||||
tokens_count = 0
|
||||
|
||||
# Estimate input + output tokens (mock for output)
|
||||
if "json" in kwargs:
|
||||
input_text = str(kwargs["json"].get("prefix", "")) + str(kwargs["json"].get("text", ""))
|
||||
tokens_count += len(input_text) // CHARS_PER_TOKEN
|
||||
|
||||
try:
|
||||
async with client.stream(method, url, **kwargs) as response:
|
||||
ttfb = (time.perf_counter() - start) * 1000
|
||||
|
||||
body = await response.aread()
|
||||
total_ms = (time.perf_counter() - start) * 1000
|
||||
success = 200 <= response.status_code < 300
|
||||
|
||||
error_msg = None
|
||||
if not success:
|
||||
error_msg = body.decode(errors="ignore")[:200]
|
||||
else:
|
||||
# Estimate output tokens from response content
|
||||
try:
|
||||
resp_json = json.loads(body)
|
||||
content = resp_json.get("content", "") or resp_json.get("text", "") or resp_json.get("markdown", "")
|
||||
tokens_count += len(content) // CHARS_PER_TOKEN
|
||||
except:
|
||||
pass
|
||||
|
||||
return RequestMetric(
|
||||
task_name=name,
|
||||
endpoint=path,
|
||||
status_code=response.status_code,
|
||||
ttfb_ms=ttfb,
|
||||
total_ms=total_ms,
|
||||
success=success,
|
||||
tokens=tokens_count,
|
||||
error=error_msg
|
||||
)
|
||||
except Exception as e:
|
||||
total_ms = (time.perf_counter() - start) * 1000
|
||||
return RequestMetric(
|
||||
task_name=name,
|
||||
endpoint=path,
|
||||
status_code=0,
|
||||
ttfb_ms=ttfb or total_ms,
|
||||
total_ms=total_ms,
|
||||
success=False,
|
||||
tokens=tokens_count,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def run_task(self, client: httpx.AsyncClient, task_type: str, name: str, iterations: int):
|
||||
if name not in self.results:
|
||||
self.results[name] = BenchStats(name)
|
||||
|
||||
stats = self.results[name]
|
||||
stats.start_time = time.perf_counter()
|
||||
|
||||
sem = self.semaphores.get(task_type, self.semaphores["completions"])
|
||||
|
||||
async def worker():
|
||||
async with sem:
|
||||
if task_type == "completions":
|
||||
# Stability Test Variation
|
||||
prefix_len = 100
|
||||
if "Normal" in name: prefix_len = 1000
|
||||
if "Long" in name: prefix_len = 4000
|
||||
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/completions", json={
|
||||
"prefix": generate_context_text(prefix_len),
|
||||
"suffix": "End of document.",
|
||||
"model_thinking": "low"
|
||||
})
|
||||
elif task_type == "ocr":
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/ocr", json={
|
||||
"image": get_dummy_base64_image(),
|
||||
"filename": "bench.png"
|
||||
})
|
||||
elif task_type == "convert":
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/convert", json={
|
||||
"file": base64.b64encode(b"Performance test data").decode(),
|
||||
"filename": "bench.txt"
|
||||
})
|
||||
elif task_type == "tts":
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/tts-asr/tts", json={
|
||||
"text": "This is a performance benchmark for the text to speech engine.",
|
||||
"voice": "v2/en_speaker_6",
|
||||
"format": "wav"
|
||||
})
|
||||
elif task_type == "asr":
|
||||
metric = await self._execute_request(client, name, "POST", "/v1/tts-asr/asr", json={
|
||||
"audio_base64": get_dummy_base64_audio(),
|
||||
"language": "en"
|
||||
})
|
||||
else:
|
||||
metric = await self._execute_request(client, name, "GET", "/v1/tts-asr/status")
|
||||
|
||||
stats.add(metric)
|
||||
|
||||
tasks = [worker() for _ in range(iterations)]
|
||||
await asyncio.gather(*tasks)
|
||||
stats.end_time = time.perf_counter()
|
||||
|
||||
def generate_report(self, output_file: str):
|
||||
report = []
|
||||
report.append(f"# API Benchmarking Report ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})")
|
||||
report.append(f"\n**Base URL:** `{self.base_url}`")
|
||||
|
||||
# Summary Table
|
||||
report.append("\n## Executive Summary")
|
||||
report.append("| Task | Success Rate | Avg TTFB | Avg Latency | P95 Latency | TPS | RPS |")
|
||||
report.append("| :--- | :--- | :--- | :--- | :--- | :--- | :--- |")
|
||||
|
||||
for name, stats in self.results.items():
|
||||
s = stats.get_summary()
|
||||
if not s: continue
|
||||
report.append(f"| {s['name']} | {s['success_rate']:.1f}% | {s['avg_ttfb']:.1f}ms | {s['avg_latency']:.1f}ms | {s['p95_latency']:.1f}ms | {s['tps']:.1f} | {s['rps']:.2f} |")
|
||||
|
||||
# Stability Analysis
|
||||
report.append("\n## Stability & Context Analysis")
|
||||
report.append("Detailed analysis of how context length affects TTFB and overall performance.")
|
||||
|
||||
# Details per category
|
||||
for name, stats in self.results.items():
|
||||
s = stats.get_summary()
|
||||
if not s: continue
|
||||
report.append(f"\n### {name} Details")
|
||||
report.append(f"- **Total Samples:** {s['total_requests']}")
|
||||
report.append(f"- **Duration:** {s['duration']:.2f}s")
|
||||
failures = [m for m in stats.metrics if not m.success]
|
||||
if failures:
|
||||
report.append(f"- **Top Errors:**")
|
||||
for f in failures[:3]:
|
||||
report.append(f" - `[{f.status_code}]` {f.error}")
|
||||
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(report))
|
||||
logger.info(f"Report generated: {output_file}")
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="Advanced LLM API Benchmarker")
|
||||
parser.add_argument("--url", default=DEFAULT_BASE_URL, help="Base URL")
|
||||
parser.add_argument("--key", default=DEFAULT_API_KEY, help="API Key")
|
||||
parser.add_argument("--c-comp", type=int, default=5, help="Completion Concurrency")
|
||||
parser.add_argument("--c-ocr", type=int, default=2, help="OCR Concurrency")
|
||||
parser.add_argument("--c-audio", type=int, default=2, help="TTS/ASR Concurrency")
|
||||
parser.add_argument("--iters", type=int, default=10, help="Iterations per test suite")
|
||||
parser.add_argument("--output", default="api_performance_report.md", help="Output report file")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
bench = ApiBenchmarker(args.url, args.key)
|
||||
bench.semaphores["completions"] = asyncio.Semaphore(args.c_comp)
|
||||
bench.semaphores["ocr"] = asyncio.Semaphore(args.c_ocr)
|
||||
bench.semaphores["tts-asr"] = asyncio.Semaphore(args.c_audio)
|
||||
|
||||
async with httpx.AsyncClient(headers=bench.headers, timeout=120.0) as client:
|
||||
logger.info("Starting Benchmark Suites...")
|
||||
|
||||
# Suite 1: Stability - Completion Contexts
|
||||
logger.info("Running Stability Suite (Short Context)...")
|
||||
await bench.run_task(client, "completions", "Completion-Short", args.iters)
|
||||
|
||||
logger.info("Running Stability Suite (Normal Context)...")
|
||||
await bench.run_task(client, "completions", "Completion-Normal", args.iters)
|
||||
|
||||
logger.info("Running Stability Suite (Long Context)...")
|
||||
await bench.run_task(client, "completions", "Completion-Long", args.iters)
|
||||
|
||||
# Suite 2: Functional Concurrency
|
||||
logger.info("Running OCR Concurrency Suite...")
|
||||
await bench.run_task(client, "ocr", "OCR-Concurrent", args.iters)
|
||||
|
||||
logger.info("Running TTS Concurrency Suite...")
|
||||
await bench.run_task(client, "tts", "TTS-Concurrent", args.iters)
|
||||
|
||||
logger.info("Running ASR Concurrency Suite...")
|
||||
await bench.run_task(client, "asr", "ASR-Concurrent", args.iters)
|
||||
|
||||
logger.info("Running File Transformation Suite...")
|
||||
await bench.run_task(client, "convert", "Convert-Concurrent", args.iters)
|
||||
|
||||
bench.generate_report(args.output)
|
||||
print(f"\nBenchmark Complete! View the report at: {args.output}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,453 +0,0 @@
|
||||
# TTS/ASR 测试指南
|
||||
|
||||
本文档提供完整的测试脚本使用说明,包括单元测试、集成测试和macOS环境模拟测试。
|
||||
|
||||
## 测试脚本概览
|
||||
|
||||
| 脚本 | 位置 | 用途 | 需要后端服务 |
|
||||
|------|------|------|--------------|
|
||||
| `test_tts_asr_unit.py` | `backend/tests/` | 单元测试(设备检测、模型选择、音频处理) | 否 |
|
||||
| `test_tts_asr_integration.py` | `backend/tests/` | 集成测试(API端点、完整流程) | 是 |
|
||||
| `simulate_macos.py` | `backend/tests/` | macOS环境模拟(在非Mac环境测试) | 否 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 单元测试(推荐首先运行)
|
||||
|
||||
单元测试不需要实际运行模型或后端服务,测试代码逻辑:
|
||||
|
||||
```bash
|
||||
# 使用pytest运行(推荐)
|
||||
pytest backend/tests/test_tts_asr_unit.py -v
|
||||
|
||||
# 直接运行
|
||||
python backend/tests/test_tts_asr_unit.py
|
||||
|
||||
# 运行特定测试类
|
||||
pytest backend/tests/test_tts_asr_unit.py::TestAppleSiliconDetection -v
|
||||
|
||||
# 运行特定测试方法
|
||||
pytest backend/tests/test_tts_asr_unit.py::TestAppleSiliconDetection::test_is_apple_silicon_on_darwin_arm64 -v
|
||||
```
|
||||
|
||||
### 2. macOS环境模拟测试
|
||||
|
||||
在非macOS环境下模拟Apple Silicon环境:
|
||||
|
||||
```bash
|
||||
# 运行完整模拟测试套件
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
|
||||
# 仅模拟Apple Silicon环境并进入交互模式
|
||||
python backend/tests/simulate_macos.py --apple-silicon
|
||||
|
||||
# 模拟特定设备
|
||||
python backend/tests/simulate_macos.py --device mps
|
||||
python backend/tests/simulate_macos.py --device cuda
|
||||
|
||||
# 运行特定测试
|
||||
python backend/tests/simulate_macos.py --test device # 设备检测
|
||||
python backend/tests/simulate_macos.py --test memory # 内存管理
|
||||
python backend/tests/simulate_macos.py --test model # 模型选择
|
||||
python backend/tests/simulate_macos.py --test audio # 音频处理
|
||||
python backend/tests/simulate_macos.py --test env # 环境变量
|
||||
```
|
||||
|
||||
### 3. 集成测试
|
||||
|
||||
集成测试需要运行后端服务:
|
||||
|
||||
```bash
|
||||
# 1. 启动后端服务(终端1)
|
||||
python backend/main.py
|
||||
|
||||
# 2. 运行集成测试(终端2)
|
||||
# 运行所有测试
|
||||
python backend/tests/test_tts_asr_integration.py
|
||||
|
||||
# 运行特定测试
|
||||
python backend/tests/test_tts_asr_integration.py --test config # 配置端点
|
||||
python backend/tests/test_tts_asr_integration.py --test status # 状态端点
|
||||
python backend/tests/test_tts_asr_integration.py --test warmup # 预热测试
|
||||
python backend/tests/test_tts_asr_integration.py --test tts # TTS测试
|
||||
python backend/tests/test_tts_asr_integration.py --test asr # ASR测试
|
||||
python backend/tests/test_tts_asr_integration.py --test perf # 性能测试
|
||||
|
||||
# 自定义API地址
|
||||
python backend/tests/test_tts_asr_integration.py --url http://localhost:8001 --key your-api-key
|
||||
```
|
||||
|
||||
## 详细测试说明
|
||||
|
||||
### 单元测试详解
|
||||
|
||||
#### TestAppleSiliconDetection
|
||||
|
||||
测试Apple Silicon检测功能:
|
||||
|
||||
- `test_is_apple_silicon_on_darwin_arm64`: 在Darwin/arm64环境检测
|
||||
- `test_is_apple_silicon_on_windows`: 在Windows环境不应检测到
|
||||
- `test_is_apple_silicon_on_linux`: 在Linux环境不应检测到
|
||||
|
||||
#### TestEnvironmentVariables
|
||||
|
||||
测试环境变量解析:
|
||||
|
||||
- `test_default_environment_values`: 验证默认值
|
||||
- `test_custom_environment_values`: 验证自定义值
|
||||
|
||||
#### TestModelSizeSelection
|
||||
|
||||
测试模型大小选择:
|
||||
|
||||
- `test_whisper_model_sizes_mapping`: 模型大小映射验证
|
||||
- `test_recommended_model_size_explicit`: 显式指定大小
|
||||
- `test_invalid_model_size_falls_back`: 无效大小回退
|
||||
|
||||
#### TestAudioValidation
|
||||
|
||||
测试音频验证:
|
||||
|
||||
- `test_validate_empty_audio`: 空音频验证
|
||||
- `test_validate_valid_wav_header`: 有效WAV头验证
|
||||
- `test_validate_invalid_audio`: 无效音频验证
|
||||
|
||||
#### TestAudioResampling
|
||||
|
||||
测试音频重采样:
|
||||
|
||||
- `test_resample_same_rate`: 相同采样率
|
||||
- `test_resample_different_rate`: 不同采样率重采样
|
||||
- `test_resample_downsample`: 下采样
|
||||
|
||||
#### TestDeviceCapabilities
|
||||
|
||||
测试设备能力检测:
|
||||
|
||||
- `test_device_capabilities_dataclass`: 数据类验证
|
||||
- `test_device_capabilities_with_mps`: MPS设备能力
|
||||
|
||||
#### TestModelCacheCheck
|
||||
|
||||
测试模型缓存检查:
|
||||
|
||||
- `test_cache_check_non_offline_mode`: 非离线模式
|
||||
- `test_cache_check_offline_mode_missing`: 离线模式缺失模型
|
||||
|
||||
#### TestRequestResponseModels
|
||||
|
||||
测试API模型:
|
||||
|
||||
- `test_tts_request_model`: TTS请求模型
|
||||
- `test_asr_request_model`: ASR请求模型
|
||||
- `test_model_status_model`: 状态模型
|
||||
|
||||
### 集成测试详解
|
||||
|
||||
#### TTSASRIntegrationTest
|
||||
|
||||
主要集成测试:
|
||||
|
||||
- `test_01_config_endpoint`: 配置端点测试
|
||||
- `test_02_status_endpoint`: 状态端点测试
|
||||
- `test_03_warmup_endpoint`: 预热端点测试
|
||||
- `test_04_tts_endpoint_basic`: TTS基本功能测试
|
||||
- `test_05_asr_endpoint_basic`: ASR基本功能测试
|
||||
- `test_06_api_key_validation`: API密钥验证测试
|
||||
- `test_07_tts_long_text`: TTS长文本测试
|
||||
|
||||
#### PerformanceTest
|
||||
|
||||
性能测试:
|
||||
|
||||
- `test_tts_latency`: TTS延迟测试
|
||||
|
||||
### macOS模拟测试详解
|
||||
|
||||
#### MacOSSimulator类
|
||||
|
||||
提供以下模拟功能:
|
||||
|
||||
- `simulate_apple_silicon()`: 模拟Darwin/arm64环境
|
||||
- `simulate_mps_device()`: 模拟MPS设备可用
|
||||
- `simulate_cuda_device()`: 模拟CUDA设备可用
|
||||
- `cleanup()`: 清理模拟环境
|
||||
|
||||
#### 独立测试函数
|
||||
|
||||
- `test_device_detection_on_apple_silicon()`: Apple Silicon设备检测
|
||||
- `test_memory_management()`: 内存管理测试
|
||||
- `test_model_size_selection()`: 模型大小选择测试
|
||||
- `test_audio_processing()`: 音频处理测试
|
||||
- `test_environment_variables()`: 环境变量测试
|
||||
|
||||
## 测试覆盖率
|
||||
|
||||
### 单元测试覆盖的功能
|
||||
|
||||
- [x] Apple Silicon检测逻辑
|
||||
- [x] 环境变量解析和默认值
|
||||
- [x] 模型大小选择和推荐
|
||||
- [x] 音频数据验证
|
||||
- [x] 音频重采样(多回退方案)
|
||||
- [x] 设备能力检测数据结构
|
||||
- [x] 模型缓存检查
|
||||
- [x] API请求/响应模型
|
||||
|
||||
### 集成测试覆盖的功能
|
||||
|
||||
- [x] 配置端点(`/v1/tts-asr/config`)
|
||||
- [x] 状态端点(`/v1/tts-asr/status`)
|
||||
- [x] 预热端点(`/v1/tts-asr/warmup`)
|
||||
- [x] TTS端点(`/v1/tts-asr/tts`)
|
||||
- [x] ASR端点(`/v1/tts-asr/asr`)
|
||||
- [x] API密钥验证
|
||||
- [x] 长文本处理
|
||||
- [x] 性能基准测试
|
||||
|
||||
### macOS模拟测试覆盖的场景
|
||||
|
||||
- [x] Apple Silicon环境模拟
|
||||
- [x] MPS设备模拟
|
||||
- [x] CUDA设备模拟
|
||||
- [x] 系统内存模拟
|
||||
- [x] 完整环境变量测试
|
||||
|
||||
## 常见测试场景
|
||||
|
||||
### 场景1: 开发时快速验证
|
||||
|
||||
```bash
|
||||
# 快速单元测试
|
||||
pytest backend/tests/test_tts_asr_unit.py -v --tb=short
|
||||
|
||||
# macOS模拟(完整)
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
```
|
||||
|
||||
### 场景2: 验证特定配置
|
||||
|
||||
```bash
|
||||
# 设置环境变量后测试
|
||||
export TTS_ASR_MODEL_SIZE=small
|
||||
export TTS_ASR_QUANTIZE=true
|
||||
|
||||
# 运行测试
|
||||
python backend/tests/simulate_macos.py --test model
|
||||
```
|
||||
|
||||
### 场景3: API功能验证
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
python backend/main.py
|
||||
|
||||
# 测试配置端点
|
||||
python backend/tests/test_tts_asr_integration.py --test config
|
||||
|
||||
# 测试TTS功能
|
||||
python backend/tests/test_tts_asr_integration.py --test tts
|
||||
|
||||
# 测试ASR功能
|
||||
python backend/tests/test_tts_asr_integration.py --test asr
|
||||
```
|
||||
|
||||
### 场景4: 性能基准测试
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
python backend/main.py
|
||||
|
||||
# 运行性能测试
|
||||
python backend/tests/test_tts_asr_integration.py --test perf
|
||||
```
|
||||
|
||||
## 测试输出解读
|
||||
|
||||
### 成功示例
|
||||
|
||||
```
|
||||
test_is_apple_silicon_on_darwin_arm64 ... ok
|
||||
test_is_apple_silicon_on_windows ... ok
|
||||
test_is_apple_silicon_on_linux ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 3 tests in 0.005s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
### 失败示例
|
||||
|
||||
```
|
||||
test_device_detection_on_apple_silicon ... FAIL
|
||||
|
||||
======================================================================
|
||||
FAIL: test_device_detection_on_apple_silicon
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "test_tts_asr_unit.py", line 45, in test_is_apple_silicon_on_darwin_arm64
|
||||
self.assertTrue(_is_apple_silicon())
|
||||
AssertionError: False is not true
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 1 tests in 0.002s
|
||||
|
||||
FAILED (failures=1)
|
||||
```
|
||||
|
||||
## 持续集成配置
|
||||
|
||||
### GitHub Actions示例
|
||||
|
||||
```yaml
|
||||
name: TTS/ASR Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r backend/requirements.txt
|
||||
pip install pytest
|
||||
- name: Run unit tests
|
||||
run: pytest backend/tests/test_tts_asr_unit.py -v
|
||||
- name: Run macOS simulation
|
||||
run: python backend/tests/simulate_macos.py --full-simulation
|
||||
```
|
||||
|
||||
### pytest配置
|
||||
|
||||
创建 `pytest.ini`:
|
||||
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = backend/tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -v --tb=short
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题1: 导入错误
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'backend'
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 确保在项目根目录运行
|
||||
cd /path/to/llm-in-text
|
||||
|
||||
# 或设置PYTHONPATH
|
||||
export PYTHONPATH="${PYTHONPATH}:$(pwd)"
|
||||
```
|
||||
|
||||
### 问题2: 后端服务连接失败
|
||||
|
||||
```
|
||||
✗ 无法连接到服务: [Errno 111] Connection refused
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 确保后端服务正在运行
|
||||
python backend/main.py
|
||||
|
||||
# 检查端口
|
||||
lsof -i :8001
|
||||
|
||||
# 或使用自定义URL
|
||||
python backend/tests/test_tts_asr_integration.py --url http://localhost:8001
|
||||
```
|
||||
|
||||
### 问题3: 模型未加载
|
||||
|
||||
```
|
||||
⚠ TTS失败(可能是模型未加载)
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
这是预期行为,表示模型需要时间下载。可以:
|
||||
|
||||
1. 等待模型下载完成
|
||||
2. 使用预热端点: `POST /v1/tts-asr/warmup`
|
||||
3. 启用离线模式(如果模型已下载)
|
||||
|
||||
### 问题4: 测试超时
|
||||
|
||||
```
|
||||
httpx.ReadTimeout: timed out
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 增加超时时间
|
||||
export TEST_TIMEOUT=300.0
|
||||
|
||||
# 或在测试脚本中修改
|
||||
TEST_TIMEOUT = 300.0 # 5分钟
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **开发时**: 频繁运行单元测试
|
||||
```bash
|
||||
pytest backend/tests/test_tts_asr_unit.py -v --tb=short
|
||||
```
|
||||
|
||||
2. **提交前**: 运行完整测试套件
|
||||
```bash
|
||||
pytest backend/tests/test_tts_asr_unit.py -v
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
```
|
||||
|
||||
3. **部署前**: 运行集成测试
|
||||
```bash
|
||||
python backend/tests/test_tts_asr_integration.py
|
||||
```
|
||||
|
||||
4. **调试时**: 使用详细输出
|
||||
```bash
|
||||
pytest backend/tests/test_tts_asr_unit.py -v -s --tb=long
|
||||
```
|
||||
|
||||
## 测试报告
|
||||
|
||||
生成测试覆盖率报告:
|
||||
|
||||
```bash
|
||||
# 安装coverage
|
||||
pip install pytest-cov
|
||||
|
||||
# 运行并生成报告
|
||||
pytest backend/tests/test_tts_asr_unit.py --cov=backend.tts_asr --cov-report=html
|
||||
|
||||
# 查看报告
|
||||
open htmlcov/index.html
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [TTS/ASR修复说明](./TTS_ASR_MACOS_FIX.md)
|
||||
- [环境变量配置](../README.md#ttsasr环境变量配置)
|
||||
- [API文档](../README.md#api接口)
|
||||
|
||||
---
|
||||
|
||||
**更新日期**: 2026-04-06
|
||||
**维护者**: 项目开发团队
|
||||
@@ -1,37 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from backend.tts_asr import _tts_sync_with_retry
|
||||
|
||||
async def play_audio():
|
||||
print("生成测试音频中,请稍候...")
|
||||
test_text = "这是一段用以测试新语音模型音质的中文合成音频。"
|
||||
|
||||
try:
|
||||
audio_bytes, sr = await _tts_sync_with_retry(test_text, rate=1.0)
|
||||
|
||||
# 保存到本地文件
|
||||
wav_path = os.path.join(os.path.dirname(__file__), "test_audio.wav")
|
||||
with open(wav_path, "wb") as f:
|
||||
f.write(audio_bytes)
|
||||
|
||||
print(f"音频已生成并保存到: {wav_path}")
|
||||
print("正在尝试在 macOS 上播放...")
|
||||
|
||||
# Mac OS 的播放命令
|
||||
os.system(f"afplay '{wav_path}'")
|
||||
|
||||
print("播放完成。")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"音频生成失败: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(play_audio())
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
快速验证脚本
|
||||
验证TTS/ASR模块修复是否正确应用
|
||||
|
||||
运行方式:
|
||||
python backend/tests/quick_verify.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 设置控制台编码
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
# 确保可以导入backend模块
|
||||
script_path = Path(__file__).resolve()
|
||||
project_root = script_path.parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
print(f"项目根目录: {project_root}")
|
||||
print(f"脚本路径: {script_path}")
|
||||
|
||||
|
||||
def check_file_exists(filepath: str, description: str) -> bool:
|
||||
"""检查文件是否存在"""
|
||||
full_path = project_root / filepath
|
||||
exists = full_path.exists()
|
||||
status = "[OK]" if exists else "[FAIL]"
|
||||
print(f"{status} {description}: {filepath} (完整路径: {full_path})")
|
||||
return exists
|
||||
|
||||
|
||||
def check_function_exists(module_name: str, function_name: str) -> bool:
|
||||
"""检查函数是否存在"""
|
||||
try:
|
||||
module = __import__(module_name, fromlist=[function_name])
|
||||
exists = hasattr(module, function_name)
|
||||
status = "[OK]" if exists else "[FAIL]"
|
||||
print(f"{status} 函数存在: {module_name}.{function_name}")
|
||||
return exists
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 导入失败: {module_name} - {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_environment_variable(var_name: str, expected_default: str) -> bool:
|
||||
"""检查环境变量默认值"""
|
||||
try:
|
||||
# 清除可能存在的环境变量
|
||||
original_value = os.environ.get(var_name)
|
||||
if var_name in os.environ:
|
||||
del os.environ[var_name]
|
||||
|
||||
# 重新导入模块
|
||||
if 'backend.tts_asr' in sys.modules:
|
||||
del sys.modules['backend.tts_asr']
|
||||
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE, TTS_ASR_WARMUP, TTS_ASR_WARMUP_TIMEOUT,
|
||||
TTS_ASR_IDLE_TIMEOUT, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
)
|
||||
|
||||
var_map = {
|
||||
'TTS_ASR_DEVICE': TTS_ASR_DEVICE,
|
||||
'TTS_ASR_MODEL_SIZE': TTS_ASR_MODEL_SIZE,
|
||||
'TTS_ASR_QUANTIZE': TTS_ASR_QUANTIZE,
|
||||
'TTS_ASR_OFFLINE_MODE': TTS_ASR_OFFLINE_MODE,
|
||||
'TTS_ASR_WARMUP': TTS_ASR_WARMUP,
|
||||
'TTS_ASR_WARMUP_TIMEOUT': TTS_ASR_WARMUP_TIMEOUT,
|
||||
'TTS_ASR_IDLE_TIMEOUT': TTS_ASR_IDLE_TIMEOUT,
|
||||
'TTS_ASR_MPS_MEMORY_LIMIT_MB': TTS_ASR_MPS_MEMORY_LIMIT_MB,
|
||||
}
|
||||
|
||||
actual_value = var_map.get(var_name)
|
||||
if var_name == 'TTS_ASR_MODEL_SIZE':
|
||||
expected = 'auto'
|
||||
elif var_name == 'TTS_ASR_QUANTIZE':
|
||||
expected = False
|
||||
elif var_name == 'TTS_ASR_OFFLINE_MODE':
|
||||
expected = False
|
||||
elif var_name == 'TTS_ASR_WARMUP':
|
||||
expected = True
|
||||
elif var_name == 'TTS_ASR_WARMUP_TIMEOUT':
|
||||
expected = 120
|
||||
elif var_name == 'TTS_ASR_IDLE_TIMEOUT':
|
||||
expected = 0
|
||||
elif var_name == 'TTS_ASR_MPS_MEMORY_LIMIT_MB':
|
||||
expected = 8192
|
||||
else:
|
||||
expected = expected_default
|
||||
|
||||
matches = actual_value == expected
|
||||
status = "[OK]" if matches else "[FAIL]"
|
||||
print(f"{status} 环境变量默认值: {var_name} = {actual_value} (预期: {expected})")
|
||||
return matches
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 检查环境变量失败: {var_name} - {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("="*70)
|
||||
print("TTS/ASR模块快速验证")
|
||||
print("="*70)
|
||||
|
||||
checks = []
|
||||
|
||||
# 1. 检查文件
|
||||
print("\n[1] 文件检查")
|
||||
print("-"*70)
|
||||
checks.append(check_file_exists("backend/tts_asr.py", "主模块文件"))
|
||||
checks.append(check_file_exists("backend/tests/test_tts_asr_unit.py", "单元测试"))
|
||||
checks.append(check_file_exists("backend/tests/test_tts_asr_integration.py", "集成测试"))
|
||||
checks.append(check_file_exists("backend/tests/simulate_macos.py", "macOS模拟工具"))
|
||||
checks.append(check_file_exists("backend/tests/TESTING_GUIDE.md", "测试指南"))
|
||||
checks.append(check_file_exists("backend/TTS_ASR_MACOS_FIX.md", "修复文档"))
|
||||
|
||||
# 2. 检查核心函数
|
||||
print("\n[2] 核心函数检查")
|
||||
print("-"*70)
|
||||
checks.append(check_function_exists("backend.tts_asr", "_is_apple_silicon"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_detect_device_capabilities"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_get_recommended_model_size"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_validate_audio_data"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_resample_audio_robust"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_check_model_cached"))
|
||||
|
||||
# 3. 检查数据类
|
||||
print("\n[3] 数据类检查")
|
||||
print("-"*70)
|
||||
checks.append(check_function_exists("backend.tts_asr", "DeviceCapabilities"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "ModelStatus"))
|
||||
|
||||
# 4. 检查环境变量
|
||||
print("\n[4] 环境变量默认值检查")
|
||||
print("-"*70)
|
||||
checks.append(check_environment_variable("TTS_ASR_DEVICE", "auto"))
|
||||
checks.append(check_environment_variable("TTS_ASR_MODEL_SIZE", "auto"))
|
||||
checks.append(check_environment_variable("TTS_ASR_QUANTIZE", "false"))
|
||||
checks.append(check_environment_variable("TTS_ASR_OFFLINE_MODE", "false"))
|
||||
|
||||
# 5. 检查常量
|
||||
print("\n[5] 常量检查")
|
||||
print("-"*70)
|
||||
try:
|
||||
from backend.tts_asr import WHISPER_MODEL_SIZES, APPLE_SILICON_DEFAULT_SIZE
|
||||
expected_sizes = ['tiny', 'base', 'small', 'medium', 'large', 'turbo']
|
||||
sizes_match = list(WHISPER_MODEL_SIZES.keys()) == expected_sizes
|
||||
status = "[OK]" if sizes_match else "[FAIL]"
|
||||
print(f"{status} WHISPER_MODEL_SIZES: {list(WHISPER_MODEL_SIZES.keys())}")
|
||||
checks.append(sizes_match)
|
||||
|
||||
size_match = APPLE_SILICON_DEFAULT_SIZE == 'small'
|
||||
status = "[OK]" if size_match else "[FAIL]"
|
||||
print(f"{status} APPLE_SILICON_DEFAULT_SIZE: {APPLE_SILICON_DEFAULT_SIZE}")
|
||||
checks.append(size_match)
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 常量检查失败: {e}")
|
||||
checks.extend([False, False])
|
||||
|
||||
# 汇总结果
|
||||
print("\n" + "="*70)
|
||||
print("验证结果")
|
||||
print("="*70)
|
||||
|
||||
total = len(checks)
|
||||
passed = sum(checks)
|
||||
|
||||
print(f"通过: {passed}/{total}")
|
||||
|
||||
if all(checks):
|
||||
print("\n[SUCCESS] 所有验证通过!TTS/ASR模块修复已正确应用。")
|
||||
return 0
|
||||
else:
|
||||
print("\n[FAILED] 部分验证失败,请检查上述错误。")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,204 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TTS/ASR测试运行器
|
||||
便捷地运行各种测试组合
|
||||
|
||||
运行方式:
|
||||
python backend/tests/run_tests.py --help
|
||||
python backend/tests/run_tests.py unit
|
||||
python backend/tests/run_tests.py integration
|
||||
python backend/tests/run_tests.py simulate
|
||||
python backend/tests/run_tests.py all
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_command(cmd: list, cwd: str = None) -> int:
|
||||
"""运行命令并返回退出码"""
|
||||
print(f"\n执行: {' '.join(cmd)}")
|
||||
print("-" * 70)
|
||||
result = subprocess.run(cmd, cwd=cwd)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def run_unit_tests(verbose: bool = False) -> int:
|
||||
"""运行单元测试"""
|
||||
print("\n" + "="*70)
|
||||
print("运行单元测试")
|
||||
print("="*70)
|
||||
|
||||
cmd = ['pytest', 'backend/tests/test_tts_asr_unit.py']
|
||||
if verbose:
|
||||
cmd.append('-v')
|
||||
|
||||
return run_command(cmd)
|
||||
|
||||
|
||||
def run_integration_tests(test_type: str = None, url: str = None, key: str = None) -> int:
|
||||
"""运行集成测试"""
|
||||
print("\n" + "="*70)
|
||||
print("运行集成测试")
|
||||
print("="*70)
|
||||
|
||||
cmd = ['python', 'backend/tests/test_tts_asr_integration.py']
|
||||
|
||||
if test_type:
|
||||
cmd.extend(['--test', test_type])
|
||||
|
||||
if url:
|
||||
cmd.extend(['--url', url])
|
||||
|
||||
if key:
|
||||
cmd.extend(['--key', key])
|
||||
|
||||
return run_command(cmd)
|
||||
|
||||
|
||||
def run_simulation(test_type: str = None) -> int:
|
||||
"""运行macOS模拟测试"""
|
||||
print("\n" + "="*70)
|
||||
print("运行macOS环境模拟测试")
|
||||
print("="*70)
|
||||
|
||||
if test_type == 'full':
|
||||
cmd = ['python', 'backend/tests/simulate_macos.py', '--full-simulation']
|
||||
elif test_type:
|
||||
cmd = ['python', 'backend/tests/simulate_macos.py', '--test', test_type]
|
||||
else:
|
||||
cmd = ['python', 'backend/tests/simulate_macos.py', '--full-simulation']
|
||||
|
||||
return run_command(cmd)
|
||||
|
||||
|
||||
def run_all_tests(url: str = None, key: str = None) -> int:
|
||||
"""运行所有测试"""
|
||||
print("\n" + "="*70)
|
||||
print("运行完整测试套件")
|
||||
print("="*70)
|
||||
|
||||
results = []
|
||||
|
||||
# 1. 单元测试
|
||||
print("\n[1/3] 单元测试")
|
||||
results.append(("单元测试", run_unit_tests(verbose=True)))
|
||||
|
||||
# 2. macOS模拟测试
|
||||
print("\n[2/3] macOS模拟测试")
|
||||
results.append(("macOS模拟", run_simulation(test_type='full')))
|
||||
|
||||
# 3. 集成测试(如果服务可用)
|
||||
print("\n[3/3] 集成测试")
|
||||
print("注意: 集成测试需要后端服务运行中")
|
||||
response = input("是否继续运行集成测试? [y/N]: ")
|
||||
|
||||
if response.lower() == 'y':
|
||||
results.append(("集成测试", run_integration_tests(url=url, key=key)))
|
||||
else:
|
||||
print("跳过集成测试")
|
||||
results.append(("集成测试", 0))
|
||||
|
||||
# 汇总结果
|
||||
print("\n" + "="*70)
|
||||
print("测试结果汇总")
|
||||
print("="*70)
|
||||
|
||||
total_passed = 0
|
||||
for name, code in results:
|
||||
status = "✓ 通过" if code == 0 else "✗ 失败"
|
||||
print(f"{name}: {status}")
|
||||
if code == 0:
|
||||
total_passed += 1
|
||||
|
||||
print("\n" + "-"*70)
|
||||
print(f"总计: {total_passed}/{len(results)} 测试套件通过")
|
||||
print("="*70)
|
||||
|
||||
return 0 if all(code == 0 for _, code in results) else 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='TTS/ASR测试运行器',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
# 运行单元测试
|
||||
python backend/tests/run_tests.py unit
|
||||
|
||||
# 运行集成测试
|
||||
python backend/tests/run_tests.py integration
|
||||
|
||||
# 运行macOS模拟测试
|
||||
python backend/tests/run_tests.py simulate
|
||||
|
||||
# 运行所有测试
|
||||
python backend/tests/run_tests.py all
|
||||
|
||||
# 运行特定集成测试
|
||||
python backend/tests/run_tests.py integration --test config
|
||||
|
||||
# 运行特定模拟测试
|
||||
python backend/tests/run_tests.py simulate --test device
|
||||
"""
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='测试类型')
|
||||
|
||||
# 单元测试
|
||||
unit_parser = subparsers.add_parser('unit', help='运行单元测试')
|
||||
unit_parser.add_argument('-v', '--verbose', action='store_true', help='详细输出')
|
||||
|
||||
# 集成测试
|
||||
integration_parser = subparsers.add_parser('integration', help='运行集成测试')
|
||||
integration_parser.add_argument('--test', choices=[
|
||||
'config', 'status', 'warmup', 'tts', 'asr', 'perf'
|
||||
], help='运行特定测试')
|
||||
integration_parser.add_argument('--url', default='http://localhost:8001', help='API URL')
|
||||
integration_parser.add_argument('--key', default='your-secret-key-here', help='API密钥')
|
||||
|
||||
# macOS模拟测试
|
||||
simulate_parser = subparsers.add_parser('simulate', help='运行macOS模拟测试')
|
||||
simulate_parser.add_argument('--test', choices=[
|
||||
'device', 'memory', 'model', 'audio', 'env', 'full'
|
||||
], help='运行特定测试')
|
||||
|
||||
# 所有测试
|
||||
all_parser = subparsers.add_parser('all', help='运行所有测试')
|
||||
all_parser.add_argument('--url', default='http://localhost:8001', help='API URL')
|
||||
all_parser.add_argument('--key', default='your-secret-key-here', help='API密钥')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 确保在项目根目录
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
os.chdir(project_root)
|
||||
|
||||
if args.command == 'unit':
|
||||
return run_unit_tests(verbose=args.verbose)
|
||||
|
||||
elif args.command == 'integration':
|
||||
return run_integration_tests(
|
||||
test_type=args.test,
|
||||
url=args.url,
|
||||
key=args.key
|
||||
)
|
||||
|
||||
elif args.command == 'simulate':
|
||||
return run_simulation(test_type=args.test)
|
||||
|
||||
elif args.command == 'all':
|
||||
return run_all_tests(url=args.url, key=args.key)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,505 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
macOS环境模拟测试工具
|
||||
在非macOS环境下模拟Apple Silicon环境进行测试
|
||||
|
||||
运行方式:
|
||||
python backend/tests/simulate_macos.py --help
|
||||
python backend/tests/simulate_macos.py --device mps
|
||||
python backend/tests/simulate_macos.py --apple-silicon
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
|
||||
|
||||
class MacOSSimulator:
|
||||
"""macOS环境模拟器"""
|
||||
|
||||
def __init__(self):
|
||||
self.original_platform_system = platform.system
|
||||
self.original_platform_machine = platform.machine
|
||||
self.patches = []
|
||||
|
||||
def simulate_apple_silicon(self):
|
||||
"""模拟Apple Silicon环境"""
|
||||
print("\n" + "="*70)
|
||||
print("模拟 Apple Silicon 环境")
|
||||
print("="*70)
|
||||
|
||||
# 模拟Darwin系统和arm64架构
|
||||
self.patches.append(patch('platform.system', return_value='Darwin'))
|
||||
self.patches.append(patch('platform.machine', return_value='arm64'))
|
||||
|
||||
for p in self.patches:
|
||||
p.start()
|
||||
|
||||
print("✓ 平台: Darwin (macOS)")
|
||||
print("✓ 架构: arm64 (Apple Silicon)")
|
||||
|
||||
def simulate_mps_device(self):
|
||||
"""模拟MPS设备可用"""
|
||||
print("\n" + "="*70)
|
||||
print("模拟 MPS 设备")
|
||||
print("="*70)
|
||||
|
||||
# 创建模拟的torch.backends.mps
|
||||
mock_mps = type('MockMPS', (), {
|
||||
'is_available': lambda: True,
|
||||
'is_built': lambda: True,
|
||||
'empty_cache': lambda: None
|
||||
})()
|
||||
|
||||
mock_backends = type('MockBackends', (), {
|
||||
'mps': mock_mps
|
||||
})()
|
||||
|
||||
# 模拟torch模块
|
||||
mock_torch = type('MockTorch', (), {
|
||||
'backends': mock_backends,
|
||||
'mps': mock_mps,
|
||||
'randn': lambda *args, **kwargs: np.random.randn(*args),
|
||||
'mm': lambda a, b: np.dot(a, b),
|
||||
'empty_cache': lambda: None
|
||||
})()
|
||||
|
||||
self.patches.append(patch('torch', mock_torch))
|
||||
self.patches.append(patch('torch.backends.mps.is_available', return_value=True))
|
||||
self.patches.append(patch('torch.backends.mps.is_built', return_value=True))
|
||||
|
||||
for p in self.patches[-3:]:
|
||||
p.start()
|
||||
|
||||
print("✓ MPS 可用: True")
|
||||
print("✓ MPS 已编译: True")
|
||||
|
||||
def simulate_cuda_device(self):
|
||||
"""模拟CUDA设备可用"""
|
||||
print("\n" + "="*70)
|
||||
print("模拟 CUDA 设备")
|
||||
print("="*70)
|
||||
|
||||
mock_cuda = type('MockCUDA', (), {
|
||||
'is_available': lambda: True,
|
||||
'device_count': lambda: 1,
|
||||
'get_device_properties': lambda n: type('Props', (), {'total_memory': 8*1024*1024*1024})(),
|
||||
'empty_cache': lambda: None
|
||||
})()
|
||||
|
||||
self.patches.append(patch('torch.cuda', mock_cuda))
|
||||
self.patches.append(patch('torch.cuda.is_available', return_value=True))
|
||||
|
||||
for p in self.patches[-2:]:
|
||||
p.start()
|
||||
|
||||
print("✓ CUDA 可用: True")
|
||||
print("✓ GPU 数量: 1")
|
||||
print("✓ 显存: 8 GB")
|
||||
|
||||
def cleanup(self):
|
||||
"""清理所有补丁"""
|
||||
for p in self.patches:
|
||||
p.stop()
|
||||
self.patches.clear()
|
||||
print("\n✓ 已清理模拟环境")
|
||||
|
||||
|
||||
def test_device_detection_on_apple_silicon():
|
||||
"""测试Apple Silicon设备检测"""
|
||||
print("\n测试1: Apple Silicon 设备检测")
|
||||
print("-"*70)
|
||||
|
||||
simulator = MacOSSimulator()
|
||||
try:
|
||||
simulator.simulate_apple_silicon()
|
||||
simulator.simulate_mps_device()
|
||||
|
||||
# 设置环境变量
|
||||
os.environ['TTS_ASR_DEVICE'] = 'auto'
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'auto'
|
||||
|
||||
# 重新导入模块以应用模拟
|
||||
if 'backend.tts_asr' in sys.modules:
|
||||
del sys.modules['backend.tts_asr']
|
||||
|
||||
from backend.tts_asr import (
|
||||
_is_apple_silicon,
|
||||
_detect_device_capabilities,
|
||||
_get_recommended_model_size
|
||||
)
|
||||
|
||||
# 测试Apple Silicon检测
|
||||
assert _is_apple_silicon(), "应该检测到Apple Silicon"
|
||||
print("✓ Apple Silicon 检测: 通过")
|
||||
|
||||
# 测试设备能力检测
|
||||
caps = _detect_device_capabilities()
|
||||
print(f"✓ 设备: {caps.device}")
|
||||
print(f"✓ MPS 可用: {caps.mps_available}")
|
||||
print(f"✓ 推荐模型大小: {caps.recommended_model_size}")
|
||||
|
||||
# 测试模型大小推荐
|
||||
recommended_size = _get_recommended_model_size()
|
||||
assert recommended_size in ['small', 'tiny', 'base'], \
|
||||
f"Apple Silicon应推荐小模型,但推荐了 {recommended_size}"
|
||||
print(f"✓ 推荐模型大小: {recommended_size}")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
simulator.cleanup()
|
||||
|
||||
|
||||
def test_memory_management():
|
||||
"""测试内存管理"""
|
||||
print("\n测试2: 内存管理")
|
||||
print("-"*70)
|
||||
|
||||
simulator = MacOSSimulator()
|
||||
try:
|
||||
simulator.simulate_apple_silicon()
|
||||
simulator.simulate_mps_device()
|
||||
|
||||
# 模拟系统内存
|
||||
import psutil
|
||||
original_virtual_memory = psutil.virtual_memory
|
||||
|
||||
def mock_virtual_memory():
|
||||
mock_mem = type('MockMemory', (), {
|
||||
'total': 16 * 1024 * 1024 * 1024 # 16GB
|
||||
})()
|
||||
return mock_mem
|
||||
|
||||
self.patches.append(patch('psutil.virtual_memory', mock_virtual_memory))
|
||||
|
||||
from backend.tts_asr import _get_system_memory_mb, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
|
||||
mem_mb = _get_system_memory_mb()
|
||||
print(f"✓ 系统内存: {mem_mb} MB")
|
||||
|
||||
# 计算预期的MPS内存限制(60%)
|
||||
expected_limit = int(mem_mb * 0.6)
|
||||
print(f"✓ 预期MPS限制: {expected_limit} MB (60%)")
|
||||
print(f"✓ 配置MPS限制: {TTS_ASR_MPS_MEMORY_LIMIT_MB} MB")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
simulator.cleanup()
|
||||
|
||||
|
||||
def test_model_size_selection():
|
||||
"""测试模型大小选择"""
|
||||
print("\n测试3: 模型大小选择")
|
||||
print("-"*70)
|
||||
|
||||
test_cases = [
|
||||
('auto', 'Apple Silicon默认'),
|
||||
('tiny', '最小模型'),
|
||||
('small', '推荐模型'),
|
||||
('medium', '中等模型'),
|
||||
('large', '大模型'),
|
||||
('turbo', 'turbo模型'),
|
||||
]
|
||||
|
||||
from backend.tts_asr import WHISPER_MODEL_SIZES, _get_recommended_model_size
|
||||
|
||||
for size, desc in test_cases:
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = size
|
||||
|
||||
# 重新加载模块
|
||||
if 'backend.tts_asr' in sys.modules:
|
||||
del sys.modules['backend.tts_asr']
|
||||
|
||||
from backend.tts_asr import _get_recommended_model_size
|
||||
|
||||
if size == 'auto':
|
||||
# 自动选择
|
||||
recommended = _get_recommended_model_size()
|
||||
print(f"✓ {desc}: {recommended}")
|
||||
else:
|
||||
# 显式选择
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = size
|
||||
result = _get_recommended_model_size()
|
||||
assert result == size, f"应该返回 {size},但返回了 {result}"
|
||||
print(f"✓ {desc}: {size} -> {WHISPER_MODEL_SIZES[size]}")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
|
||||
def test_audio_processing():
|
||||
"""测试音频处理"""
|
||||
print("\n测试4: 音频处理")
|
||||
print("-"*70)
|
||||
|
||||
from backend.tts_asr import (
|
||||
_validate_audio_data,
|
||||
_resample_audio_robust
|
||||
)
|
||||
|
||||
# 测试音频验证
|
||||
test_cases = [
|
||||
(b'', False, "空数据"),
|
||||
(b'short', False, "太短"),
|
||||
(b'RIFF' + b'\x00' * 40, True, "有效WAV头"),
|
||||
]
|
||||
|
||||
for data, expected, desc in test_cases:
|
||||
result = _validate_audio_data(data)
|
||||
assert result == expected, f"{desc}: 预期 {expected},得到 {result}"
|
||||
print(f"✓ 音频验证 ({desc}): {'通过' if result == expected else '失败'}")
|
||||
|
||||
# 测试重采样
|
||||
audio_16k = np.sin(np.linspace(0, 2*np.pi, 16000)).astype(np.float32)
|
||||
|
||||
# 16k -> 48k
|
||||
audio_48k = _resample_audio_robust(audio_16k, 16000, 48000)
|
||||
assert len(audio_48k) == 48000, f"48kHz音频长度错误: {len(audio_48k)}"
|
||||
print(f"✓ 重采样 (16k -> 48k): 长度 {len(audio_16k)} -> {len(audio_48k)}")
|
||||
|
||||
# 48k -> 16k
|
||||
audio_back = _resample_audio_robust(audio_48k, 48000, 16000)
|
||||
assert len(audio_back) == 16000, f"16kHz音频长度错误: {len(audio_back)}"
|
||||
print(f"✓ 重采样 (48k -> 16k): 长度 {len(audio_48k)} -> {len(audio_back)}")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
|
||||
def test_environment_variables():
|
||||
"""测试环境变量"""
|
||||
print("\n测试5: 环境变量配置")
|
||||
print("-"*70)
|
||||
|
||||
# 清理环境变量
|
||||
env_vars = [
|
||||
'TTS_ASR_DEVICE', 'TTS_ASR_MODEL_SIZE', 'TTS_ASR_QUANTIZE',
|
||||
'TTS_ASR_OFFLINE_MODE', 'TTS_ASR_WARMUP', 'TTS_ASR_WARMUP_TIMEOUT',
|
||||
'TTS_ASR_IDLE_TIMEOUT', 'TTS_ASR_MPS_MEMORY_LIMIT_MB'
|
||||
]
|
||||
|
||||
original_values = {}
|
||||
for var in env_vars:
|
||||
original_values[var] = os.environ.get(var)
|
||||
if var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
try:
|
||||
# 测试默认值
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE, TTS_ASR_WARMUP, TTS_ASR_WARMUP_TIMEOUT,
|
||||
TTS_ASR_IDLE_TIMEOUT, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
)
|
||||
|
||||
defaults = {
|
||||
'TTS_ASR_DEVICE': 'auto',
|
||||
'TTS_ASR_MODEL_SIZE': 'auto',
|
||||
'TTS_ASR_QUANTIZE': False,
|
||||
'TTS_ASR_OFFLINE_MODE': False,
|
||||
'TTS_ASR_WARMUP': True,
|
||||
'TTS_ASR_WARMUP_TIMEOUT': 120,
|
||||
'TTS_ASR_IDLE_TIMEOUT': 0,
|
||||
'TTS_ASR_MPS_MEMORY_LIMIT_MB': 8192,
|
||||
}
|
||||
|
||||
for var, expected in defaults.items():
|
||||
actual = locals()[var]
|
||||
assert actual == expected, f"{var}: 预期 {expected},得到 {actual}"
|
||||
print(f"✓ {var} = {actual}")
|
||||
|
||||
# 测试自定义值
|
||||
print("\n自定义配置测试:")
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'small'
|
||||
os.environ['TTS_ASR_QUANTIZE'] = 'true'
|
||||
os.environ['TTS_ASR_OFFLINE_MODE'] = 'true'
|
||||
os.environ['TTS_ASR_MPS_MEMORY_LIMIT_MB'] = '4096'
|
||||
|
||||
# 重新加载
|
||||
if 'backend.tts_asr' in sys.modules:
|
||||
del sys.modules['backend.tts_asr']
|
||||
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
)
|
||||
|
||||
assert TTS_ASR_MODEL_SIZE == 'small'
|
||||
assert TTS_ASR_QUANTIZE == True
|
||||
assert TTS_ASR_OFFLINE_MODE == True
|
||||
assert TTS_ASR_MPS_MEMORY_LIMIT_MB == 4096
|
||||
|
||||
print(f"✓ TTS_ASR_MODEL_SIZE = {TTS_ASR_MODEL_SIZE}")
|
||||
print(f"✓ TTS_ASR_QUANTIZE = {TTS_ASR_QUANTIZE}")
|
||||
print(f"✓ TTS_ASR_OFFLINE_MODE = {TTS_ASR_OFFLINE_MODE}")
|
||||
print(f"✓ TTS_ASR_MPS_MEMORY_LIMIT_MB = {TTS_ASR_MPS_MEMORY_LIMIT_MB}")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
finally:
|
||||
# 恢复原始值
|
||||
for var, value in original_values.items():
|
||||
if value is not None:
|
||||
os.environ[var] = value
|
||||
elif var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
|
||||
def run_full_simulation():
|
||||
"""运行完整模拟测试"""
|
||||
print("\n" + "="*70)
|
||||
print("完整macOS环境模拟测试")
|
||||
print("="*70)
|
||||
|
||||
results = []
|
||||
|
||||
# 运行所有测试
|
||||
results.append(("设备检测", test_device_detection_on_apple_silicon()))
|
||||
results.append(("内存管理", test_memory_management()))
|
||||
results.append(("模型选择", test_model_size_selection()))
|
||||
results.append(("音频处理", test_audio_processing()))
|
||||
results.append(("环境变量", test_environment_variables()))
|
||||
|
||||
# 汇总结果
|
||||
print("\n" + "="*70)
|
||||
print("测试结果汇总")
|
||||
print("="*70)
|
||||
|
||||
for name, passed in results:
|
||||
status = "✓ 通过" if passed else "✗ 失败"
|
||||
print(f"{name}: {status}")
|
||||
|
||||
total = len(results)
|
||||
passed = sum(1 for _, p in results if p)
|
||||
|
||||
print("\n" + "-"*70)
|
||||
print(f"总计: {passed}/{total} 测试通过")
|
||||
print("="*70)
|
||||
|
||||
return all(p for _, p in results)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='macOS环境模拟测试工具',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
# 运行完整模拟测试
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
|
||||
# 仅模拟Apple Silicon环境
|
||||
python backend/tests/simulate_macos.py --apple-silicon
|
||||
|
||||
# 仅模拟MPS设备
|
||||
python backend/tests/simulate_macos.py --device mps
|
||||
|
||||
# 仅模拟CUDA设备
|
||||
python backend/tests/simulate_macos.py --device cuda
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--full-simulation',
|
||||
action='store_true',
|
||||
help='运行完整模拟测试'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--apple-silicon',
|
||||
action='store_true',
|
||||
help='模拟Apple Silicon环境'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--device',
|
||||
choices=['mps', 'cuda'],
|
||||
help='模拟特定设备'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--test',
|
||||
choices=['device', 'memory', 'model', 'audio', 'env'],
|
||||
help='运行特定测试'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 确保可以导入backend模块
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
if args.full_simulation:
|
||||
success = run_full_simulation()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
if args.apple_silicon:
|
||||
simulator = MacOSSimulator()
|
||||
try:
|
||||
simulator.simulate_apple_silicon()
|
||||
simulator.simulate_mps_device()
|
||||
|
||||
print("\n环境已模拟,按Ctrl+D退出")
|
||||
print("在Python环境中可以使用:")
|
||||
print(" from backend.tts_asr import _is_apple_silicon")
|
||||
print(" print(_is_apple_silicon()) # 应该返回 True")
|
||||
|
||||
# 进入交互模式
|
||||
import code
|
||||
code.interact(local=locals())
|
||||
finally:
|
||||
simulator.cleanup()
|
||||
|
||||
if args.device:
|
||||
simulator = MacOSSimulator()
|
||||
try:
|
||||
if args.device == 'mps':
|
||||
simulator.simulate_mps_device()
|
||||
elif args.device == 'cuda':
|
||||
simulator.simulate_cuda_device()
|
||||
|
||||
print("\n设备已模拟")
|
||||
import code
|
||||
code.interact(local=locals())
|
||||
finally:
|
||||
simulator.cleanup()
|
||||
|
||||
if args.test:
|
||||
test_func = {
|
||||
'device': test_device_detection_on_apple_silicon,
|
||||
'memory': test_memory_management,
|
||||
'model': test_model_size_selection,
|
||||
'audio': test_audio_processing,
|
||||
'env': test_environment_variables,
|
||||
}
|
||||
|
||||
success = test_func[args.test]()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
# 默认运行完整测试
|
||||
if not any([args.full_simulation, args.apple_silicon, args.device, args.test]):
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Binary file not shown.
@@ -1,168 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import types
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
# Ensure the backend directory is on sys.path so we can import the geoip module directly
|
||||
BACKEND_DIR = pathlib.Path(__file__).resolve().parents[1] # backend/ folder
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import geoip as geoip
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_geoip_reader():
|
||||
# Ensure each test starts with a clean cache
|
||||
geoip._geoip_reader = None
|
||||
yield
|
||||
geoip._geoip_reader = None
|
||||
|
||||
|
||||
def test_get_reader_import_error(monkeypatch):
|
||||
import builtins
|
||||
real_import = getattr(builtins, "__import__")
|
||||
|
||||
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||
if name == "geoip2.database":
|
||||
raise ImportError("simulate missing geoip2")
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
geoip._geoip_reader = None
|
||||
assert geoip._get_reader() is None
|
||||
|
||||
|
||||
def test_get_reader_db_missing(monkeypatch):
|
||||
# Provide a fake geoip2 module, but force the database file to be considered missing
|
||||
fake_db_module = types.ModuleType("geoip2.database")
|
||||
class FakeReader:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
fake_db_module.Reader = FakeReader
|
||||
|
||||
fake_geoip2 = types.ModuleType("geoip2")
|
||||
fake_geoip2.database = fake_db_module
|
||||
|
||||
sys.modules["geoip2"] = fake_geoip2
|
||||
sys.modules["geoip2.database"] = fake_db_module
|
||||
|
||||
# Ensure path existence check returns False
|
||||
monkeypatch.setattr(geoip.os.path, "exists", lambda p: False)
|
||||
|
||||
geoip._geoip_reader = None
|
||||
assert geoip._get_reader() is None
|
||||
|
||||
# Clean up injected modules
|
||||
del sys.modules["geoip2"]
|
||||
del sys.modules["geoip2.database"]
|
||||
|
||||
|
||||
def test_get_reader_loads_and_caches(monkeypatch):
|
||||
fake_db_module = types.ModuleType("geoip2.database")
|
||||
class FakeReader:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
fake_db_module.Reader = FakeReader
|
||||
|
||||
fake_geoip2 = types.ModuleType("geoip2")
|
||||
fake_geoip2.database = fake_db_module
|
||||
|
||||
sys.modules["geoip2"] = fake_geoip2
|
||||
sys.modules["geoip2.database"] = fake_db_module
|
||||
|
||||
# Simulate that the database file exists
|
||||
monkeypatch.setattr(geoip.os.path, "exists", lambda p: True)
|
||||
|
||||
geoip._geoip_reader = None
|
||||
r1 = geoip._get_reader()
|
||||
assert isinstance(r1, FakeReader)
|
||||
# Second call should return the same cached instance
|
||||
r2 = geoip._get_reader()
|
||||
assert r1 is r2
|
||||
# Clean up injected modules
|
||||
del sys.modules["geoip2"]
|
||||
del sys.modules["geoip2.database"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip", [None, "", "127.0.0.1", "localhost", "::1"])
|
||||
def test_get_ip_location_none_inputs(ip):
|
||||
assert geoip.get_ip_location(ip) is None
|
||||
|
||||
|
||||
def test_get_ip_location_reader_none(monkeypatch):
|
||||
# When there is no reader (no database), return None
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: None)
|
||||
assert geoip.get_ip_location("1.2.3.4") is None
|
||||
|
||||
|
||||
def test_get_ip_location_successful_lookup(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
country = SimpleNamespace(name="United States")
|
||||
region = SimpleNamespace(name="California")
|
||||
resp = SimpleNamespace(
|
||||
country=country,
|
||||
subdivisions=SimpleNamespace(most_specific=region),
|
||||
city=SimpleNamespace(name="Mountain View"),
|
||||
)
|
||||
|
||||
class FakeReader:
|
||||
def city(self, ip):
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: FakeReader())
|
||||
loc = geoip.get_ip_location("1.2.3.4")
|
||||
assert loc == {
|
||||
"country": "United States",
|
||||
"region": "California",
|
||||
"city": "Mountain View",
|
||||
"display": "United States California Mountain View",
|
||||
}
|
||||
|
||||
|
||||
def test_get_ip_location_reader_exception(monkeypatch):
|
||||
class FakeReader:
|
||||
def city(self, ip):
|
||||
raise Exception("boom")
|
||||
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: FakeReader())
|
||||
assert geoip.get_ip_location("1.2.3.4") is None
|
||||
|
||||
|
||||
def test_get_ip_location_no_location_parts(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
resp = SimpleNamespace(country=SimpleNamespace(name=None), subdivisions=None, city=None)
|
||||
|
||||
class FakeReader:
|
||||
def city(self, ip):
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: FakeReader())
|
||||
assert geoip.get_ip_location("1.2.3.4") is None
|
||||
|
||||
|
||||
def test_get_ip_location_text_valid(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
country = SimpleNamespace(name="United States")
|
||||
region = SimpleNamespace(name="California")
|
||||
resp = SimpleNamespace(
|
||||
country=country,
|
||||
subdivisions=SimpleNamespace(most_specific=region),
|
||||
city=SimpleNamespace(name="Mountain View"),
|
||||
)
|
||||
|
||||
class FakeReader:
|
||||
def city(self, ip):
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(geoip, "_get_reader", lambda: FakeReader())
|
||||
assert geoip.get_ip_location_text("1.2.3.4") == "United States California Mountain View"
|
||||
|
||||
|
||||
def test_get_ip_location_text_none_when_no_location(monkeypatch):
|
||||
# Force get_ip_location to return None
|
||||
monkeypatch.setattr(geoip, "get_ip_location", lambda ip: None)
|
||||
assert geoip.get_ip_location_text("1.2.3.4") == ""
|
||||
@@ -1,211 +0,0 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
try:
|
||||
llm = importlib.import_module("llm")
|
||||
except ModuleNotFoundError:
|
||||
pytest.skip("llm module dependencies are not available", allow_module_level=True)
|
||||
|
||||
|
||||
def test_extract_message_with_object_message_content_and_thinking():
|
||||
class Msg:
|
||||
def __init__(self, content, thinking):
|
||||
self.content = content
|
||||
self.thinking = thinking
|
||||
|
||||
class Resp:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
resp = Resp(Msg("hello world", "thinking about it"))
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "hello world"
|
||||
assert thinking == "thinking about it"
|
||||
|
||||
|
||||
def test_extract_message_with_object_message_empty_content():
|
||||
class Msg:
|
||||
def __init__(self, content, thinking):
|
||||
self.content = content
|
||||
self.thinking = thinking
|
||||
|
||||
class Resp:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
resp = Resp(Msg("", None))
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_with_dict_message():
|
||||
resp = {"message": {"content": "ok", "thinking": "calc"}}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "ok"
|
||||
assert thinking == "calc"
|
||||
|
||||
|
||||
def test_extract_message_dict_no_message_key():
|
||||
resp = {"not_message": {"content": "irrelevant"}}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_dict_message_content_none_and_thinking_none():
|
||||
resp = {"message": {"content": None, "thinking": None}}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_dict_message_thinking_none():
|
||||
resp = {"message": {"content": "val", "thinking": None}}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "val"
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_empty_dict():
|
||||
resp = {}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_call_ollama_no_system_message(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
captured["messages"] = kwargs.get("messages", [])
|
||||
return {"message": {"content": "ok", "thinking": ""}}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama("user prompt body", system_prompt=None, tag="no-system", temperature=0.1)
|
||||
)
|
||||
assert result["content"] == "ok"
|
||||
assert len(captured["messages"]) == 1
|
||||
assert captured["messages"][0]["role"] == "user"
|
||||
assert captured["messages"][0]["content"] == "user prompt body"
|
||||
|
||||
|
||||
def test_call_ollama_whitespace_system_message(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
captured["messages"] = kwargs.get("messages", [])
|
||||
return {"message": {"content": "ok", "thinking": ""}}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama("user prompt", system_prompt=" ", tag="whitespace-system", temperature=0.1)
|
||||
)
|
||||
assert result["content"] == "ok"
|
||||
assert len(captured["messages"]) == 1
|
||||
assert captured["messages"][0]["role"] == "user"
|
||||
|
||||
|
||||
def test_call_ollama_thinking_in_kwargs(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"message": {"content": "ok", "thinking": "boom"}}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
|
||||
res = asyncio.run(
|
||||
llm.call_ollama("prompt", thinking="boom", tag="think-flag", temperature=0.7)
|
||||
)
|
||||
assert res["content"] == "ok" and res["think"] == "boom"
|
||||
assert captured.get("think") == "boom"
|
||||
|
||||
|
||||
def test_call_ollama_cancelled_reraises(monkeypatch):
|
||||
async def fake_chat(**kwargs):
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
asyncio.run(
|
||||
llm.call_ollama("prompt", system_prompt=None, tag="cancel", temperature=0.7)
|
||||
)
|
||||
|
||||
|
||||
def test_call_ollama_chat_raises_rethrows(monkeypatch):
|
||||
async def fake_chat(**kwargs):
|
||||
raise ValueError("boom")
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(
|
||||
llm.call_ollama("prompt", system_prompt=None, tag="exception", temperature=0.7)
|
||||
)
|
||||
|
||||
|
||||
def test_call_ollama_returns_content_and_think_from_response(monkeypatch):
|
||||
async def fake_chat(**kwargs):
|
||||
return {"message": {"content": "final", "thinking": "process"}}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
|
||||
res = asyncio.run(
|
||||
llm.call_ollama("prompt", system_prompt=None, tag="return", temperature=0.7)
|
||||
)
|
||||
assert res["content"] == "final" and res["think"] == "process"
|
||||
|
||||
|
||||
def test_call_vlm_ocr_passes_image_and_prompt(monkeypatch):
|
||||
image_bytes = b"image-bytes"
|
||||
called = {}
|
||||
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
called["kwargs"] = kwargs
|
||||
return {"message": {"content": "ocr result", "thinking": ""}}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
|
||||
result = asyncio.run(llm.call_vlm_ocr(image_bytes, language="auto"))
|
||||
|
||||
messages = called["kwargs"].get("messages", [])
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "OCR PROMPT"
|
||||
assert messages[0]["images"] == [image_bytes]
|
||||
assert result == "ocr result"
|
||||
|
||||
|
||||
def test_call_vlm_ocr_chat_raises_rethrows(monkeypatch):
|
||||
image_bytes = b"image-bytes"
|
||||
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
raise RuntimeError("ocr fail")
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(llm.call_vlm_ocr(image_bytes))
|
||||
|
||||
|
||||
def test_call_vlm_ocr_returns_content_from_response(monkeypatch):
|
||||
image_bytes = b"img"
|
||||
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
return {"message": {"content": "ocr text", "thinking": ""}}
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
content = asyncio.run(llm.call_vlm_ocr(image_bytes))
|
||||
assert content == "ocr text"
|
||||
@@ -79,10 +79,8 @@ def test_cancel_endpoint_cancels_running_task(monkeypatch):
|
||||
assert cancelled.wait(timeout=2.0)
|
||||
|
||||
completion_response = response_box["response"]
|
||||
# 499 = client disconnected (TestClient timeout during cancel)
|
||||
assert completion_response.status_code in (200, 499)
|
||||
if completion_response.status_code == 200:
|
||||
assert completion_response.json()["cancelled"] is True
|
||||
assert completion_response.status_code == 200
|
||||
assert '"cancelled": true' in completion_response.text
|
||||
|
||||
|
||||
def test_cancel_not_found():
|
||||
@@ -115,7 +113,6 @@ def test_completion_normal_flow(monkeypatch):
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["content"] == "completion text"
|
||||
assert data["request_id"] is not None
|
||||
assert '"content": "completion text"' in response.text
|
||||
assert '"done": true' in response.text
|
||||
assert main.ACTIVE_COMPLETIONS == {}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import base64
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
|
||||
if BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
|
||||
import main # type: ignore
|
||||
|
||||
API_KEY = main.API_KEY
|
||||
HEADERS = {"X-API-Key": API_KEY}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_active_completions():
|
||||
main.ACTIVE_COMPLETIONS.clear()
|
||||
yield
|
||||
main.ACTIVE_COMPLETIONS.clear()
|
||||
|
||||
|
||||
class DummyRequest:
|
||||
def __init__(self, host=None, headers=None):
|
||||
class Client:
|
||||
pass
|
||||
self.client = Client() if host is not None else None
|
||||
if self.client is not None:
|
||||
self.client.host = host
|
||||
self.headers = headers or {}
|
||||
|
||||
|
||||
def test_preview_short_text():
|
||||
assert main._preview("Hello") == "Hello"
|
||||
|
||||
|
||||
def test_preview_long_text_truncated():
|
||||
long_text = "a" * 100
|
||||
assert main._preview(long_text) == long_text[:80] + "..."
|
||||
|
||||
|
||||
def test_preview_none_input():
|
||||
assert main._preview(None) == ""
|
||||
|
||||
|
||||
def test_preview_newlines_replaced():
|
||||
assert main._preview("line1\nline2") == "line1\\nline2"
|
||||
|
||||
|
||||
def test_sanitize_markdown_strips_image_markdown():
|
||||
assert "" not in main._sanitize_converted_markdown(
|
||||
"text with image  end"
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_markdown_strips_img_tag():
|
||||
assert "<img" not in main._sanitize_converted_markdown("<img src='x.png'/>")
|
||||
|
||||
|
||||
def test_sanitize_markdown_collapse_newlines():
|
||||
assert main._sanitize_converted_markdown("a\n\n\nb\n\n\n\nc") == "a\n\nb\n\nc"
|
||||
|
||||
|
||||
def test_sanitize_markdown_normalize_crlf():
|
||||
result = main._sanitize_converted_markdown("line1\r\nline2\r\n")
|
||||
assert "line1\nline2" in result
|
||||
assert "\r" not in result
|
||||
|
||||
|
||||
def test_get_client_ip_from_host():
|
||||
req = DummyRequest(host="1.2.3.4", headers={})
|
||||
assert main.get_client_ip(req) == "1.2.3.4"
|
||||
|
||||
|
||||
def test_get_client_ip_header_overrides_host():
|
||||
req = DummyRequest(host="1.2.3.4", headers={"X-Client-IP": "5.6.7.8"})
|
||||
assert main.get_client_ip(req) == "5.6.7.8"
|
||||
|
||||
|
||||
def test_get_client_ip_when_client_missing():
|
||||
req = DummyRequest(host=None, headers={"X-Client-IP": "9.9.9.9"})
|
||||
req.client = None
|
||||
assert main.get_client_ip(req) == "9.9.9.9"
|
||||
|
||||
|
||||
def test_post_completions_wrong_api_key_returns_401():
|
||||
client = TestClient(main.app)
|
||||
resp = client.post("/v1/completions", json={
|
||||
"prefix": "hello", "suffix": "", "languageId": "markdown",
|
||||
"model_thinking": "low", "privacy_mode": True,
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_post_completions_privacy_mode(monkeypatch):
|
||||
async def fake_call(*args, **kwargs):
|
||||
return {"content": "done", "think": ""}
|
||||
monkeypatch.setattr(main, "call_ollama", fake_call)
|
||||
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user"))
|
||||
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("p", "s"))
|
||||
|
||||
client = TestClient(main.app)
|
||||
resp = client.post("/v1/completions", headers=HEADERS, json={
|
||||
"prefix": "hello", "suffix": "", "languageId": "markdown",
|
||||
"model_thinking": "low", "privacy_mode": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data.get("content") == "done"
|
||||
|
||||
|
||||
def test_post_ocr_mocked(monkeypatch):
|
||||
async def fake_ocr(*args, **kwargs):
|
||||
return "OCR result text"
|
||||
monkeypatch.setattr(main, "call_vlm_ocr", fake_ocr)
|
||||
|
||||
client = TestClient(main.app)
|
||||
img_b64 = base64.b64encode(b"pretend image data").decode()
|
||||
resp = client.post("/v1/ocr", headers=HEADERS, json={
|
||||
"image": img_b64, "filename": "test.jpg", "language": "auto",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
j = resp.json()
|
||||
assert j["text"] == "OCR result text"
|
||||
assert j["filename"] == "test.jpg"
|
||||
|
||||
|
||||
def test_post_ocr_invalid_base64_returns_500():
|
||||
client = TestClient(main.app)
|
||||
resp = client.post("/v1/ocr", headers=HEADERS, json={
|
||||
"image": "not-base64!!!", "filename": "test.jpg",
|
||||
})
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
def test_post_convert_txt_returns_markdown():
|
||||
client = TestClient(main.app)
|
||||
content = base64.b64encode(b"hello world").decode()
|
||||
resp = client.post("/v1/convert", headers=HEADERS, json={
|
||||
"file": content, "filename": "sample.txt",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
j = resp.json()
|
||||
assert j["markdown"] == "hello world"
|
||||
assert j["filename"] == "sample.txt"
|
||||
|
||||
|
||||
def test_post_convert_unsupported_extension_returns_500():
|
||||
client = TestClient(main.app)
|
||||
content = base64.b64encode(b"data").decode()
|
||||
resp = client.post("/v1/convert", headers=HEADERS, json={
|
||||
"file": content, "filename": "sample.xlsx",
|
||||
})
|
||||
assert resp.status_code == 500
|
||||
assert "仅支持" in resp.json()["error"]
|
||||
|
||||
|
||||
def test_post_convert_docx_with_mocked_markitdown(monkeypatch):
|
||||
class FakeResult:
|
||||
text_content = "markdown from docx"
|
||||
class FakeMD:
|
||||
def convert(self, path):
|
||||
return FakeResult()
|
||||
monkeypatch.setattr(main, "_get_markitdown", lambda: FakeMD())
|
||||
|
||||
client = TestClient(main.app)
|
||||
content = base64.b64encode(b"docx content").decode()
|
||||
resp = client.post("/v1/convert", headers=HEADERS, json={
|
||||
"file": content, "filename": "sample.docx",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
j = resp.json()
|
||||
assert j["markdown"] == "markdown from docx"
|
||||
|
||||
|
||||
def test_post_cancel_non_existent_returns_not_found():
|
||||
client = TestClient(main.app)
|
||||
resp = client.post("/v1/completions/cancel", headers=HEADERS, json={
|
||||
"request_id": "non-existent", "reason": "abort",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cancelled"] is False
|
||||
assert data["status"] == "not_found"
|
||||
|
||||
|
||||
def test_post_cancel_wrong_api_key_returns_401():
|
||||
client = TestClient(main.app)
|
||||
resp = client.post("/v1/completions/cancel", json={
|
||||
"request_id": "id", "reason": "abort",
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_post_cancel_already_done(monkeypatch):
|
||||
main.ACTIVE_COMPLETIONS.clear()
|
||||
# Create a mock task that appears done
|
||||
mock_task = MagicMock()
|
||||
mock_task.done.return_value = True
|
||||
mock_task.cancel = MagicMock()
|
||||
main.ACTIVE_COMPLETIONS["done-id"] = mock_task
|
||||
|
||||
client = TestClient(main.app)
|
||||
resp = client.post("/v1/completions/cancel", headers=HEADERS, json={
|
||||
"request_id": "done-id", "reason": "abort",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cancelled"] is False
|
||||
assert data["status"] == "already_done"
|
||||
main.ACTIVE_COMPLETIONS.clear()
|
||||
@@ -16,13 +16,15 @@ def test_prompt_builds_system_and_user():
|
||||
language_id="markdown",
|
||||
)
|
||||
|
||||
assert "inline completion engine" in system_prompt
|
||||
assert "Hard constraints you must follow" in system_prompt
|
||||
assert "strict KaTeX-compatible math" in system_prompt
|
||||
assert "$...$" in system_prompt
|
||||
assert "$$...$$" in system_prompt
|
||||
assert "```{language}" in system_prompt
|
||||
assert "Mermaid" in system_prompt
|
||||
assert "Mermaid-specific completion rules" in system_prompt
|
||||
assert "CURSOR_FENCE_LANGUAGE" in system_prompt
|
||||
assert "MERMAID_CONTEXT" in system_prompt
|
||||
assert "Output Mermaid statements only." in system_prompt
|
||||
assert "CURSOR_IN_FENCED_CODE_BLOCK" in user_prompt
|
||||
assert "CURSOR_FENCE_LANGUAGE" in user_prompt
|
||||
assert "MERMAID_CONTEXT" in user_prompt
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the project root is in sys.path so imports like `from backend import prompt` work
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from backend import prompt # type: ignore
|
||||
|
||||
|
||||
def test_get_current_datetime_auto_format():
|
||||
s = prompt._get_current_datetime("auto")
|
||||
assert isinstance(s, str)
|
||||
# Expect a date-like prefix: YYYY-MM-DD
|
||||
assert re.match(r"^\d{4}-\d{2}-\d{2}", s)
|
||||
# Expect a 3-letter weekday somewhere
|
||||
assert re.search(r"\b[A-Za-z]{3}\b", s)
|
||||
# Accept either an explicit UTC offset or a UTC label
|
||||
assert re.search(r"UTC|[+-]\d{2}:?\d{2}", s)
|
||||
|
||||
|
||||
def test_get_current_datetime_utc_plus5():
|
||||
s = prompt._get_current_datetime("UTC+5")
|
||||
assert isinstance(s, str)
|
||||
assert "UTC+5" in s
|
||||
|
||||
|
||||
def test_get_current_datetime_gmt_minus3():
|
||||
s = prompt._get_current_datetime("GMT-3")
|
||||
assert isinstance(s, str)
|
||||
assert "GMT-3" in s
|
||||
|
||||
|
||||
def test_get_current_datetime_new_york_fallback():
|
||||
s = prompt._get_current_datetime("America/New_York")
|
||||
assert isinstance(s, str)
|
||||
# Fallback behavior: allow either an explicit offset or a simple date prefix
|
||||
ok = bool(re.search(r"[+-]\d{2}:?\d{2}", s)) or bool(re.match(r"^\d{4}-\d{2}-\d{2}", s))
|
||||
assert ok
|
||||
|
||||
|
||||
def test_sanitize_language_id_empty_none_and_chars():
|
||||
# Empty / None should map to markdown by design
|
||||
assert prompt._sanitize_language_id("") == "markdown"
|
||||
assert prompt._sanitize_language_id(None) == "markdown"
|
||||
# Dangerous chars should be stripped
|
||||
sanitized = prompt._sanitize_language_id("<script>alert(1)</script>")
|
||||
assert "<" not in sanitized and ">" not in sanitized
|
||||
# Valid input preserved
|
||||
assert prompt._sanitize_language_id("python") == "python"
|
||||
# Truncation at 32 chars
|
||||
long_input = "a" * 50
|
||||
trimmed = prompt._sanitize_language_id(long_input)
|
||||
assert len(trimmed) <= 32
|
||||
assert trimmed == "a" * min(32, len(long_input))
|
||||
|
||||
|
||||
def test_normalize_newlines():
|
||||
mixed = "line1\r\nline2\rline3\n"
|
||||
norm = prompt._normalize_newlines(mixed)
|
||||
assert norm == "line1\nline2\nline3\n"
|
||||
|
||||
|
||||
def test_canonical_language_id_synonyms_and_unknown():
|
||||
assert prompt._canonical_language_id("md") == "markdown"
|
||||
assert prompt._canonical_language_id("py") == "python"
|
||||
assert prompt._canonical_language_id("js") == "javascript"
|
||||
assert prompt._canonical_language_id("ts") == "typescript"
|
||||
assert prompt._canonical_language_id("yml") == "yaml"
|
||||
assert prompt._canonical_language_id("Rust") == "rust"
|
||||
|
||||
|
||||
def test_language_guidance_behaviors():
|
||||
# markdown yields empty guidance
|
||||
assert prompt._language_guidance("markdown") == ""
|
||||
# mermaid guidance should mention mermaid
|
||||
g_mermaid = prompt._language_guidance("mermaid")
|
||||
assert isinstance(g_mermaid, str)
|
||||
assert "mermaid" in g_mermaid.lower()
|
||||
# python / javascript should reference the language
|
||||
g_py = prompt._language_guidance("python")
|
||||
assert isinstance(g_py, str) and "python" in g_py.lower()
|
||||
g_js = prompt._language_guidance("javascript")
|
||||
assert isinstance(g_js, str) and "javascript" in g_js.lower()
|
||||
# unknown language should return a string as fallback
|
||||
g_unknown = prompt._language_guidance("unknownlang")
|
||||
assert isinstance(g_unknown, str)
|
||||
|
||||
|
||||
def test_build_inline_system_prompt_templates():
|
||||
s_md = prompt.build_inline_system_prompt("markdown")
|
||||
assert isinstance(s_md, str) and "markdown" in s_md.lower()
|
||||
s_mermaid = prompt.build_inline_system_prompt("mermaid")
|
||||
assert isinstance(s_mermaid, str) and "mermaid" in s_mermaid.lower()
|
||||
|
||||
|
||||
def test_prepare_context_strips_br_tags():
|
||||
prefix, suffix = prompt._prepare_context("<br>hello<br/>", "world<br />")
|
||||
assert "<br" not in prefix
|
||||
assert "<br" not in suffix
|
||||
|
||||
|
||||
def test_cursor_and_fence_helpers_basic():
|
||||
sample = "```python\nprint('hi')\n"
|
||||
assert prompt._cursor_in_fenced_code_block(sample) is True
|
||||
assert prompt._cursor_in_fenced_code_block("plain text") is False
|
||||
assert prompt._active_fence_language(sample) == "python"
|
||||
assert prompt._active_fence_language("plain text") == "none"
|
||||
|
||||
|
||||
def test_is_mermaid_context_detection():
|
||||
assert prompt._is_mermaid_context("flowchart TD", "", "none") is True
|
||||
assert prompt._is_mermaid_context("```mermaid\n", "\n```", "mermaid") is True
|
||||
assert prompt._is_mermaid_context("plain text", "", "none") is False
|
||||
|
||||
|
||||
def test_build_completion_prompts_with_userprefs():
|
||||
class UserPrefs:
|
||||
language = "python"
|
||||
currency = "USD"
|
||||
timezone = "UTC+0"
|
||||
system, user = prompt.build_completion_prompts(
|
||||
prefix="hello", suffix="world", language_id="markdown",
|
||||
preferences=UserPrefs(),
|
||||
)
|
||||
assert isinstance(system, str)
|
||||
assert isinstance(user, str)
|
||||
assert "python" in user.lower() or "USD" in user
|
||||
|
||||
|
||||
def test_build_completion_prompts_privacy_mode_location_empty():
|
||||
system, user = prompt.build_completion_prompts(
|
||||
prefix="hello", suffix="world", language_id="markdown",
|
||||
location="",
|
||||
)
|
||||
assert isinstance(system, str)
|
||||
assert isinstance(user, str)
|
||||
|
||||
|
||||
def test_build_prompt_backward_compatibility():
|
||||
res = prompt.build_prompt(prefix="hello", suffix="world", language_id="markdown")
|
||||
assert isinstance(res, str)
|
||||
@@ -1,327 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
def _make_torch_stub(cuda_avail=False, mps_avail=False):
|
||||
class DummyTensor:
|
||||
def __matmul__(self, other): return self
|
||||
def matmul(self, other): return self
|
||||
|
||||
stub = types.SimpleNamespace()
|
||||
stub.float32 = "float32"
|
||||
stub.float16 = "float16"
|
||||
stub.randn = lambda *a, **k: DummyTensor()
|
||||
stub.mm = lambda a, b: DummyTensor()
|
||||
stub.from_numpy = lambda arr: DummyTensor()
|
||||
stub.nn = types.SimpleNamespace()
|
||||
stub.nn.Linear = MagicMock(return_value=MagicMock())
|
||||
stub.nn.Module = type("Module", (), {})
|
||||
stub.no_grad = MagicMock()
|
||||
stub.no_grad.return_value.__enter__ = MagicMock(return_value=None)
|
||||
stub.no_grad.return_value.__exit__ = MagicMock(return_value=False)
|
||||
stub.backends = types.SimpleNamespace()
|
||||
stub.backends.mps = types.SimpleNamespace()
|
||||
stub.backends.mps.is_available = lambda: mps_avail
|
||||
stub.backends.mps.is_built = lambda: mps_avail
|
||||
stub.cuda = types.SimpleNamespace()
|
||||
stub.cuda.is_available = lambda: cuda_avail
|
||||
stub.cuda.device_count = lambda: 1 if cuda_avail else 0
|
||||
stub.cuda.get_device_properties = lambda n: types.SimpleNamespace(total_memory=8*1024*1024*1024)
|
||||
stub.cuda.empty_cache = lambda: None
|
||||
stub.mps = types.SimpleNamespace()
|
||||
stub.mps.is_available = lambda: mps_avail
|
||||
stub.mps.is_built = lambda: mps_avail
|
||||
stub.mps.empty_cache = lambda: None
|
||||
stub.device = lambda s: s
|
||||
stub.Tensor = MagicMock()
|
||||
return stub
|
||||
|
||||
|
||||
def _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None):
|
||||
for mod_name in list(sys.modules.keys()):
|
||||
if mod_name.startswith("tts_asr") or mod_name == "torch":
|
||||
del sys.modules[mod_name]
|
||||
torch_stub = _make_torch_stub(cuda_avail=cuda_avail, mps_avail=mps_avail)
|
||||
sys.modules["torch"] = torch_stub
|
||||
if env_device is not None:
|
||||
os.environ["TTS_ASR_DEVICE"] = env_device
|
||||
elif "TTS_ASR_DEVICE" in os.environ:
|
||||
del os.environ["TTS_ASR_DEVICE"]
|
||||
import tts_asr
|
||||
tts_asr._device_caps = None
|
||||
tts_asr._tts_pipeline = None
|
||||
tts_asr._asr_pipeline = None
|
||||
tts_asr._tts_last_used = 0
|
||||
tts_asr._asr_last_used = 0
|
||||
return tts_asr
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_tts_env():
|
||||
saved = {}
|
||||
for k in ["TTS_ASR_DEVICE", "TTS_ASR_IDLE_TIMEOUT", "TTS_ASR_MODEL_SIZE",
|
||||
"TTS_ASR_QUANTIZE", "TTS_ASR_OFFLINE_MODE", "TTS_ASR_WARMUP",
|
||||
"TTS_ASR_MPS_MEMORY_LIMIT_MB"]:
|
||||
saved[k] = os.environ.get(k)
|
||||
if k in os.environ:
|
||||
del os.environ[k]
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
if v is not None:
|
||||
os.environ[k] = v
|
||||
elif k in os.environ:
|
||||
del os.environ[k]
|
||||
|
||||
|
||||
# --- Cache clearing ---
|
||||
def test_clear_cuda_cache():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cpu")
|
||||
tts._clear_cuda_cache()
|
||||
|
||||
|
||||
def test_clear_mps_cache():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="cpu")
|
||||
tts._clear_mps_cache()
|
||||
|
||||
|
||||
# --- Model cache check ---
|
||||
def test_check_model_cached_non_offline():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_OFFLINE_MODE"] = "false"
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
assert tts._check_model_cached("openai/whisper-tiny") is True
|
||||
|
||||
|
||||
def test_check_model_cached_offline_mode():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_OFFLINE_MODE"] = "true"
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
assert tts._check_model_cached("openai/whisper-tiny") is False
|
||||
|
||||
|
||||
# --- Torch dtype ---
|
||||
def test_get_torch_dtype_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._get_torch_dtype() == "float32"
|
||||
|
||||
|
||||
def test_get_torch_dtype_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._get_torch_dtype() == "float32"
|
||||
|
||||
|
||||
def test_get_torch_dtype_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_torch_dtype() == "float16"
|
||||
|
||||
|
||||
# --- Device detection ---
|
||||
def test_get_device_cpu_env():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_device_mps_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._get_device() == "mps"
|
||||
|
||||
|
||||
def test_get_device_mps_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="mps")
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_device_cuda_available():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cuda"
|
||||
|
||||
|
||||
def test_get_device_cuda_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_device_auto_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device=None)
|
||||
assert tts._get_device() == "mps"
|
||||
|
||||
|
||||
def test_get_device_auto_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cuda"
|
||||
|
||||
|
||||
def test_get_device_auto_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_device_arg_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._device_arg() == "cuda:0"
|
||||
|
||||
|
||||
def test_device_arg_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._device_arg() == "cpu"
|
||||
|
||||
|
||||
def test_device_arg_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._device_arg() == "mps"
|
||||
|
||||
|
||||
def test_test_device_capability_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("cpu")
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
|
||||
|
||||
def test_test_device_capability_mps_not_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("mps")
|
||||
assert ok is False
|
||||
assert len(err) > 0
|
||||
|
||||
|
||||
def test_test_device_capability_cuda_not_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("cuda")
|
||||
assert ok is False
|
||||
assert len(err) > 0
|
||||
|
||||
|
||||
def test_test_device_capability_unknown_device():
|
||||
tts = _reload_tts_asr()
|
||||
ok, err = tts._test_device_capability("vulkan")
|
||||
assert ok is False
|
||||
assert len(err) > 0
|
||||
|
||||
|
||||
# --- Idle model unload ---
|
||||
def test_check_and_unload_idle_models_timeout_zero():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "0"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time()
|
||||
tts._asr_last_used = time.time()
|
||||
tts._check_and_unload_idle_models()
|
||||
assert tts._tts_pipeline == "pipeline"
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_unloads_when_expired():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "1"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time() - 10
|
||||
tts._asr_last_used = time.time() - 10
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
tts._check_and_unload_idle_models()
|
||||
assert True # Function executed without error
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_keeps_when_not_expired():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "60"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time()
|
||||
tts._asr_last_used = time.time()
|
||||
tts._check_and_unload_idle_models()
|
||||
assert tts._tts_pipeline == "pipeline"
|
||||
|
||||
|
||||
# --- API key ---
|
||||
def test_get_api_key_success():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
key = tts.get_api_key("your-secret-key-here")
|
||||
assert key == "your-secret-key-here"
|
||||
|
||||
|
||||
def test_get_api_key_wrong_key_raises():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
with pytest.raises(Exception):
|
||||
tts.get_api_key("wrong-key")
|
||||
|
||||
|
||||
def test_get_api_key_missing_key_raises():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
with pytest.raises(Exception):
|
||||
tts.get_api_key("")
|
||||
|
||||
|
||||
# --- Pydantic models ---
|
||||
def test_tts_request_model():
|
||||
tts = _reload_tts_asr()
|
||||
req = tts.TTSRequest(text="hello")
|
||||
assert req.text == "hello"
|
||||
assert req.voice == "af_bella"
|
||||
assert req.rate == 1.0
|
||||
assert req.format == "wav"
|
||||
|
||||
|
||||
def test_asr_request_model():
|
||||
tts = _reload_tts_asr()
|
||||
req = tts.ASRRequest(audio_base64="base64data", language="zh")
|
||||
assert req.audio_base64 == "base64data"
|
||||
assert req.language == "zh"
|
||||
|
||||
|
||||
# --- Device capabilities ---
|
||||
def test_detect_device_capabilities_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
caps = tts._detect_device_capabilities()
|
||||
assert caps.device == "cpu"
|
||||
assert caps.mps_available is False
|
||||
assert caps.cuda_available is False
|
||||
|
||||
|
||||
def test_detect_device_capabilities_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True)
|
||||
caps = tts._detect_device_capabilities()
|
||||
assert caps.device == "mps"
|
||||
assert caps.mps_available is True
|
||||
|
||||
|
||||
def test_detect_device_capabilities_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False)
|
||||
caps = tts._detect_device_capabilities()
|
||||
assert caps.device == "cuda"
|
||||
assert caps.cuda_available is True
|
||||
|
||||
|
||||
# --- Apple Silicon check ---
|
||||
def test_is_apple_silicon_windows():
|
||||
tts = _reload_tts_asr()
|
||||
assert tts._is_apple_silicon() is False
|
||||
|
||||
|
||||
# --- Model size ---
|
||||
def test_recommended_model_size_auto():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
size = tts._get_recommended_model_size()
|
||||
assert size in tts.WHISPER_MODEL_SIZES or size == "auto"
|
||||
|
||||
|
||||
def test_recommended_model_size_explicit():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_MODEL_SIZE"] = "tiny"
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
size = tts._get_recommended_model_size()
|
||||
assert size == "tiny"
|
||||
@@ -1,231 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
def _make_torch_stub(cuda_avail=False, mps_avail=False):
|
||||
class DummyTensor:
|
||||
def __matmul__(self, other):
|
||||
return self
|
||||
def matmul(self, other):
|
||||
return self
|
||||
|
||||
def dummy_randn(*args, **kwargs):
|
||||
return DummyTensor()
|
||||
def dummy_mm(a, b):
|
||||
return DummyTensor()
|
||||
def dummy_from_numpy(arr):
|
||||
return DummyTensor()
|
||||
|
||||
stub = types.SimpleNamespace()
|
||||
stub.float32 = "float32"
|
||||
stub.float16 = "float16"
|
||||
stub.randn = dummy_randn
|
||||
stub.mm = dummy_mm
|
||||
stub.from_numpy = dummy_from_numpy
|
||||
|
||||
stub.backends = types.SimpleNamespace()
|
||||
stub.backends.mps = types.SimpleNamespace()
|
||||
stub.backends.mps.is_available = lambda: mps_avail
|
||||
stub.backends.mps.is_built = lambda: mps_avail
|
||||
|
||||
stub.cuda = types.SimpleNamespace()
|
||||
stub.cuda.is_available = lambda: cuda_avail
|
||||
stub.cuda.device_count = lambda: 1 if cuda_avail else 0
|
||||
stub.cuda.get_device_properties = lambda n: types.SimpleNamespace(total_memory=8*1024*1024*1024)
|
||||
stub.cuda.empty_cache = lambda: None
|
||||
|
||||
stub.mps = types.SimpleNamespace()
|
||||
stub.mps.is_available = lambda: mps_avail
|
||||
stub.mps.is_built = lambda: mps_avail
|
||||
stub.mps.empty_cache = lambda: None
|
||||
|
||||
return stub
|
||||
|
||||
|
||||
def _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None):
|
||||
for mod_name in list(sys.modules.keys()):
|
||||
if mod_name.startswith("tts_asr") or mod_name == "torch":
|
||||
del sys.modules[mod_name]
|
||||
|
||||
torch_stub = _make_torch_stub(cuda_avail=cuda_avail, mps_avail=mps_avail)
|
||||
sys.modules["torch"] = torch_stub
|
||||
|
||||
if env_device is not None:
|
||||
os.environ["TTS_ASR_DEVICE"] = env_device
|
||||
elif "TTS_ASR_DEVICE" in os.environ:
|
||||
del os.environ["TTS_ASR_DEVICE"]
|
||||
|
||||
import tts_asr
|
||||
tts_asr._device_caps = None
|
||||
tts_asr._tts_pipeline = None
|
||||
tts_asr._asr_pipeline = None
|
||||
tts_asr._tts_last_used = 0
|
||||
tts_asr._asr_last_used = 0
|
||||
|
||||
return tts_asr
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env():
|
||||
saved = {}
|
||||
for k in ["TTS_ASR_DEVICE", "TTS_ASR_IDLE_TIMEOUT", "TTS_ASR_MODEL_SIZE",
|
||||
"TTS_ASR_QUANTIZE", "TTS_ASR_OFFLINE_MODE", "TTS_ASR_WARMUP",
|
||||
"TTS_ASR_MPS_MEMORY_LIMIT_MB"]:
|
||||
saved[k] = os.environ.get(k)
|
||||
if k in os.environ:
|
||||
del os.environ[k]
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
if v is not None:
|
||||
os.environ[k] = v
|
||||
elif k in os.environ:
|
||||
del os.environ[k]
|
||||
|
||||
|
||||
def test_get_device_cpu_env():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_device_mps_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._get_device() == "mps"
|
||||
|
||||
|
||||
def test_get_device_mps_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="mps")
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_device_cuda_available():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cuda"
|
||||
|
||||
|
||||
def test_get_device_cuda_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_device_auto_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device=None)
|
||||
assert tts._get_device() == "mps"
|
||||
|
||||
|
||||
def test_get_device_auto_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cuda"
|
||||
|
||||
|
||||
def test_get_device_auto_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_device_arg_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._device_arg() == "cuda:0"
|
||||
|
||||
|
||||
def test_device_arg_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._device_arg() == "cpu"
|
||||
|
||||
|
||||
def test_device_arg_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._device_arg() == "mps"
|
||||
|
||||
|
||||
def test_test_device_capability_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("cpu")
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
|
||||
|
||||
def test_test_device_capability_mps_not_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("mps")
|
||||
assert ok is False
|
||||
assert isinstance(err, str) and len(err) > 0
|
||||
|
||||
|
||||
def test_test_device_capability_cuda_not_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("cuda")
|
||||
assert ok is False
|
||||
assert isinstance(err, str) and len(err) > 0
|
||||
|
||||
|
||||
def test_test_device_capability_unknown_device():
|
||||
tts = _reload_tts_asr()
|
||||
ok, err = tts._test_device_capability("vulkan")
|
||||
assert ok is False
|
||||
assert isinstance(err, str)
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_timeout_zero():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "0"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time()
|
||||
tts._asr_last_used = time.time()
|
||||
tts._check_and_unload_idle_models()
|
||||
assert tts._tts_pipeline == "pipeline"
|
||||
assert tts._asr_pipeline == "pipeline"
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_unloads_when_expired():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "1"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time() - 10
|
||||
tts._asr_last_used = time.time() - 10
|
||||
# Force re-read of env var
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
tts._check_and_unload_idle_models()
|
||||
# The module reload may reset state, so we test the logic directly
|
||||
# by checking that the function runs without error
|
||||
assert True # Function executed successfully
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_keeps_when_not_expired():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "60"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time()
|
||||
tts._asr_last_used = time.time()
|
||||
tts._check_and_unload_idle_models()
|
||||
assert tts._tts_pipeline == "pipeline"
|
||||
assert tts._asr_pipeline == "pipeline"
|
||||
|
||||
|
||||
def test_get_api_key_success(monkeypatch):
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
key = tts.get_api_key("your-secret-key-here")
|
||||
assert key == "your-secret-key-here"
|
||||
|
||||
|
||||
def test_get_api_key_wrong_key_raises(monkeypatch):
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
with pytest.raises(Exception):
|
||||
tts.get_api_key("wrong-key")
|
||||
|
||||
|
||||
def test_get_api_key_missing_key_raises(monkeypatch):
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
with pytest.raises(Exception):
|
||||
tts.get_api_key("")
|
||||
@@ -1,395 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TTS/ASR模块集成测试
|
||||
测试API端点和完整流程(需要运行后端服务)
|
||||
|
||||
运行方式:
|
||||
# 方式1: 使用pytest
|
||||
pytest backend/tests/test_tts_asr_integration.py -v -s
|
||||
|
||||
# 方式2: 直接运行
|
||||
python backend/tests/test_tts_asr_integration.py
|
||||
|
||||
# 方式3: 测试特定端点
|
||||
python backend/tests/test_tts_asr_integration.py --test config
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from typing import Optional
|
||||
import httpx
|
||||
|
||||
# 配置
|
||||
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:8001')
|
||||
API_KEY = os.environ.get('API_KEY', 'your-secret-key-here')
|
||||
TEST_TIMEOUT = 120.0 # 2分钟超时
|
||||
|
||||
|
||||
class TTSASRIntegrationTest(unittest.TestCase):
|
||||
"""TTS/ASR集成测试"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""测试类初始化"""
|
||||
cls.client = httpx.Client(timeout=TEST_TIMEOUT)
|
||||
cls.headers = {'X-API-Key': API_KEY}
|
||||
|
||||
# 检查服务是否运行
|
||||
try:
|
||||
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
|
||||
if response.status_code == 200:
|
||||
cls.service_available = True
|
||||
print(f"\n✓ 服务可用: {API_BASE_URL}")
|
||||
else:
|
||||
cls.service_available = False
|
||||
print(f"\n✗ 服务返回非200状态码: {response.status_code}")
|
||||
except Exception as e:
|
||||
cls.service_available = False
|
||||
print(f"\n✗ 无法连接到服务: {e}")
|
||||
print(f" 请确保后端服务正在运行: python backend/main.py")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""测试类清理"""
|
||||
cls.client.close()
|
||||
|
||||
def setUp(self):
|
||||
"""每个测试前的检查"""
|
||||
if not self.service_available:
|
||||
self.skipTest("后端服务不可用")
|
||||
|
||||
def test_01_config_endpoint(self):
|
||||
"""测试配置端点"""
|
||||
response = self.client.get(
|
||||
f'{API_BASE_URL}/v1/tts-asr/config',
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
config = response.json()
|
||||
|
||||
# 验证配置结构
|
||||
self.assertIn('environment', config)
|
||||
self.assertIn('device', config)
|
||||
self.assertIn('model', config)
|
||||
self.assertIn('status', config)
|
||||
|
||||
# 验证环境变量配置
|
||||
env = config['environment']
|
||||
self.assertIn('TTS_ASR_DEVICE', env)
|
||||
self.assertIn('TTS_ASR_MODEL_SIZE', env)
|
||||
self.assertIn('TTS_ASR_QUANTIZE', env)
|
||||
|
||||
# 验证设备信息
|
||||
device = config['device']
|
||||
self.assertIn('current', device)
|
||||
self.assertIn('mps_available', device)
|
||||
self.assertIn('cuda_available', device)
|
||||
self.assertIn('is_apple_silicon', device)
|
||||
|
||||
# 验证模型信息
|
||||
model = config['model']
|
||||
self.assertIn('tts', model)
|
||||
self.assertIn('asr_current_size', model)
|
||||
self.assertIn('available_sizes', model)
|
||||
|
||||
print(f"\n配置信息:")
|
||||
print(f" 设备: {device['current']}")
|
||||
print(f" Apple Silicon: {device['is_apple_silicon']}")
|
||||
print(f" MPS可用: {device['mps_available']}")
|
||||
print(f" ASR模型大小: {model['asr_current_size']}")
|
||||
|
||||
def test_02_status_endpoint(self):
|
||||
"""测试状态端点"""
|
||||
response = self.client.get(
|
||||
f'{API_BASE_URL}/v1/tts-asr/status',
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
status = response.json()
|
||||
|
||||
# 验证状态结构
|
||||
self.assertIn('tts_loaded', status)
|
||||
self.assertIn('asr_loaded', status)
|
||||
self.assertIn('device', status)
|
||||
self.assertIn('offline_mode', status)
|
||||
self.assertIn('quantize_enabled', status)
|
||||
|
||||
print(f"\n状态信息:")
|
||||
print(f" TTS已加载: {status['tts_loaded']}")
|
||||
print(f" ASR已加载: {status['asr_loaded']}")
|
||||
print(f" 设备: {status['device']}")
|
||||
print(f" 离线模式: {status['offline_mode']}")
|
||||
print(f" 量化启用: {status['quantize_enabled']}")
|
||||
|
||||
def test_03_warmup_endpoint(self):
|
||||
"""测试预热端点"""
|
||||
print("\n开始模型预热(可能需要几分钟)...")
|
||||
start_time = time.time()
|
||||
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/warmup',
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
self.assertIn('tts_warmup', result)
|
||||
self.assertIn('asr_warmup', result)
|
||||
self.assertIn('device', result)
|
||||
|
||||
print(f"\n预热完成 (耗时: {elapsed:.2f}秒):")
|
||||
print(f" TTS预热: {'成功' if result['tts_warmup'] else '失败'}")
|
||||
print(f" ASR预热: {'成功' if result['asr_warmup'] else '失败'}")
|
||||
|
||||
# 警告:预热失败不一定是错误(可能模型未下载)
|
||||
if not result['tts_warmup'] or not result['asr_warmup']:
|
||||
print("\n⚠ 警告: 预热失败可能是因为模型未下载")
|
||||
print(" 请确保网络连接正常,或使用已下载的模型")
|
||||
|
||||
def test_04_tts_endpoint_basic(self):
|
||||
"""测试TTS基本功能"""
|
||||
# 简单的中文文本
|
||||
test_text = "这是一个测试"
|
||||
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/tts',
|
||||
headers=self.headers,
|
||||
json={
|
||||
'text': test_text,
|
||||
'voice': 'af_bella',
|
||||
'rate': 1.0,
|
||||
'format': 'wav'
|
||||
}
|
||||
)
|
||||
|
||||
# 检查响应
|
||||
if response.status_code == 500:
|
||||
error = response.json()
|
||||
print(f"\n⚠ TTS失败(可能是模型未加载): {error.get('detail', 'Unknown error')}")
|
||||
self.skipTest("TTS模型未加载或不可用")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
# 验证响应结构
|
||||
self.assertIn('audio_base64', result)
|
||||
self.assertIn('format', result)
|
||||
self.assertIn('duration_ms', result)
|
||||
|
||||
# 验证音频数据
|
||||
audio_data = base64.b64decode(result['audio_base64'])
|
||||
self.assertGreater(len(audio_data), 0)
|
||||
self.assertGreater(result['duration_ms'], 0)
|
||||
|
||||
print(f"\nTTS测试成功:")
|
||||
print(f" 输入文本: {test_text}")
|
||||
print(f" 音频大小: {len(audio_data)} bytes")
|
||||
print(f" 时长: {result['duration_ms']} ms")
|
||||
|
||||
def test_05_asr_endpoint_basic(self):
|
||||
"""测试ASR基本功能"""
|
||||
# 创建一个简单的静音WAV文件(1秒,16kHz,单声道)
|
||||
sample_rate = 16000
|
||||
duration = 1.0
|
||||
samples = int(sample_rate * duration)
|
||||
|
||||
# 生成静音数据
|
||||
import numpy as np
|
||||
silence = np.zeros(samples, dtype=np.int16)
|
||||
|
||||
# 创建WAV文件字节流
|
||||
import io
|
||||
import wave
|
||||
|
||||
wav_buffer = io.BytesIO()
|
||||
with wave.open(wav_buffer, 'wb') as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(silence.tobytes())
|
||||
|
||||
audio_bytes = wav_buffer.getvalue()
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode()
|
||||
|
||||
# 发送ASR请求
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/asr',
|
||||
headers=self.headers,
|
||||
json={
|
||||
'audio_base64': audio_base64,
|
||||
'language': 'zh-CN'
|
||||
}
|
||||
)
|
||||
|
||||
# 检查响应
|
||||
if response.status_code == 500:
|
||||
error = response.json()
|
||||
print(f"\n⚠ ASR失败(可能是模型未加载): {error.get('detail', 'Unknown error')}")
|
||||
self.skipTest("ASR模型未加载或不可用")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
# 验证响应结构
|
||||
self.assertIn('text', result)
|
||||
self.assertIn('language', result)
|
||||
|
||||
print(f"\nASR测试成功:")
|
||||
print(f" 识别文本: '{result['text']}'")
|
||||
print(f" 语言: {result['language']}")
|
||||
print(f" 注意: 静音音频应该返回空文本")
|
||||
|
||||
def test_06_api_key_validation(self):
|
||||
"""测试API密钥验证"""
|
||||
# 使用错误的API密钥
|
||||
wrong_headers = {'X-API-Key': 'wrong-api-key'}
|
||||
|
||||
response = self.client.get(
|
||||
f'{API_BASE_URL}/v1/tts-asr/status',
|
||||
headers=wrong_headers
|
||||
)
|
||||
|
||||
# 应该返回403 Forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
print(f"\n✓ API密钥验证正常:错误密钥被拒绝")
|
||||
|
||||
def test_07_tts_long_text(self):
|
||||
"""测试TTS长文本处理"""
|
||||
# 较长的文本
|
||||
long_text = "这是一段较长的测试文本,用于测试TTS系统对长文本的处理能力。" * 3
|
||||
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/tts',
|
||||
headers=self.headers,
|
||||
json={
|
||||
'text': long_text,
|
||||
'voice': 'af_bella',
|
||||
'rate': 1.0,
|
||||
'format': 'wav'
|
||||
},
|
||||
timeout=60.0 # 长文本需要更长超时
|
||||
)
|
||||
|
||||
if response.status_code == 500:
|
||||
self.skipTest("TTS模型未加载或不可用")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
print(f"\n长文本TTS测试成功:")
|
||||
print(f" 输入长度: {len(long_text)} 字符")
|
||||
print(f" 音频大小: {len(base64.b64decode(result['audio_base64']))} bytes")
|
||||
print(f" 时长: {result['duration_ms']} ms")
|
||||
|
||||
|
||||
class PerformanceTest(unittest.TestCase):
|
||||
"""性能测试"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.client = httpx.Client(timeout=TEST_TIMEOUT)
|
||||
cls.headers = {'X-API-Key': API_KEY}
|
||||
|
||||
try:
|
||||
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
|
||||
cls.service_available = response.status_code == 200
|
||||
except:
|
||||
cls.service_available = False
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.client.close()
|
||||
|
||||
def setUp(self):
|
||||
if not self.service_available:
|
||||
self.skipTest("后端服务不可用")
|
||||
|
||||
def test_tts_latency(self):
|
||||
"""测试TTS延迟"""
|
||||
test_text = "测试延迟"
|
||||
|
||||
latencies = []
|
||||
for i in range(3):
|
||||
start = time.time()
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/tts',
|
||||
headers=self.headers,
|
||||
json={'text': test_text}
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
|
||||
if response.status_code == 200:
|
||||
latencies.append(elapsed)
|
||||
|
||||
if latencies:
|
||||
avg_latency = sum(latencies) / len(latencies)
|
||||
print(f"\nTTS延迟测试:")
|
||||
print(f" 平均延迟: {avg_latency:.3f}秒")
|
||||
print(f" 最小延迟: {min(latencies):.3f}秒")
|
||||
print(f" 最大延迟: {max(latencies):.3f}秒")
|
||||
|
||||
|
||||
def run_tests(test_type: Optional[str] = None):
|
||||
"""运行测试"""
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
if test_type == 'config':
|
||||
suite.addTest(TTSASRIntegrationTest('test_01_config_endpoint'))
|
||||
elif test_type == 'status':
|
||||
suite.addTest(TTSASRIntegrationTest('test_02_status_endpoint'))
|
||||
elif test_type == 'warmup':
|
||||
suite.addTest(TTSASRIntegrationTest('test_03_warmup_endpoint'))
|
||||
elif test_type == 'tts':
|
||||
suite.addTest(TTSASRIntegrationTest('test_04_tts_endpoint_basic'))
|
||||
elif test_type == 'asr':
|
||||
suite.addTest(TTSASRIntegrationTest('test_05_asr_endpoint_basic'))
|
||||
elif test_type == 'perf':
|
||||
suite.addTests(loader.loadTestsFromTestCase(PerformanceTest))
|
||||
else:
|
||||
# 运行所有测试
|
||||
suite.addTests(loader.loadTestsFromTestCase(TTSASRIntegrationTest))
|
||||
suite.addTests(loader.loadTestsFromTestCase(PerformanceTest))
|
||||
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='TTS/ASR集成测试')
|
||||
parser.add_argument('--test', choices=[
|
||||
'config', 'status', 'warmup', 'tts', 'asr', 'perf'
|
||||
], help='运行特定测试')
|
||||
parser.add_argument('--url', default=API_BASE_URL, help='API基础URL')
|
||||
parser.add_argument('--key', default=API_KEY, help='API密钥')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 更新配置
|
||||
API_BASE_URL = args.url
|
||||
API_KEY = args.key
|
||||
|
||||
print("=" * 70)
|
||||
print("TTS/ASR 集成测试")
|
||||
print("=" * 70)
|
||||
print(f"API URL: {API_BASE_URL}")
|
||||
print(f"测试类型: {args.test or '全部'}")
|
||||
print("=" * 70)
|
||||
|
||||
success = run_tests(args.test)
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,377 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TTS/ASR模块单元测试
|
||||
测试核心功能,无需实际运行模型
|
||||
|
||||
运行方式:
|
||||
pytest backend/tests/test_tts_asr_unit.py -v
|
||||
python backend/tests/test_tts_asr_unit.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
import tempfile
|
||||
import numpy as np
|
||||
|
||||
# 确保可以导入backend和tts_asr模块
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
|
||||
class TestAppleSiliconDetection(unittest.TestCase):
|
||||
"""测试Apple Silicon检测功能"""
|
||||
|
||||
def test_is_apple_silicon_on_darwin_arm64(self):
|
||||
"""测试在Darwin/arm64环境下检测Apple Silicon"""
|
||||
with patch('platform.system', return_value='Darwin'), \
|
||||
patch('platform.machine', return_value='arm64'):
|
||||
# 需要重新导入以应用mock
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _is_apple_silicon
|
||||
self.assertTrue(_is_apple_silicon())
|
||||
|
||||
def test_is_apple_silicon_on_windows(self):
|
||||
"""测试在Windows环境下不是Apple Silicon"""
|
||||
with patch('platform.system', return_value='Windows'), \
|
||||
patch('platform.machine', return_value='AMD64'):
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _is_apple_silicon
|
||||
self.assertFalse(_is_apple_silicon())
|
||||
|
||||
def test_is_apple_silicon_on_linux(self):
|
||||
"""测试在Linux环境下不是Apple Silicon"""
|
||||
with patch('platform.system', return_value='Linux'), \
|
||||
patch('platform.machine', return_value='x86_64'):
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _is_apple_silicon
|
||||
self.assertFalse(_is_apple_silicon())
|
||||
|
||||
|
||||
class TestEnvironmentVariables(unittest.TestCase):
|
||||
"""测试环境变量解析"""
|
||||
|
||||
def test_default_environment_values(self):
|
||||
"""测试默认环境变量值"""
|
||||
# 清除可能存在的环境变量
|
||||
env_vars = [
|
||||
'TTS_ASR_DEVICE', 'TTS_ASR_MODEL_SIZE', 'TTS_ASR_QUANTIZE',
|
||||
'TTS_ASR_OFFLINE_MODE', 'TTS_ASR_WARMUP', 'TTS_ASR_WARMUP_TIMEOUT',
|
||||
'TTS_ASR_IDLE_TIMEOUT', 'TTS_ASR_MPS_MEMORY_LIMIT_MB'
|
||||
]
|
||||
|
||||
# 保存原始值
|
||||
original_values = {}
|
||||
for var in env_vars:
|
||||
original_values[var] = os.environ.get(var)
|
||||
if var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
try:
|
||||
# 重新加载模块以应用默认值
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE, TTS_ASR_WARMUP, TTS_ASR_WARMUP_TIMEOUT,
|
||||
TTS_ASR_IDLE_TIMEOUT, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
)
|
||||
|
||||
self.assertEqual(TTS_ASR_DEVICE, 'auto')
|
||||
self.assertEqual(TTS_ASR_MODEL_SIZE, 'auto')
|
||||
self.assertFalse(TTS_ASR_QUANTIZE)
|
||||
self.assertFalse(TTS_ASR_OFFLINE_MODE)
|
||||
self.assertTrue(TTS_ASR_WARMUP)
|
||||
self.assertEqual(TTS_ASR_WARMUP_TIMEOUT, 120)
|
||||
self.assertEqual(TTS_ASR_IDLE_TIMEOUT, 0)
|
||||
self.assertEqual(TTS_ASR_MPS_MEMORY_LIMIT_MB, 8192)
|
||||
finally:
|
||||
# 恢复原始值
|
||||
for var, value in original_values.items():
|
||||
if value is not None:
|
||||
os.environ[var] = value
|
||||
elif var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
def test_custom_environment_values(self):
|
||||
"""测试自定义环境变量值"""
|
||||
os.environ['TTS_ASR_DEVICE'] = 'cpu'
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'small'
|
||||
os.environ['TTS_ASR_QUANTIZE'] = 'true'
|
||||
os.environ['TTS_ASR_OFFLINE_MODE'] = 'true'
|
||||
|
||||
try:
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE
|
||||
)
|
||||
|
||||
self.assertEqual(TTS_ASR_DEVICE, 'cpu')
|
||||
self.assertEqual(TTS_ASR_MODEL_SIZE, 'small')
|
||||
self.assertTrue(TTS_ASR_QUANTIZE)
|
||||
self.assertTrue(TTS_ASR_OFFLINE_MODE)
|
||||
finally:
|
||||
# 清理环境变量
|
||||
for var in ['TTS_ASR_DEVICE', 'TTS_ASR_MODEL_SIZE',
|
||||
'TTS_ASR_QUANTIZE', 'TTS_ASR_OFFLINE_MODE']:
|
||||
if var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
|
||||
class TestModelSizeSelection(unittest.TestCase):
|
||||
"""测试模型大小选择逻辑"""
|
||||
|
||||
def test_whisper_model_sizes_mapping(self):
|
||||
"""测试Whisper模型大小映射"""
|
||||
from backend.tts_asr import WHISPER_MODEL_SIZES
|
||||
|
||||
expected_sizes = ['tiny', 'base', 'small', 'medium', 'large', 'turbo']
|
||||
self.assertEqual(list(WHISPER_MODEL_SIZES.keys()), expected_sizes)
|
||||
|
||||
# 验证模型ID格式
|
||||
for size, model_id in WHISPER_MODEL_SIZES.items():
|
||||
self.assertTrue(model_id.startswith('openai/whisper'))
|
||||
self.assertIn(size, model_id)
|
||||
|
||||
def test_recommended_model_size_explicit(self):
|
||||
"""测试显式指定的模型大小"""
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'medium'
|
||||
|
||||
try:
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _get_recommended_model_size
|
||||
size = _get_recommended_model_size()
|
||||
self.assertEqual(size, 'medium')
|
||||
finally:
|
||||
if 'TTS_ASR_MODEL_SIZE' in os.environ:
|
||||
del os.environ['TTS_ASR_MODEL_SIZE']
|
||||
|
||||
def test_invalid_model_size_falls_back(self):
|
||||
"""测试无效模型大小回退到自动选择"""
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'invalid_size'
|
||||
|
||||
try:
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _get_recommended_model_size, WHISPER_MODEL_SIZES
|
||||
# 应该回退到推荐大小而不崩溃
|
||||
size = _get_recommended_model_size()
|
||||
self.assertIn(size, WHISPER_MODEL_SIZES.keys())
|
||||
finally:
|
||||
if 'TTS_ASR_MODEL_SIZE' in os.environ:
|
||||
del os.environ['TTS_ASR_MODEL_SIZE']
|
||||
|
||||
|
||||
class TestAudioValidation(unittest.TestCase):
|
||||
"""测试音频验证功能"""
|
||||
|
||||
def test_validate_empty_audio(self):
|
||||
"""测试空音频数据验证"""
|
||||
from backend.tts_asr import _validate_audio_data
|
||||
|
||||
self.assertFalse(_validate_audio_data(b''))
|
||||
self.assertFalse(_validate_audio_data(b'short'))
|
||||
|
||||
def test_validate_valid_wav_header(self):
|
||||
"""测试有效WAV头部验证"""
|
||||
from backend.tts_asr import _validate_audio_data
|
||||
|
||||
# 创建一个最小的有效WAV头部(44字节)
|
||||
valid_wav_header = b'RIFF' + b'\x00' * 40
|
||||
self.assertTrue(_validate_audio_data(valid_wav_header))
|
||||
|
||||
def test_validate_invalid_audio(self):
|
||||
"""测试无效音频数据验证"""
|
||||
from backend.tts_asr import _validate_audio_data
|
||||
|
||||
# 小于最小WAV头部大小
|
||||
invalid_audio = b'RIFF' + b'\x00' * 30
|
||||
self.assertFalse(_validate_audio_data(invalid_audio))
|
||||
|
||||
|
||||
class TestAudioResampling(unittest.TestCase):
|
||||
"""测试音频重采样功能"""
|
||||
|
||||
def test_resample_same_rate(self):
|
||||
"""测试相同采样率(无需重采样)"""
|
||||
from backend.tts_asr import _resample_audio_robust
|
||||
|
||||
audio = np.random.randn(16000).astype(np.float32)
|
||||
resampled = _resample_audio_robust(audio, 16000, 16000)
|
||||
|
||||
# 应该返回原始音频
|
||||
np.testing.assert_array_almost_equal(audio, resampled)
|
||||
|
||||
def test_resample_different_rate(self):
|
||||
"""测试不同采样率重采样"""
|
||||
from backend.tts_asr import _resample_audio_robust
|
||||
|
||||
# 创建1秒的音频,从16kHz重采样到48kHz
|
||||
audio_16k = np.sin(np.linspace(0, 2*np.pi, 16000)).astype(np.float32)
|
||||
audio_48k = _resample_audio_robust(audio_16k, 16000, 48000)
|
||||
|
||||
# 检查长度变化
|
||||
expected_length = int(len(audio_16k) * 48000 / 16000)
|
||||
self.assertEqual(len(audio_48k), expected_length)
|
||||
|
||||
def test_resample_downsample(self):
|
||||
"""测试下采样"""
|
||||
from backend.tts_asr import _resample_audio_robust
|
||||
|
||||
# 从48kHz下采样到16kHz
|
||||
audio_48k = np.sin(np.linspace(0, 2*np.pi, 48000)).astype(np.float32)
|
||||
audio_16k = _resample_audio_robust(audio_48k, 48000, 16000)
|
||||
|
||||
expected_length = int(len(audio_48k) * 16000 / 48000)
|
||||
self.assertEqual(len(audio_16k), expected_length)
|
||||
|
||||
|
||||
class TestDeviceCapabilities(unittest.TestCase):
|
||||
"""测试设备能力检测"""
|
||||
|
||||
def test_device_capabilities_dataclass(self):
|
||||
"""测试DeviceCapabilities数据类"""
|
||||
from backend.tts_asr import DeviceCapabilities
|
||||
|
||||
caps = DeviceCapabilities(
|
||||
device='cpu',
|
||||
mps_available=False,
|
||||
cuda_available=False
|
||||
)
|
||||
|
||||
self.assertEqual(caps.device, 'cpu')
|
||||
self.assertFalse(caps.mps_available)
|
||||
self.assertFalse(caps.cuda_available)
|
||||
self.assertEqual(caps.recommended_model_size, 'large') # 默认值
|
||||
|
||||
def test_device_capabilities_with_mps(self):
|
||||
"""测试MPS设备能力"""
|
||||
from backend.tts_asr import DeviceCapabilities
|
||||
|
||||
caps = DeviceCapabilities(
|
||||
device='mps',
|
||||
mps_available=True,
|
||||
mps_memory_limit_mb=8192,
|
||||
recommended_model_size='small'
|
||||
)
|
||||
|
||||
self.assertEqual(caps.device, 'mps')
|
||||
self.assertTrue(caps.mps_available)
|
||||
self.assertEqual(caps.mps_memory_limit_mb, 8192)
|
||||
self.assertEqual(caps.recommended_model_size, 'small')
|
||||
|
||||
|
||||
class TestModelCacheCheck(unittest.TestCase):
|
||||
"""测试模型缓存检查"""
|
||||
|
||||
@patch('backend.tts_asr.TTS_ASR_OFFLINE_MODE', False)
|
||||
def test_cache_check_non_offline_mode(self):
|
||||
"""测试非离线模式下缓存检查总是返回True"""
|
||||
from backend.tts_asr import _check_model_cached
|
||||
|
||||
# 非离线模式应该总是返回True
|
||||
result = _check_model_cached('any/model')
|
||||
self.assertTrue(result)
|
||||
|
||||
@patch('backend.tts_asr.TTS_ASR_OFFLINE_MODE', True)
|
||||
def test_cache_check_offline_mode_missing(self):
|
||||
"""测试离线模式下缺失模型的处理"""
|
||||
try:
|
||||
import huggingface_hub # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest("huggingface_hub not installed")
|
||||
from backend.tts_asr import _check_model_cached
|
||||
|
||||
# 模拟缓存路径
|
||||
with patch('huggingface_hub.constants.HF_HUB_CACHE', '/nonexistent/path'):
|
||||
result = _check_model_cached('nonexistent/model')
|
||||
# 应该返回False(模型未缓存)
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
class TestRequestResponseModels(unittest.TestCase):
|
||||
"""测试请求/响应数据模型"""
|
||||
|
||||
def test_tts_request_model(self):
|
||||
"""测试TTS请求模型"""
|
||||
from backend.tts_asr import TTSRequest
|
||||
|
||||
req = TTSRequest(text="测试文本")
|
||||
self.assertEqual(req.text, "测试文本")
|
||||
self.assertEqual(req.voice, "af_bella") # 默认值
|
||||
self.assertEqual(req.rate, 1.0) # 默认值
|
||||
self.assertEqual(req.format, "wav") # 默认值
|
||||
|
||||
def test_asr_request_model(self):
|
||||
"""测试ASR请求模型"""
|
||||
from backend.tts_asr import ASRRequest
|
||||
|
||||
req = ASRRequest(audio_base64="dGVzdA==")
|
||||
self.assertEqual(req.audio_base64, "dGVzdA==")
|
||||
self.assertEqual(req.language, "zh-CN") # 默认值
|
||||
|
||||
def test_model_status_model(self):
|
||||
"""测试ModelStatus模型"""
|
||||
from backend.tts_asr import ModelStatus
|
||||
|
||||
status = ModelStatus(
|
||||
tts_loaded=False,
|
||||
asr_loaded=False,
|
||||
device='cpu'
|
||||
)
|
||||
|
||||
self.assertFalse(status.tts_loaded)
|
||||
self.assertFalse(status.asr_loaded)
|
||||
self.assertEqual(status.device, 'cpu')
|
||||
self.assertIsNone(status.tts_last_used)
|
||||
self.assertIsNone(status.asr_last_used)
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""运行所有测试"""
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
# 添加所有测试类
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestAppleSiliconDetection))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestEnvironmentVariables))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestModelSizeSelection))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestAudioValidation))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestAudioResampling))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestDeviceCapabilities))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestModelCacheCheck))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestRequestResponseModels))
|
||||
|
||||
# 运行测试
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 直接运行时执行测试
|
||||
success = run_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,56 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 确保能找到backend模块
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
|
||||
from backend.tts_asr import _tts_sync_with_retry, _load_asr_pipeline_with_retry, _get_asr_pipeline
|
||||
|
||||
async def verify_tts_asr_cross():
|
||||
print("====================================")
|
||||
print(" 开始严格交叉验证: TTS 生成 -> ASR 解析")
|
||||
print("====================================")
|
||||
|
||||
test_text = "苹果设备支持离线大模型运算"
|
||||
print(f"\n[1] 正在调用 TTS 引擎 (suno/bark-small)...")
|
||||
print(f"目标文本: '{test_text}'")
|
||||
|
||||
try:
|
||||
# TTS生成
|
||||
audio_bytes, sr = await _tts_sync_with_retry(test_text, rate=1.0)
|
||||
print(f"-> TTS 成功生成音频数据,大小: {len(audio_bytes)} Bytes, 采样率: {sr}Hz")
|
||||
except Exception as e:
|
||||
print(f"-> TTS 失败: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n[2] 正在调用 ASR 引擎 (Whisper)...")
|
||||
try:
|
||||
loaded = await _load_asr_pipeline_with_retry()
|
||||
if not loaded:
|
||||
print("-> ASR 核心加载失败!")
|
||||
sys.exit(1)
|
||||
|
||||
print("-> ASR 加载成功,开始解析音频...")
|
||||
|
||||
# 将生成的wav bytes传递给ASR进行语音识别
|
||||
asr_pipeline = _get_asr_pipeline()
|
||||
result = asr_pipeline(audio_bytes, generate_kwargs={"task": "transcribe"})
|
||||
recognized_text = result.get('text', '')
|
||||
print(f"-> ASR 识别结果: '{recognized_text.strip()}'")
|
||||
|
||||
if len(recognized_text.strip()) > 0:
|
||||
print("\n结论: ✅ 验证成功!TTS和ASR模块功能链路闭环完成。")
|
||||
else:
|
||||
print("\n结论: ❌ ASR输出为空字符,闭环失败。")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"-> ASR 分析阶段失败: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(verify_tts_asr_cross())
|
||||
@@ -1,199 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# New TTS model import
|
||||
try:
|
||||
from qwen_tts import Qwen3TTSModel # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
Qwen3TTSModel = None # type: ignore
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Global TTS model instance
|
||||
_tts_model: Optional["Qwen3TTSModel"] = None
|
||||
|
||||
|
||||
def _get_device_map() -> str:
|
||||
"""设备检测逻辑:优先 CUDA,其次 MPS,最后 CPU"""
|
||||
if torch.cuda.is_available():
|
||||
return "cuda:0"
|
||||
try:
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
async def _warmup_tts():
|
||||
"""预热 TTS 模型"""
|
||||
await asyncio.to_thread(_load_tts_model_with_retry)
|
||||
|
||||
|
||||
async def _warmup_all():
|
||||
"""预热所有模型(TTS 和 ASR)"""
|
||||
logger.info("[Warmup] 开始预热 TTS 模型...")
|
||||
await _warmup_tts()
|
||||
logger.info("[Warmup] TTS 模型预热完成")
|
||||
|
||||
|
||||
def _load_tts_model_with_retry(max_retries: int = 3) -> "Qwen3TTSModel":
|
||||
"""加载 TTS 模型,支持多个镜像源"""
|
||||
global _tts_model
|
||||
if _tts_model is not None:
|
||||
return _tts_model
|
||||
if Qwen3TTSModel is None:
|
||||
raise RuntimeError("qwen_tts 库未安装,无法加载 TTS 模型")
|
||||
|
||||
candidates = [
|
||||
"Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
|
||||
"ModelScope/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
|
||||
]
|
||||
device_map = _get_device_map()
|
||||
last_err = None
|
||||
for i, model_id in enumerate(candidates, start=1):
|
||||
try:
|
||||
_tts_model = Qwen3TTSModel.from_pretrained(
|
||||
model_id,
|
||||
device_map=device_map,
|
||||
dtype=torch.float16,
|
||||
attn_implementation="flash_attention_2",
|
||||
)
|
||||
logger.info("Loaded TTS model from %s", model_id)
|
||||
return _tts_model
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load TTS model from %s: %s", model_id, e)
|
||||
last_err = e
|
||||
if i >= max_retries:
|
||||
break
|
||||
raise RuntimeError(f"Unable to load TTS model from sources: {candidates}") from last_err
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
instruct: str = ""
|
||||
speaker: str = "Vivian"
|
||||
format: str = "wav"
|
||||
|
||||
|
||||
class TTSResponse(BaseModel):
|
||||
audio_base64: str
|
||||
format: str
|
||||
duration_ms: int
|
||||
|
||||
|
||||
class ModelStatus(BaseModel):
|
||||
tts_loaded: bool
|
||||
asr_loaded: bool = False
|
||||
device: str
|
||||
tts_last_used: Optional[float] = None
|
||||
asr_last_used: Optional[float] = None
|
||||
|
||||
|
||||
def _ensure_model() -> "Qwen3TTSModel":
|
||||
"""确保模型已加载"""
|
||||
global _tts_model
|
||||
if _tts_model is None:
|
||||
_tts_model = _load_tts_model_with_retry()
|
||||
return _tts_model
|
||||
|
||||
|
||||
@router.get("/status", response_model=ModelStatus)
|
||||
async def get_status():
|
||||
"""获取模型状态"""
|
||||
return ModelStatus(
|
||||
tts_loaded=_tts_model is not None,
|
||||
asr_loaded=False,
|
||||
device=_get_device_map(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config():
|
||||
"""获取配置信息"""
|
||||
return {
|
||||
"model": {
|
||||
"tts": "Qwen3-TTS-12Hz-1.7B-VoiceDesign",
|
||||
"asr": None,
|
||||
},
|
||||
"device": _get_device_map(),
|
||||
"status": {
|
||||
"tts_loaded": _tts_model is not None,
|
||||
"asr_loaded": False,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/warmup")
|
||||
async def warmup_models():
|
||||
"""手动触发模型预热"""
|
||||
await _warmup_tts()
|
||||
return {
|
||||
"tts_warmup": _tts_model is not None,
|
||||
"device": _get_device_map(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tts", response_model=TTSResponse)
|
||||
async def tts_endpoint(req: TTSRequest):
|
||||
"""TTS 文字转语音端点"""
|
||||
try:
|
||||
model = _ensure_model()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
text = req.text
|
||||
instruct = req.instruct or ""
|
||||
speaker = req.speaker or "Vivian"
|
||||
|
||||
try:
|
||||
wavs_sr = model.generate_custom_voice(
|
||||
text=text,
|
||||
language="Chinese",
|
||||
speaker=speaker,
|
||||
instruct=instruct,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("TTS 推理失败")
|
||||
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}")
|
||||
|
||||
# Normalize output
|
||||
if isinstance(wavs_sr, tuple) and len(wavs_sr) == 2:
|
||||
wav_data, sr = wavs_sr
|
||||
else:
|
||||
wav_data, sr = wavs_sr[0], wavs_sr[1] # type: ignore
|
||||
|
||||
# 编码 WAV 到内存
|
||||
try:
|
||||
import soundfile as sf
|
||||
bio = BytesIO()
|
||||
sf.write(bio, wav_data, sr, format="WAV")
|
||||
audio_bytes = bio.getvalue()
|
||||
except Exception as e:
|
||||
logger.exception("音频编码失败")
|
||||
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}")
|
||||
|
||||
# 计算时长(毫秒)
|
||||
duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0
|
||||
|
||||
# 返回 JSON 格式,包含 base64 编码的音频
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
|
||||
return TTSResponse(
|
||||
audio_base64=audio_base64,
|
||||
format="wav",
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
|
||||
def register_tts_asr_routes(app):
|
||||
"""注册 TTS/ASR 路由到 FastAPI 应用"""
|
||||
app.include_router(router, prefix="/v1/tts-asr")
|
||||
Generated
+3
-5769
File diff suppressed because it is too large
Load Diff
@@ -11,22 +11,13 @@
|
||||
"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",
|
||||
"@univerjs/preset-docs-core": "^0.20.0",
|
||||
"@univerjs/preset-sheets-core": "^0.20.0",
|
||||
"@univerjs/presets": "^0.20.0",
|
||||
"@univerjs/slides": "^0.20.0",
|
||||
"@univerjs/slides-ui": "^0.20.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",
|
||||
@@ -38,7 +29,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/language-server": "^3.2.6",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = backend/tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -v --tb=short --cov=backend.main --cov=backend.llm --cov=backend.prompt --cov=backend.geoip --cov=backend.prompts --cov=backend.tts_asr --cov-report=term-missing --cov-report=html --cov-fail-under=90
|
||||
|
||||
[coverage:run]
|
||||
omit =
|
||||
backend/tests/*
|
||||
backend/test_*.py
|
||||
|
||||
[coverage:report]
|
||||
fail_under = 90
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
raise NotImplementedError
|
||||
+21
-3
@@ -1,10 +1,19 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { useSettingsStore } from './stores/settings'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
|
||||
const MilkdownEditor = defineAsyncComponent(() => import('./components/MilkdownEditor.vue'))
|
||||
|
||||
const markdown = ref('')
|
||||
const emit = defineEmits(['update:markdown'])
|
||||
const settings = useSettingsStore()
|
||||
|
||||
function onChange(markdownValue) {
|
||||
markdown.value = markdownValue
|
||||
emit('update:markdown', markdownValue)
|
||||
}
|
||||
|
||||
const appStyle = computed(() => {
|
||||
const style = {}
|
||||
if (settings.backgroundType === 'warm') {
|
||||
@@ -43,7 +52,9 @@ const backgroundStyle = computed(() => {
|
||||
<div class="app-shell" :style="appStyle">
|
||||
<div v-if="settings.backgroundType === 'image'" class="app-bg-layer" :style="backgroundStyle"></div>
|
||||
|
||||
<router-view />
|
||||
<div class="editor-container">
|
||||
<MilkdownEditor @update:markdown="onChange" />
|
||||
</div>
|
||||
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
@@ -53,10 +64,17 @@ const backgroundStyle = computed(() => {
|
||||
.app-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
transition: background 0.3s, color 0.3s;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
node: { type: Object, default: null },
|
||||
canPaste: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'rename', 'delete', 'copy', 'cut', 'paste', 'new-file', 'new-folder'])
|
||||
|
||||
const menuRef = ref(null)
|
||||
const menuStyle = ref({})
|
||||
|
||||
function handleClickOutside(event) {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target)) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, async (val) => {
|
||||
if (val) {
|
||||
await nextTick()
|
||||
const menu = menuRef.value
|
||||
if (menu) {
|
||||
const rect = menu.getBoundingClientRect()
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
menuStyle.value = {
|
||||
left: props.x + rect.width > vw ? vw - rect.width - 8 : props.x,
|
||||
top: props.y + rect.height > vh ? vh - rect.height - 8 : props.y
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="context-menu"
|
||||
:style="{ left: `${menuStyle.left || x}px`, top: `${menuStyle.top || y}px` }"
|
||||
>
|
||||
<template v-if="node">
|
||||
<button v-if="node.type === 'folder'" class="context-menu-item" @click="emit('new-file', node.id)">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H3.75zM3 1.75C3 .784 3.784 0 4.75 0h5.339c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113 16H4.75A1.75 1.75 0 013 14.25V1.75z"/><path d="M8.5 4V1.5H10a.5.5 0 01.5.5v1.5a.5.5 0 01-.5.5H9a.5.5 0 01-.5-.5zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
|
||||
新建文件
|
||||
</button>
|
||||
<button v-if="node.type === 'folder'" class="context-menu-item" @click="emit('new-folder', node.id)">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M.5 2.5A1.5 1.5 0 012 1h3.5a.5.5 0 01.354.146l1.5 1.5a.5.5 0 00.354.146H13a1.5 1.5 0 011.5 1.5v7.5a1.5 1.5 0 01-1.5 1.5H2a1.5 1.5 0 01-1.5-1.5v-7.5zM6 2v1.5h4.5V2H6zm-2 5a.5.5 0 01.5-.5h5a.5.5 0 010 1h-5a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h5a.5.5 0 000-1h-5z"/></svg>
|
||||
新建文件夹
|
||||
</button>
|
||||
<div v-if="node.type === 'folder'" class="context-menu-divider"></div>
|
||||
<button class="context-menu-item" @click="emit('copy', node.id)">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25zM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25z"/></svg>
|
||||
复制
|
||||
</button>
|
||||
<button class="context-menu-item" @click="emit('cut', node.id)">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.455.752a1.523 1.523 0 012.173.043l2.84 3.076a3.052 3.052 0 01.524.669h3.258a.75.75 0 01.643 1.137l-2.55 4.25a.75.75 0 01-1.286-.784L11.5 6.75h-2.5a3.052 3.052 0 01-.524.669L5.632 10.49a1.523 1.523 0 01-2.173.043l-.93-.93a.75.75 0 111.06-1.06l.93.93 2.845-3.076a1.55 1.55 0 000-2.134L4.52 1.683l-.93.93a.75.75 0 01-1.06-1.06l.93-.93.995.995z"/></svg>
|
||||
剪切
|
||||
</button>
|
||||
<button v-if="canPaste" class="context-menu-item" @click="emit('paste', node.type === 'folder' ? node.id : null)">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v.59c0 .396.316.717.707.717h5.586c.39 0 .707-.32.707-.716v-.591a.25.25 0 00-.25-.25H4.75zm6.543-.75a1.75 1.75 0 011.75 1.75v.59c0 .396-.107.767-.293 1.086l1.293 1.293a.75.75 0 010 1.061l-1.293 1.293c.186.32.293.69.293 1.087v.59a1.75 1.75 0 01-1.75 1.75H4.75a1.75 1.75 0 01-1.75-1.75v-.59c0-.396.107-.767.293-1.087L2 5.53a.75.75 0 010-1.06l1.293-1.294A2.048 2.048 0 013 2.09v-.59A1.75 1.75 0 014.75 0h6.543zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
|
||||
粘贴
|
||||
</button>
|
||||
<div class="context-menu-divider"></div>
|
||||
<button class="context-menu-item danger" @click="emit('delete', node.id)">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.41 15h5.18a1.75 1.75 0 001.746-1.578l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.227H5.41a.25.25 0 01-.249-.227l-.66-6.6z"/></svg>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="context-menu-item" @click="emit('new-file', null)">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H3.75zM3 1.75C3 .784 3.784 0 4.75 0h5.339c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113 16H4.75A1.75 1.75 0 013 14.25V1.75z"/><path d="M8.5 4V1.5H10a.5.5 0 01.5.5v1.5a.5.5 0 01-.5.5H9a.5.5 0 01-.5-.5zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
|
||||
新建文件
|
||||
</button>
|
||||
<button class="context-menu-item" @click="emit('new-folder', null)">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M.5 2.5A1.5 1.5 0 012 1h3.5a.5.5 0 01.354.146l1.5 1.5a.5.5 0 00.354.146H13a1.5 1.5 0 011.5 1.5v7.5a1.5 1.5 0 01-1.5 1.5H2a1.5 1.5 0 01-1.5-1.5v-7.5zM6 2v1.5h4.5V2H6zm-2 5a.5.5 0 01.5-.5h5a.5.5 0 010 1h-5a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h5a.5.5 0 000-1h-5z"/></svg>
|
||||
新建文件夹
|
||||
</button>
|
||||
<button v-if="canPaste" class="context-menu-item" @click="emit('paste', null)">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v.59c0 .396.316.717.707.717h5.586c.39 0 .707-.32.707-.716v-.591a.25.25 0 00-.25-.25H4.75zm6.543-.75a1.75 1.75 0 011.75 1.75v.59c0 .396-.107.767-.293 1.086l1.293 1.293a.75.75 0 010 1.061l-1.293 1.293c.186.32.293.69.293 1.087v.59a1.75 1.75 0 01-1.75 1.75H4.75a1.75 1.75 0 01-1.75-1.75v-.59c0-.396.107-.767.293-1.087L2 5.53a.75.75 0 010-1.06l1.293-1.294A2.048 2.048 0 013 2.09v-.59A1.75 1.75 0 014.75 0h6.543zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
|
||||
粘贴
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 100000;
|
||||
min-width: 200px;
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
animation: contextMenuIn 0.12s ease-out;
|
||||
}
|
||||
|
||||
@keyframes contextMenuIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--app-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--focus-ring);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.context-menu-item.danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.context-menu-item.danger:hover {
|
||||
background: var(--danger-text);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--panel-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
+163
-258
@@ -1,345 +1,250 @@
|
||||
<template>
|
||||
<section class="doc-card" :class="{ 'is-collapsed': collapsedState }">
|
||||
<header class="doc-card__header">
|
||||
<div class="doc-card__badge">{{ typeLabel }}</div>
|
||||
<div class="doc-card__meta">
|
||||
<div class="doc-card__name">{{ docName }}</div>
|
||||
<div class="doc-card__time">{{ displayTime }}</div>
|
||||
<div class="doc-block-crepe" :class="{ collapsed: isCollapsed }">
|
||||
<div class="doc-header">
|
||||
<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"/>
|
||||
</svg>
|
||||
<svg v-else-if="docType === 'doc'" 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"/>
|
||||
</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>
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="doc-card__actions">
|
||||
<button type="button" class="doc-card__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">
|
||||
<div class="doc-name">{{ docName }}</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">
|
||||
<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="doc-card__btn doc-card__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>
|
||||
</header>
|
||||
<div v-show="!collapsedState" class="doc-card__body">
|
||||
<div ref="editorRoot" class="doc-card__editor"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="doc-editor" v-show="!isCollapsed">
|
||||
<div ref="editorRoot" class="inner-crepe"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { replaceAll } from '@milkdown/kit/utils'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { copilotPlugin, copilotConfigCtx, setCopilotEnabled } from '../plugins/copilotPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
|
||||
const props = defineProps({
|
||||
docType: { type: String, default: 'txt' },
|
||||
docType: { type: String, default: 'text' },
|
||||
docName: { type: String, default: 'document.txt' },
|
||||
uploadTime: { 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 },
|
||||
initialContent: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:content', 'delete'])
|
||||
|
||||
const editorRoot = ref(null)
|
||||
const collapsedState = ref(Boolean(props.collapsed))
|
||||
const currentContent = ref(props.content || '')
|
||||
const isCollapsed = ref(false)
|
||||
let crepe = null
|
||||
let syncTimer = null
|
||||
let syncingExternal = 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 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 })
|
||||
})
|
||||
let internalChangeTimer = null
|
||||
|
||||
const toggleCollapse = () => {
|
||||
collapsedState.value = !collapsedState.value
|
||||
props.onUpdateCollapsed?.(collapsedState.value)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
const syncContent = () => {
|
||||
if (!crepe) return
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
syncTimer = setTimeout(async () => {
|
||||
if (!crepe || syncingExternal) return
|
||||
if (internalChangeTimer) clearTimeout(internalChangeTimer)
|
||||
internalChangeTimer = setTimeout(async () => {
|
||||
const markdown = await crepe.getMarkdown()
|
||||
currentContent.value = markdown
|
||||
props.onUpdateContent?.(markdown)
|
||||
emit('update:content', markdown)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const syncExternalContent = async (nextValue) => {
|
||||
const value = nextValue || ''
|
||||
if (!crepe) {
|
||||
currentContent.value = value
|
||||
return
|
||||
}
|
||||
if (value === currentContent.value) return
|
||||
syncingExternal = true
|
||||
try {
|
||||
crepe.editor.action(replaceAll(value))
|
||||
currentContent.value = value
|
||||
} finally {
|
||||
syncingExternal = 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.content || '',
|
||||
defaultValue: props.initialContent || '',
|
||||
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: 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,
|
||||
fetchSuggestion,
|
||||
debounceMs: 1000
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (syncTimer) {
|
||||
clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
}
|
||||
if (crepe) {
|
||||
crepe.editor.action((ctx) => {
|
||||
watch(() => props.initialContent, (newVal) => {
|
||||
if (crepe && newVal !== undefined) {
|
||||
crepe.editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearGhostSuggestion(view)
|
||||
const currentPos = view.state.selection.from
|
||||
view.dispatch(view.state.tr.insertText(newVal))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (internalChangeTimer) clearTimeout(internalChangeTimer)
|
||||
if (crepe) {
|
||||
crepe.destroy()
|
||||
crepe = null
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
getContent: () => crepe ? crepe.getMarkdown() : Promise.resolve(''),
|
||||
getEditor: () => crepe
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 8px 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.12);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-card {
|
||||
background: rgba(26, 30, 39, 0.8);
|
||||
border-color: rgba(96, 165, 250, 0.15);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.doc-card__header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-card__header {
|
||||
background: rgba(26, 30, 39, 0.8);
|
||||
border-bottom-color: rgba(96, 165, 250, 0.15);
|
||||
}
|
||||
|
||||
.doc-card__badge {
|
||||
min-width: 48px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.doc-card__meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-card__name {
|
||||
color: #1e293b;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-card__name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.doc-card__time {
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-card__time {
|
||||
color: #aeb6c5;
|
||||
}
|
||||
|
||||
.doc-card__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.doc-card__btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.12);
|
||||
.doc-block-crepe {
|
||||
margin: 12px 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: #64748b;
|
||||
overflow: hidden;
|
||||
background: var(--crepe-color-surface-low);
|
||||
border: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.doc-block-crepe.collapsed .doc-editor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--crepe-color-surface);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--crepe-color-primary);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-card__btn {
|
||||
background: rgba(34, 40, 52, 0.5);
|
||||
border-color: rgba(96, 165, 250, 0.15);
|
||||
color: #aeb6c5;
|
||||
}
|
||||
|
||||
.doc-card__btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.doc-card__btn--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.doc-card__body {
|
||||
padding: 8px 10px;
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-card__body {
|
||||
background: rgba(18, 22, 30, 0.8);
|
||||
}
|
||||
|
||||
.doc-card__editor {
|
||||
min-height: 48px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.08);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
.doc-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--crepe-color-on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-card__editor {
|
||||
background: rgba(26, 30, 39, 0.8);
|
||||
border-color: rgba(96, 165, 250, 0.12);
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.milkdown) {
|
||||
.action-btn {
|
||||
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;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--crepe-color-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.doc-editor {
|
||||
padding: 8px;
|
||||
background: var(--crepe-color-surface-low);
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.inner-crepe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.inner-crepe :deep(.milkdown) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.milkdown__main),
|
||||
.doc-card__editor :deep(.milkdown__editor) {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.ProseMirror) {
|
||||
min-height: 0;
|
||||
padding: 10px 12px 12px !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.ProseMirror > *:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.ProseMirror p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.milkdown__toolbar),
|
||||
.doc-card__editor :deep(.milkdown__menu),
|
||||
.doc-card__editor :deep(.milkdown__statusbar),
|
||||
.doc-card__editor :deep(.milkdown-slate-toolbar),
|
||||
.doc-card__editor :deep(.milkdown-bubble-menu) {
|
||||
display: none !important;
|
||||
.inner-crepe :deep(.ProseMirror) {
|
||||
min-height: 80px;
|
||||
padding: 8px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -107,110 +107,89 @@ const downloadDoc = () => {
|
||||
|
||||
<style scoped>
|
||||
.doc-block {
|
||||
margin: 8px 0;
|
||||
border-radius: 10px;
|
||||
margin: 12px 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-block {
|
||||
background: rgba(26, 30, 39, 0.8);
|
||||
border-color: rgba(96, 165, 250, 0.15);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: var(--crepe-color-surface-low);
|
||||
border: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.doc-block.collapsed .doc-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 深色条 */
|
||||
.doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-bottom: 1px solid rgba(59, 130, 246, 0.12);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-header {
|
||||
background: rgba(26, 30, 39, 0.8);
|
||||
border-bottom-color: rgba(96, 165, 250, 0.15);
|
||||
padding: 10px 12px;
|
||||
background: var(--crepe-color-surface);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 文件类型icon */
|
||||
.doc-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #3b82f6;
|
||||
color: var(--crepe-color-primary);
|
||||
}
|
||||
|
||||
/* 文件名 */
|
||||
.doc-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
color: var(--crepe-color-on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
color: var(--crepe-color-on-surface-variant);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
opacity: 0.75;
|
||||
border-radius: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
background: var(--crepe-color-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 浅色块:文档内容 */
|
||||
.doc-content {
|
||||
padding: 8px 10px;
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
max-height: 240px;
|
||||
padding: 12px;
|
||||
background: var(--crepe-color-surface-low);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-content {
|
||||
background: rgba(18, 22, 30, 0.8);
|
||||
}
|
||||
|
||||
.doc-content pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--crepe-color-on-surface);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .doc-content pre {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,706 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null },
|
||||
breadcrumb: { type: Array, default: () => [] },
|
||||
rootNodes: { type: Array, default: () => [] },
|
||||
getFileIcon: { type: Function, default: () => 'file' },
|
||||
getFileBlob: { type: Function, default: () => null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['navigate'])
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true
|
||||
})
|
||||
|
||||
const objectUrl = ref('')
|
||||
|
||||
const isRoot = computed(() => !props.node)
|
||||
const isFolder = computed(() => props.node?.type === 'folder')
|
||||
const fileExt = computed(() => {
|
||||
if (!props.node || props.node.type !== 'file') return ''
|
||||
const parts = props.node.name.split('.')
|
||||
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
||||
})
|
||||
const isMarkdown = computed(() => ['md', 'markdown'].includes(fileExt.value))
|
||||
const isImage = computed(() => {
|
||||
const mime = String(props.node?.mimeType || '')
|
||||
return mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(fileExt.value)
|
||||
})
|
||||
const isPdf = computed(() => {
|
||||
const mime = String(props.node?.mimeType || '')
|
||||
return mime === 'application/pdf' || fileExt.value === 'pdf'
|
||||
})
|
||||
const isText = computed(() => {
|
||||
const mime = String(props.node?.mimeType || '')
|
||||
return Boolean(props.node?.content || props.node?.previewText) || mime.startsWith('text/') || mime.includes('json') || ['txt', 'json', 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'htm', 'py', 'vue', 'xml', 'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg', 'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp', 'go', 'rs'].includes(fileExt.value)
|
||||
})
|
||||
const folderItems = computed(() => {
|
||||
if (!isRoot.value && !isFolder.value) return []
|
||||
return isRoot.value ? props.rootNodes : props.node.children || []
|
||||
})
|
||||
const fileBlob = computed(() => props.getFileBlob(props.node))
|
||||
const previewText = computed(() => props.node?.content || props.node?.previewText || '')
|
||||
const lineCount = computed(() => {
|
||||
if (!previewText.value) return 0
|
||||
return previewText.value.split('\n').length
|
||||
})
|
||||
const renderedMarkdown = computed(() => md.render(previewText.value || ''))
|
||||
const fileSizeLabel = computed(() => formatBytes(props.node?.size || fileBlob.value?.size || 0))
|
||||
const locLabel = computed(() => {
|
||||
const count = lineCount.value
|
||||
return count ? `${count} 行` : '二进制文件'
|
||||
})
|
||||
|
||||
watch(
|
||||
fileBlob,
|
||||
(blob) => {
|
||||
if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
|
||||
objectUrl.value = blob instanceof Blob ? URL.createObjectURL(blob) : ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
|
||||
})
|
||||
|
||||
function formatBytes(bytes = 0) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
return `${value >= 100 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return '-'
|
||||
return new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function navigateTo(id) {
|
||||
emit('navigate', id)
|
||||
}
|
||||
|
||||
async function copyText() {
|
||||
if (!previewText.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(previewText.value)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function openRaw() {
|
||||
if (!objectUrl.value) return
|
||||
window.open(objectUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
const blob = fileBlob.value
|
||||
if (!blob) return
|
||||
const url = objectUrl.value || URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = props.node?.name || 'download'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-content">
|
||||
<div v-if="isRoot || isFolder" class="directory-shell">
|
||||
<div class="directory-card">
|
||||
<div class="directory-card-header">
|
||||
<div>
|
||||
<h3>{{ isRoot ? '本地文件空间' : node.name }}</h3>
|
||||
<p>{{ isRoot ? '所有文件都保存在当前浏览器本地,不会上传到服务器。' : '像 GitHub 一样浏览当前目录内容。' }}</p>
|
||||
</div>
|
||||
<div class="directory-header-meta">
|
||||
<span>{{ folderItems.length }} 项</span>
|
||||
<span>{{ isRoot ? '根目录' : '目录' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="directory-table">
|
||||
<div class="directory-row directory-row-head">
|
||||
<span class="col-name">Name</span>
|
||||
<span class="col-size">Size</span>
|
||||
<span class="col-date">Updated</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isRoot" class="directory-row directory-parent" @click="navigateTo(node.parentId || null)">
|
||||
<span class="col-name">..</span>
|
||||
<span class="col-size">-</span>
|
||||
<span class="col-date">返回上级</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="item in folderItems"
|
||||
:key="item.id"
|
||||
class="directory-row directory-button"
|
||||
type="button"
|
||||
@click="navigateTo(item.id)"
|
||||
>
|
||||
<span class="col-name">
|
||||
<span :class="item.type === 'folder' ? 'icon-folder' : ['icon-file', `icon-${getFileIcon(item.name)}`]"></span>
|
||||
<span class="name-text">{{ item.name }}</span>
|
||||
</span>
|
||||
<span class="col-size">{{ item.type === 'folder' ? '-' : formatBytes(item.size) }}</span>
|
||||
<span class="col-date">{{ formatDate(item.updatedAt) }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="folderItems.length === 0" class="directory-empty">
|
||||
这里还没有文件。你可以从左侧直接上传文件,或者新建一个目录开始整理。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="node">
|
||||
<div class="github-file-header">
|
||||
<div class="file-path-bar">
|
||||
<div class="file-path">
|
||||
<span v-for="(item, index) in breadcrumb" :key="item.id || item.name" class="file-path-item">
|
||||
<button
|
||||
v-if="index < breadcrumb.length - 1"
|
||||
type="button"
|
||||
class="breadcrumb-link"
|
||||
@click="navigateTo(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
<span v-else class="breadcrumb-current">{{ item.name }}</span>
|
||||
<span v-if="index < breadcrumb.length - 1" class="path-sep">/</span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="top-link" type="button">Top</button>
|
||||
</div>
|
||||
|
||||
<div class="file-meta-row">
|
||||
<div class="meta-tabs">
|
||||
<button class="meta-tab active" type="button">{{ isImage || isPdf ? 'Preview' : 'Code' }}</button>
|
||||
<button class="meta-tab" type="button" disabled>Blame</button>
|
||||
<span class="file-meta">{{ locLabel }}</span>
|
||||
<span class="file-meta">{{ fileSizeLabel }}</span>
|
||||
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</span>
|
||||
</div>
|
||||
|
||||
<div class="file-actions">
|
||||
<button class="action-btn" type="button" @click="openRaw" :disabled="!objectUrl">Raw</button>
|
||||
<button class="action-btn" type="button" @click="copyText" :disabled="!previewText">复制</button>
|
||||
<button class="action-btn" type="button" @click="downloadFile" :disabled="!fileBlob">下载</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isMarkdown" class="content-markdown">
|
||||
<article class="markdown-body" v-html="renderedMarkdown"></article>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isText" class="content-text">
|
||||
<div class="code-container">
|
||||
<div class="line-numbers">
|
||||
<div v-for="n in Math.max(lineCount, 1)" :key="n" class="line-number">{{ n }}</div>
|
||||
</div>
|
||||
<pre class="text-content">{{ previewText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isImage && objectUrl" class="content-preview">
|
||||
<div class="preview-surface image-surface">
|
||||
<img :src="objectUrl" :alt="node.name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isPdf && objectUrl" class="content-preview">
|
||||
<iframe class="pdf-frame" :src="objectUrl" :title="node.name"></iframe>
|
||||
</div>
|
||||
|
||||
<div v-else class="content-unsupported">
|
||||
<div class="unsupported-card">
|
||||
<h3>暂不支持在线预览此文件</h3>
|
||||
<p>文件已保存在浏览器本地,你仍然可以点击右上角“下载”获取原文件。</p>
|
||||
<div class="unsupported-meta">
|
||||
<span>{{ node.name }}</span>
|
||||
<span>{{ fileSizeLabel }}</span>
|
||||
<span>{{ node.mimeType || '未知类型' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .file-content {
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.directory-shell,
|
||||
.content-markdown,
|
||||
.content-text,
|
||||
.content-preview,
|
||||
.content-unsupported {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.directory-shell {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.directory-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 12px;
|
||||
background: var(--github-bg);
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.directory-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--github-border);
|
||||
}
|
||||
|
||||
.directory-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.directory-card-header p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.directory-header-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.directory-header-meta span,
|
||||
.truncated-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 999px;
|
||||
background: var(--github-hover);
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.directory-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.directory-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 120px 180px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 48px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--github-border);
|
||||
}
|
||||
|
||||
.directory-row-head {
|
||||
min-height: 40px;
|
||||
background: var(--github-hover);
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.directory-button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.directory-button:hover,
|
||||
.directory-parent:hover {
|
||||
background: var(--github-hover);
|
||||
}
|
||||
|
||||
.directory-parent {
|
||||
cursor: pointer;
|
||||
color: var(--github-text-secondary);
|
||||
}
|
||||
|
||||
.col-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-size,
|
||||
.col-date {
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.directory-empty {
|
||||
padding: 40px 24px;
|
||||
color: var(--github-text-secondary);
|
||||
}
|
||||
|
||||
.github-file-header {
|
||||
border-bottom: 1px solid var(--github-border);
|
||||
background: var(--github-bg);
|
||||
}
|
||||
|
||||
.file-path-bar,
|
||||
.file-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.file-path-bar {
|
||||
border-bottom: 1px solid var(--github-border);
|
||||
}
|
||||
|
||||
.file-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-path-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #0969da;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--github-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.path-sep,
|
||||
.file-meta {
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.top-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--github-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-tab {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 8px;
|
||||
background: var(--github-hover);
|
||||
color: var(--github-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta-tab[disabled] {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.meta-tab.active {
|
||||
background: var(--github-bg);
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 8px;
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn:hover:enabled {
|
||||
background: var(--github-hover);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.content-markdown {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 12px;
|
||||
background: var(--github-bg);
|
||||
line-height: 1.8;
|
||||
color: var(--github-text);
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-top: 1.4em;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--github-code-bg);
|
||||
}
|
||||
|
||||
.content-text {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.code-container {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
min-height: 100%;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--github-bg);
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
padding: 18px 0;
|
||||
background: var(--github-hover);
|
||||
color: var(--github-text-secondary);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
padding: 0 14px 0 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
margin: 0;
|
||||
padding: 18px 20px;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--github-text);
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-surface {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(45deg, rgba(208, 215, 222, 0.35) 25%, transparent 25%, transparent 75%, rgba(208, 215, 222, 0.35) 75%),
|
||||
linear-gradient(45deg, rgba(208, 215, 222, 0.35) 25%, transparent 25%, transparent 75%, rgba(208, 215, 222, 0.35) 75%);
|
||||
background-position: 0 0, 12px 12px;
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
.image-surface img {
|
||||
max-width: 100%;
|
||||
max-height: 76vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.pdf-frame {
|
||||
width: 100%;
|
||||
min-height: 78vh;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 12px;
|
||||
background: var(--github-bg);
|
||||
}
|
||||
|
||||
.content-unsupported {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.unsupported-card {
|
||||
width: min(640px, 100%);
|
||||
padding: 28px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 16px;
|
||||
background: var(--github-bg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unsupported-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.unsupported-card p {
|
||||
margin: 0;
|
||||
color: var(--github-text-secondary);
|
||||
}
|
||||
|
||||
.unsupported-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.unsupported-meta span,
|
||||
.icon-folder,
|
||||
.icon-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.unsupported-meta span {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 999px;
|
||||
background: var(--github-hover);
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icon-folder::before {
|
||||
content: '📁';
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.icon-file::before {
|
||||
content: '📄';
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.icon-markdown::before { content: 'Ⓜ'; font-size: 12px; font-weight: 700; color: #0969da; }
|
||||
.icon-json::before { content: '{ }'; font-size: 8px; font-weight: 700; color: #8250df; }
|
||||
.icon-javascript::before { content: 'JS'; font-size: 9px; font-weight: 700; color: #9a6700; }
|
||||
.icon-typescript::before { content: 'TS'; font-size: 9px; font-weight: 700; color: #0969da; }
|
||||
.icon-css::before { content: 'CSS'; font-size: 7px; font-weight: 700; color: #1f883d; }
|
||||
.icon-html::before { content: 'HTML'; font-size: 6px; font-weight: 700; color: #bc4c00; }
|
||||
.icon-python::before { content: 'PY'; font-size: 9px; font-weight: 700; color: #0969da; }
|
||||
.icon-vue::before { content: 'Vue'; font-size: 8px; font-weight: 700; color: #1f883d; }
|
||||
.icon-yaml::before { content: 'YML'; font-size: 8px; font-weight: 700; color: #0969da; }
|
||||
.icon-xml::before { content: '</>'; font-size: 8px; font-weight: 700; color: #bc4c00; }
|
||||
.icon-csv::before { content: 'CSV'; font-size: 8px; font-weight: 700; color: #1f883d; }
|
||||
.icon-log::before { content: 'LOG'; font-size: 7px; font-weight: 700; color: #6e7781; }
|
||||
.icon-sql::before { content: 'SQL'; font-size: 8px; font-weight: 700; color: #8250df; }
|
||||
.icon-image::before { content: '🖼'; font-size: 13px; }
|
||||
.icon-pdf::before { content: 'PDF'; font-size: 8px; font-weight: 700; color: #cf222e; }
|
||||
.icon-word::before { content: 'DOC'; font-size: 7px; font-weight: 700; color: #0969da; }
|
||||
.icon-ppt::before { content: 'PPT'; font-size: 7px; font-weight: 700; color: #bc4c00; }
|
||||
.icon-excel::before { content: 'XLS'; font-size: 7px; font-weight: 700; color: #1f883d; }
|
||||
.icon-zip::before { content: 'ZIP'; font-size: 7px; font-weight: 700; color: #6f42c1; }
|
||||
.icon-text::before { content: 'TXT'; font-size: 8px; font-weight: 700; color: #6e7781; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.file-path-bar,
|
||||
.file-meta-row,
|
||||
.directory-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.directory-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.col-size,
|
||||
.col-date {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-container {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,633 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import TreeNodeItem from './TreeNodeItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, required: true },
|
||||
selectedId: { type: String, default: null },
|
||||
expandedIds: { type: Set, required: true },
|
||||
clipboard: { type: Object, default: null },
|
||||
getFileIcon: { type: Function, required: true },
|
||||
loading: { type: Boolean, default: false },
|
||||
stats: {
|
||||
type: Object,
|
||||
default: () => ({ fileCount: 0, folderCount: 0, usedBytes: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'select',
|
||||
'toggle',
|
||||
'create-file',
|
||||
'create-folder',
|
||||
'rename',
|
||||
'remove',
|
||||
'copy',
|
||||
'cut',
|
||||
'paste',
|
||||
'context-menu',
|
||||
'drop',
|
||||
'drag-start',
|
||||
'drag-over',
|
||||
'upload-files'
|
||||
])
|
||||
|
||||
const renameId = ref(null)
|
||||
const renameValue = ref('')
|
||||
const creatingInFolder = ref(null)
|
||||
const creatingType = ref(null)
|
||||
const creatingName = ref('')
|
||||
const keyword = ref('')
|
||||
const uploadInput = ref(null)
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
const text = keyword.value.trim().toLowerCase()
|
||||
if (!text) return props.nodes
|
||||
const filterChildren = (nodes) => {
|
||||
const result = []
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'folder') {
|
||||
const children = filterChildren(node.children || [])
|
||||
if (node.name.toLowerCase().includes(text) || children.length > 0) {
|
||||
result.push({ ...node, children })
|
||||
}
|
||||
} else if (node.name.toLowerCase().includes(text)) {
|
||||
result.push(node)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return filterChildren(props.nodes)
|
||||
})
|
||||
|
||||
function formatBytes(bytes = 0) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
return `${value >= 100 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
function startRename(node) {
|
||||
renameId.value = node.id
|
||||
renameValue.value = node.name
|
||||
}
|
||||
|
||||
function finishRename(node) {
|
||||
if (renameId.value === node.id && renameValue.value.trim()) {
|
||||
emit('rename', node.id, renameValue.value.trim())
|
||||
}
|
||||
renameId.value = null
|
||||
renameValue.value = ''
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
renameId.value = null
|
||||
renameValue.value = ''
|
||||
}
|
||||
|
||||
function startCreate(parentId, type) {
|
||||
creatingInFolder.value = parentId
|
||||
creatingType.value = type
|
||||
creatingName.value = type === 'file' ? 'untitled.md' : ''
|
||||
}
|
||||
|
||||
function finishCreate() {
|
||||
const name = creatingName.value.trim()
|
||||
if (!name) {
|
||||
cancelCreate()
|
||||
return
|
||||
}
|
||||
if (creatingType.value === 'file') emit('create-file', creatingInFolder.value, name)
|
||||
else emit('create-folder', creatingInFolder.value, name)
|
||||
cancelCreate()
|
||||
}
|
||||
|
||||
function cancelCreate() {
|
||||
creatingInFolder.value = null
|
||||
creatingType.value = null
|
||||
creatingName.value = ''
|
||||
}
|
||||
|
||||
function handleContextMenu(x, y, node) {
|
||||
emit('context-menu', x, y, node)
|
||||
}
|
||||
|
||||
function handleDrop(draggedId, targetNode) {
|
||||
emit('drop', draggedId, targetNode ? targetNode.id : null)
|
||||
}
|
||||
|
||||
function handleDropRoot(event) {
|
||||
event.preventDefault()
|
||||
const draggedId = event.dataTransfer.getData('text/plain')
|
||||
if (draggedId) emit('drop', draggedId, null)
|
||||
}
|
||||
|
||||
function getIconClass(type, name) {
|
||||
if (type === 'folder') return 'icon-folder'
|
||||
return `icon-file icon-${props.getFileIcon(name)}`
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
uploadInput.value?.click()
|
||||
}
|
||||
|
||||
function handleUpload(event) {
|
||||
const files = event.target.files
|
||||
if (files?.length) emit('upload-files', files)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function forwardDragStart(event, id) {
|
||||
emit('drag-start', event, id)
|
||||
}
|
||||
|
||||
function forwardDragOver(event, id) {
|
||||
emit('drag-over', event, id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-tree">
|
||||
<div class="sidebar-head">
|
||||
<div class="sidebar-title-row">
|
||||
<div class="sidebar-title-wrap">
|
||||
<span class="sidebar-title-icon">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25V2.75A1.75 1.75 0 0014.25 1H1.75zm0 1.5h12.5a.25.25 0 01.25.25v10.5a.25.25 0 01-.25.25H1.75a.25.25 0 01-.25-.25V2.75a.25.25 0 01.25-.25z"/><path d="M4 3.75A.75.75 0 014.75 3h2.5a.75.75 0 010 1.5h-2.5A.75.75 0 014 3.75zm0 3A.75.75 0 014.75 6h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 014 6.75zm0 3a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 014 9.75z"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<h2>Files</h2>
|
||||
<p>浏览器本地仓库</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="upload-btn" type="button" title="上传文件" @click="triggerUpload">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1.5a.75.75 0 01.75.75v6.19l1.72-1.72a.75.75 0 111.06 1.06L8.53 10.78a.75.75 0 01-1.06 0L4.47 7.78a.75.75 0 111.06-1.06l1.72 1.72V2.25A.75.75 0 018 1.5z"/><path d="M2.5 11.75A.75.75 0 013.25 11h9.5a.75.75 0 010 1.5h-9.5a.75.75 0 01-.75-.75z"/></svg>
|
||||
上传
|
||||
</button>
|
||||
<input ref="uploadInput" type="file" multiple class="hidden-input" @change="handleUpload" />
|
||||
</div>
|
||||
|
||||
<div class="branch-bar">
|
||||
<button class="branch-chip" type="button">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M1.5 2.75a2.25 2.25 0 114.5 0 2.25 2.25 0 01-1.5 2.122v5.256a2.251 2.251 0 11-1.5 0V4.872A2.251 2.251 0 011.5 2.75zm8.25 0a2.25 2.25 0 114.5 0 2.25 2.25 0 01-1.5 2.122v.378a3.25 3.25 0 01-3.25 3.25h-1v1.628a2.251 2.251 0 11-1.5 0V7.75a.75.75 0 01.75-.75H9.5A1.75 1.75 0 0011.25 5.25v-.378A2.251 2.251 0 019.75 2.75z"/></svg>
|
||||
main
|
||||
</button>
|
||||
<div class="branch-actions">
|
||||
<button class="header-icon-btn" type="button" title="新建文件" @click="startCreate(null, 'file')">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h6.5a.25.25 0 00.25-.25V5.664a.25.25 0 00-.073-.177L8.513 2.573A.25.25 0 008.336 2.5H4.75zm0-1.5h3.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0111.25 16h-6.5A1.75 1.75 0 013 14.25V1.75A1.75 1.75 0 014.75 0z"/><path d="M8 6a.75.75 0 01.75.75v1.5h1.5a.75.75 0 010 1.5h-1.5v1.5a.75.75 0 01-1.5 0v-1.5h-1.5a.75.75 0 010-1.5h1.5v-1.5A.75.75 0 018 6z"/></svg>
|
||||
</button>
|
||||
<button class="header-icon-btn" type="button" title="新建文件夹" @click="startCreate(null, 'folder')">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 000 2.75v8.5C0 12.216.784 13 1.75 13h12.5A1.75 1.75 0 0016 11.25v-6.5A1.75 1.75 0 0014.25 3H7.31l-.97-.97A1.75 1.75 0 005.103 1H1.75zm0 1.5h3.353a.25.25 0 01.177.073l1.409 1.408c.14.141.332.22.53.22h7.03a.25.25 0 01.25.25v6.8a.25.25 0 01-.25.25H1.75a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25z"/><path d="M8 6a.75.75 0 01.75.75v1h1a.75.75 0 010 1.5h-1v1a.75.75 0 01-1.5 0v-1h-1a.75.75 0 010-1.5h1v-1A.75.75 0 018 6z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="search-box">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M10.5 10.5a4.5 4.5 0 10-1 1l3.5 3.5 1-1-3.5-3.5zM6.5 10a3.5 3.5 0 110-7 3.5 3.5 0 010 7z"/></svg>
|
||||
<input v-model="keyword" type="text" placeholder="Go to file" />
|
||||
<span class="search-shortcut">t</span>
|
||||
</label>
|
||||
|
||||
<div class="stats-row">
|
||||
<span>{{ stats.folderCount }} 个文件夹</span>
|
||||
<span>{{ stats.fileCount }} 个文件</span>
|
||||
<span>{{ formatBytes(stats.usedBytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tree-header">
|
||||
<span>Code</span>
|
||||
<span class="tree-header-sub">{{ keyword ? `搜索:${keyword}` : '全部文件' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tree-content" @drop="handleDropRoot" @dragover="(event) => event.preventDefault()">
|
||||
<div v-if="loading" class="tree-empty">
|
||||
<p>正在加载本地文件…</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="filteredNodes.length">
|
||||
<TreeNodeItem
|
||||
v-for="node in filteredNodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:level="0"
|
||||
:selected-id="selectedId"
|
||||
:expanded-ids="expandedIds"
|
||||
:clipboard="clipboard"
|
||||
:get-icon-class="getIconClass"
|
||||
:rename-id="renameId"
|
||||
:rename-value="renameValue"
|
||||
:creating-in-folder="creatingInFolder"
|
||||
:creating-type="creatingType"
|
||||
:creating-name="creatingName"
|
||||
@select="emit('select', $event)"
|
||||
@toggle="emit('toggle', $event)"
|
||||
@start-rename="startRename"
|
||||
@finish-rename="finishRename"
|
||||
@cancel-rename="cancelRename"
|
||||
@update:rename-value="renameValue = $event"
|
||||
@start-create="startCreate"
|
||||
@finish-create="finishCreate"
|
||||
@cancel-create="cancelCreate"
|
||||
@update:creating-name="creatingName = $event"
|
||||
@context-menu="handleContextMenu"
|
||||
@drop="handleDrop"
|
||||
@drag-start="forwardDragStart"
|
||||
@drag-over="forwardDragOver"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else class="tree-empty">
|
||||
<p>{{ keyword ? '没有匹配的文件' : '还没有本地文件' }}</p>
|
||||
<div class="tree-empty-actions">
|
||||
<button type="button" @click="triggerUpload">上传文件</button>
|
||||
<button type="button" @click="startCreate(null, 'folder')">新建文件夹</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-tree {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text);
|
||||
}
|
||||
|
||||
.sidebar-head {
|
||||
padding: 14px 12px 10px;
|
||||
border-bottom: 1px solid var(--github-border);
|
||||
background: linear-gradient(180deg, rgba(246, 248, 250, 0.96), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sidebar-head {
|
||||
background: linear-gradient(180deg, rgba(17, 24, 39, 0.96), rgba(15, 17, 23, 0.98));
|
||||
}
|
||||
|
||||
.sidebar-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-title-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: rgba(9, 105, 218, 0.08);
|
||||
color: #0969da;
|
||||
}
|
||||
|
||||
.sidebar-title-wrap h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-title-wrap p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--github-text-secondary);
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #1f883d;
|
||||
border-radius: 8px;
|
||||
background: #1f883d;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
background: #1a7f37;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.branch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.branch-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 8px;
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.branch-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 8px;
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-icon-btn:hover {
|
||||
color: var(--github-text);
|
||||
background: var(--github-hover);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
margin-top: 12px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 8px;
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text-secondary);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--github-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-shortcut {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
color: var(--github-text-secondary);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--github-text-secondary);
|
||||
}
|
||||
|
||||
.tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--github-border);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--github-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.tree-header-sub {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 500;
|
||||
color: var(--github-text-secondary);
|
||||
}
|
||||
|
||||
.tree-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0 10px;
|
||||
}
|
||||
|
||||
.tree-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 40px 20px;
|
||||
color: var(--github-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tree-empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tree-empty-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-empty-actions button {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 8px;
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-empty-actions button:hover {
|
||||
background: var(--github-hover);
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding-right: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--github-text);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-node:hover {
|
||||
background: var(--github-hover);
|
||||
}
|
||||
|
||||
.tree-node.selected {
|
||||
background: var(--github-selected);
|
||||
color: var(--github-text);
|
||||
}
|
||||
|
||||
.tree-node.clipped {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tree-node-new {
|
||||
margin: 2px 8px;
|
||||
border: 1px dashed var(--github-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chevron,
|
||||
.chevron-placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--github-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-folder,
|
||||
.icon-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-folder::before {
|
||||
content: '📁';
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.icon-file::before {
|
||||
content: '📄';
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.icon-markdown::before { content: 'Ⓜ'; font-size: 12px; font-weight: 700; color: #0969da; }
|
||||
.icon-json::before { content: '{ }'; font-size: 8px; font-weight: 700; color: #8250df; }
|
||||
.icon-javascript::before { content: 'JS'; font-size: 9px; font-weight: 700; color: #9a6700; }
|
||||
.icon-typescript::before { content: 'TS'; font-size: 9px; font-weight: 700; color: #0969da; }
|
||||
.icon-css::before { content: 'CSS'; font-size: 7px; font-weight: 700; color: #1f883d; }
|
||||
.icon-html::before { content: 'HTML'; font-size: 6px; font-weight: 700; color: #bc4c00; }
|
||||
.icon-python::before { content: 'PY'; font-size: 9px; font-weight: 700; color: #0969da; }
|
||||
.icon-vue::before { content: 'Vue'; font-size: 8px; font-weight: 700; color: #1f883d; }
|
||||
.icon-yaml::before { content: 'YML'; font-size: 8px; font-weight: 700; color: #0969da; }
|
||||
.icon-xml::before { content: '</>'; font-size: 8px; font-weight: 700; color: #bc4c00; }
|
||||
.icon-csv::before { content: 'CSV'; font-size: 8px; font-weight: 700; color: #1f883d; }
|
||||
.icon-log::before { content: 'LOG'; font-size: 7px; font-weight: 700; color: #6e7781; }
|
||||
.icon-sql::before { content: 'SQL'; font-size: 8px; font-weight: 700; color: #8250df; }
|
||||
.icon-image::before { content: '🖼'; font-size: 13px; }
|
||||
.icon-pdf::before { content: 'PDF'; font-size: 8px; font-weight: 700; color: #cf222e; }
|
||||
.icon-word::before { content: 'DOC'; font-size: 7px; font-weight: 700; color: #0969da; }
|
||||
.icon-ppt::before { content: 'PPT'; font-size: 7px; font-weight: 700; color: #bc4c00; }
|
||||
.icon-excel::before { content: 'XLS'; font-size: 7px; font-weight: 700; color: #1f883d; }
|
||||
.icon-zip::before { content: 'ZIP'; font-size: 7px; font-weight: 700; color: #6f42c1; }
|
||||
.icon-text::before { content: 'TXT'; font-size: 8px; font-weight: 700; color: #6e7781; }
|
||||
|
||||
.node-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rename-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #0969da;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.tree-node:hover .node-actions,
|
||||
.tree-node.selected .node-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--github-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(9, 105, 218, 0.08);
|
||||
color: var(--github-text);
|
||||
}
|
||||
|
||||
.tree-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.tree-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 110, 123, 0.28);
|
||||
border-radius: 999px;
|
||||
}
|
||||
</style>
|
||||
+603
-858
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import packageJson from '../../package.json'
|
||||
|
||||
const store = useSettingsStore()
|
||||
const { setTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const VERSION = packageJson.version || '0.0.0'
|
||||
|
||||
@@ -132,11 +129,6 @@ const handleImageUpload = (event) => {
|
||||
}
|
||||
|
||||
const t = (key) => store.t[key]
|
||||
|
||||
const switchView = (view) => {
|
||||
router.push(view === 'editor' ? '/' : '/docs')
|
||||
closePanel()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -202,38 +194,6 @@ const switchView = (view) => {
|
||||
v-model.number="store.backgroundOpacity"
|
||||
class="range-slider"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- View Switch -->
|
||||
<section class="settings-section">
|
||||
<h3>{{ t('view') || '视图' }}</h3>
|
||||
<div class="view-switch">
|
||||
<button
|
||||
class="view-btn"
|
||||
:class="{ active: route.path === '/' }"
|
||||
@click="switchView('editor')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
<span>{{ t('editor') || '编辑器' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="view-btn"
|
||||
:class="{ active: route.path === '/docs' }"
|
||||
@click="switchView('docs')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
<polyline points="10 9 9 9 8 9"></polyline>
|
||||
</svg>
|
||||
<span>{{ t('docs') || '文档' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -306,27 +266,12 @@ const switchView = (view) => {
|
||||
<option value="AUD">AUD ($)</option>
|
||||
<option value="CAD">CAD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TTS Settings -->
|
||||
<section class="settings-section">
|
||||
<h3>{{ t('ttsSettings') || '语音设置' }}</h3>
|
||||
<div class="form-group">
|
||||
<label>{{ t('voiceInstruction') || '声音描述' }}</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="store.ttsInstruct"
|
||||
class="select-input"
|
||||
:placeholder="t('voiceInstructionPlaceholder') || '例如:用温柔的语气说'"
|
||||
/>
|
||||
<p class="help-text">{{ t('voiceInstructionDesc') || '描述你想要的声音风格,如语气、情感等' }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="settings-section">
|
||||
<h3>{{ t('about') }}</h3>
|
||||
<!-- About -->
|
||||
<section class="settings-section">
|
||||
<h3>{{ t('about') }}</h3>
|
||||
<div class="about-card">
|
||||
<h4>llm-in-text</h4>
|
||||
<p>A smart Markdown editor with local LLM intelligence.</p>
|
||||
@@ -374,7 +319,6 @@ const switchView = (view) => {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid var(--panel-border);
|
||||
border-radius: 0 8px 8px 0;
|
||||
box-shadow: var(--panel-shadow);
|
||||
z-index: 10000;
|
||||
transform: translateX(-100%);
|
||||
@@ -405,11 +349,8 @@ const switchView = (view) => {
|
||||
/* Mobile Fullscreen */
|
||||
@media (max-width: 640px) {
|
||||
.settings-panel {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,7 +384,6 @@ const switchView = (view) => {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
@@ -622,37 +562,5 @@ const switchView = (view) => {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.view-switch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--ghost-code-bg);
|
||||
color: var(--muted-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
color: var(--app-text);
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
border-color: var(--focus-ring);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="tts-menu-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="tts-menu"
|
||||
:style="menuStyle"
|
||||
@mousedown.stop
|
||||
>
|
||||
<button class="tts-menu__btn" @click="$emit('speak')" :disabled="loading">
|
||||
<svg v-if="!loading" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
</svg>
|
||||
<svg v-else class="tts-menu__spinner" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
{{ loading ? '生成中...' : '朗读' }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['speak'])
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${props.x}px`,
|
||||
top: `${props.y}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tts-menu {
|
||||
position: fixed;
|
||||
z-index: 100000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tts-menu__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 20px;
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: var(--app-text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--panel-shadow);
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tts-menu__btn:hover:not(:disabled) {
|
||||
background: var(--btn-hover-bg);
|
||||
color: var(--btn-hover-fg);
|
||||
border-color: var(--btn-hover-bg);
|
||||
}
|
||||
|
||||
.tts-menu__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tts-menu__spinner {
|
||||
animation: tts-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes tts-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.tts-menu-fade-enter-active,
|
||||
.tts-menu-fade-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tts-menu-fade-enter-from,
|
||||
.tts-menu-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-100% + 4px));
|
||||
}
|
||||
</style>
|
||||
@@ -1,432 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="tts-player-slide">
|
||||
<div v-if="visible" class="tts-player">
|
||||
<div class="tts-player__inner">
|
||||
<div class="tts-player__controls">
|
||||
<button class="tts-player__btn" @click="skipBackward" title="快退5秒">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 4v6h6"/><path d="M11 12a7 7 0 0 0-7-7"/><path d="M11 12l-3-3"/>
|
||||
<text x="14" y="16" font-size="8" fill="currentColor" stroke="none">5</text>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="tts-player__btn tts-player__btn--primary" @click="togglePlay" :title="isPlaying ? '暂停' : '播放'">
|
||||
<svg v-if="!isPlaying" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="tts-player__btn" @click="skipForward" title="快进5秒">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 4v6h-6"/><path d="M13 12a7 7 0 0 1 7-7"/><path d="M13 12l3-3"/>
|
||||
<text x="4" y="16" font-size="8" fill="currentColor" stroke="none">5</text>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tts-player__progress">
|
||||
<span class="tts-player__time">{{ currentTimeStr }}</span>
|
||||
<div class="tts-player__bar" @click="seek">
|
||||
<div class="tts-player__bar-fill" :style="{ width: progressPercent + '%' }">
|
||||
<div class="tts-player__bar-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tts-player__time">{{ durationStr }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tts-player__settings">
|
||||
<div class="tts-player__volume">
|
||||
<button class="tts-player__btn" @click="toggleMute" :title="isMuted ? '取消静音' : '静音'">
|
||||
<svg v-if="!isMuted && volume > 0.5" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
</svg>
|
||||
<svg v-else-if="!isMuted" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" class="tts-player__slider" v-model.number="volume" min="0" max="1" step="0.05" title="音量">
|
||||
</div>
|
||||
|
||||
<select class="tts-player__speed-select" v-model.number="playbackRate" title="播放速度">
|
||||
<option value="0.5">0.5x</option>
|
||||
<option value="0.75">0.75x</option>
|
||||
<option value="1">1x</option>
|
||||
<option value="1.25">1.25x</option>
|
||||
<option value="1.5">1.5x</option>
|
||||
<option value="2">2x</option>
|
||||
</select>
|
||||
|
||||
<button class="tts-player__btn" @click="downloadAudio" title="下载音频">
|
||||
<svg width="16" height="16" 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 class="tts-player__btn tts-player__btn--close" @click="$emit('close')" title="关闭">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio ref="audioRef" :src="audioSrc" @timeupdate="onTimeUpdate" @ended="onEnded" @loadedmetadata="onLoadedMetadata"></audio>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
audioBase64: { type: String, default: '' },
|
||||
format: { type: String, default: 'wav' },
|
||||
durationMs: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const audioRef = ref(null)
|
||||
const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const volume = ref(1)
|
||||
const isMuted = ref(false)
|
||||
const playbackRate = ref(1)
|
||||
let blobUrl = ''
|
||||
|
||||
const audioSrc = computed(() => {
|
||||
if (!props.audioBase64) return ''
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl)
|
||||
const byteChars = atob(props.audioBase64)
|
||||
const byteNumbers = new Array(byteChars.length)
|
||||
for (let i = 0; i < byteChars.length; i++) {
|
||||
byteNumbers[i] = byteChars.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: `audio/${props.format}` })
|
||||
blobUrl = URL.createObjectURL(blob)
|
||||
return blobUrl
|
||||
})
|
||||
|
||||
const currentTimeStr = computed(() => formatTime(currentTime.value))
|
||||
const durationStr = computed(() => formatTime(duration.value))
|
||||
const progressPercent = computed(() => {
|
||||
if (!duration.value) return 0
|
||||
return Math.min(100, (currentTime.value / duration.value) * 100)
|
||||
})
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (!seconds || !isFinite(seconds)) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const audio = audioRef.value
|
||||
if (!audio) return
|
||||
if (isPlaying.value) {
|
||||
audio.pause()
|
||||
} else {
|
||||
audio.play()
|
||||
}
|
||||
}
|
||||
|
||||
function skipBackward() {
|
||||
const audio = audioRef.value
|
||||
if (!audio) return
|
||||
audio.currentTime = Math.max(0, audio.currentTime - 5)
|
||||
}
|
||||
|
||||
function skipForward() {
|
||||
const audio = audioRef.value
|
||||
if (!audio) return
|
||||
audio.currentTime = Math.min(duration.value, audio.currentTime + 5)
|
||||
}
|
||||
|
||||
function seek(event) {
|
||||
const audio = audioRef.value
|
||||
if (!audio || !duration.value) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const ratio = (event.clientX - rect.left) / rect.width
|
||||
audio.currentTime = Math.max(0, Math.min(duration.value, ratio * duration.value))
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
isMuted.value = !isMuted.value
|
||||
if (audioRef.value) {
|
||||
audioRef.value.muted = isMuted.value
|
||||
}
|
||||
}
|
||||
|
||||
function downloadAudio() {
|
||||
if (!blobUrl) return
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = `tts-${Date.now()}.${props.format}`
|
||||
a.click()
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
const audio = audioRef.value
|
||||
if (!audio) return
|
||||
currentTime.value = audio.currentTime
|
||||
}
|
||||
|
||||
function onLoadedMetadata() {
|
||||
const audio = audioRef.value
|
||||
if (!audio) return
|
||||
duration.value = audio.duration || (props.durationMs / 1000)
|
||||
audio.volume = volume.value
|
||||
audio.playbackRate = playbackRate.value
|
||||
audio.play()
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
watch(isPlaying, (val) => {
|
||||
const audio = audioRef.value
|
||||
if (!audio) return
|
||||
if (val) {
|
||||
audio.play().catch(() => { isPlaying.value = false })
|
||||
} else {
|
||||
audio.pause()
|
||||
}
|
||||
})
|
||||
|
||||
watch(volume, (val) => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.volume = val
|
||||
isMuted.value = val === 0
|
||||
}
|
||||
})
|
||||
|
||||
watch(playbackRate, (val) => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.playbackRate = val
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
isPlaying.value = true
|
||||
currentTime.value = 0
|
||||
} else {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.currentTime = 0
|
||||
}
|
||||
isPlaying.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
}
|
||||
if (blobUrl) {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
blobUrl = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tts-player {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 99999;
|
||||
padding: 8px 16px;
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 1px solid var(--panel-border);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tts-player__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tts-player__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tts-player__btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--app-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tts-player__btn:hover {
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.tts-player__btn--primary {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--btn-hover-bg);
|
||||
color: var(--btn-hover-fg);
|
||||
}
|
||||
|
||||
.tts-player__btn--primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.tts-player__btn--close:hover {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.tts-player__progress {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tts-player__time {
|
||||
font-size: 11px;
|
||||
color: var(--muted-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.tts-player__bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: height 0.1s ease;
|
||||
}
|
||||
|
||||
.tts-player__bar:hover {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.tts-player__bar:hover .tts-player__bar-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tts-player__bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--btn-hover-bg), #60a5fa);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.tts-player__bar-thumb {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.tts-player__settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tts-player__volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tts-player__slider {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tts-player__slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--btn-hover-bg);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tts-player__slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--btn-hover-bg);
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tts-player__speed-select {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--app-text);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tts-player__speed-select:hover {
|
||||
border-color: var(--btn-hover-bg);
|
||||
}
|
||||
|
||||
.tts-player-slide-enter-active,
|
||||
.tts-player-slide-leave-active {
|
||||
transition: transform 0.25s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tts-player-slide-enter-from,
|
||||
.tts-player-slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,182 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
level: { type: Number, required: true },
|
||||
selectedId: { type: String, default: null },
|
||||
expandedIds: { type: Object, required: true },
|
||||
clipboard: { type: Object, default: null },
|
||||
getIconClass: { type: Function, required: true },
|
||||
renameId: { type: String, default: null },
|
||||
renameValue: { type: String, default: '' },
|
||||
creatingInFolder: { type: String, default: null },
|
||||
creatingType: { type: String, default: null },
|
||||
creatingName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'select',
|
||||
'toggle',
|
||||
'start-rename',
|
||||
'finish-rename',
|
||||
'cancel-rename',
|
||||
'update:rename-value',
|
||||
'start-create',
|
||||
'finish-create',
|
||||
'cancel-create',
|
||||
'update:creating-name',
|
||||
'context-menu',
|
||||
'drop',
|
||||
'drag-start',
|
||||
'drag-over'
|
||||
])
|
||||
|
||||
const isSelected = computed(() => props.node.id === props.selectedId)
|
||||
const isExpanded = computed(() => props.expandedIds?.has(props.node.id))
|
||||
const isClipped = computed(() => {
|
||||
if (!props.clipboard) return false
|
||||
return props.clipboard.node?.id === props.node.id || props.clipboard.nodeId === props.node.id
|
||||
})
|
||||
const isRenaming = computed(() => props.renameId === props.node.id)
|
||||
const isCreating = computed(() => props.creatingInFolder === props.node.id)
|
||||
const paddingLeft = computed(() => `${props.level * 16 + 12}px`)
|
||||
const createPaddingLeft = computed(() => `${(props.level + 1) * 16 + 12}px`)
|
||||
|
||||
function handleContextMenu(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('context-menu', event.clientX, event.clientY, props.node)
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const draggedId = event.dataTransfer.getData('text/plain')
|
||||
if (draggedId) emit('drop', draggedId, props.node)
|
||||
}
|
||||
|
||||
function handleDragStart(event) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', props.node.id)
|
||||
emit('drag-start', event, props.node.id)
|
||||
}
|
||||
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault()
|
||||
emit('drag-over', event, props.node.id)
|
||||
}
|
||||
|
||||
function forwardStartCreate(id, type) {
|
||||
emit('start-create', id, type)
|
||||
}
|
||||
|
||||
function forwardContextMenu(x, y, node) {
|
||||
emit('context-menu', x, y, node)
|
||||
}
|
||||
|
||||
function forwardDrop(id, node) {
|
||||
emit('drop', id, node)
|
||||
}
|
||||
|
||||
function forwardDragStart(event, id) {
|
||||
emit('drag-start', event, id)
|
||||
}
|
||||
|
||||
function forwardDragOver(event, id) {
|
||||
emit('drag-over', event, id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="tree-node"
|
||||
:class="{ selected: isSelected, clipped: isClipped }"
|
||||
:style="{ paddingLeft }"
|
||||
draggable="true"
|
||||
@click="emit('select', node.id)"
|
||||
@contextmenu="handleContextMenu"
|
||||
@dragstart="handleDragStart"
|
||||
@dragover="handleDragOver"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<button
|
||||
v-if="node.type === 'folder'"
|
||||
class="chevron"
|
||||
type="button"
|
||||
@click.stop="emit('toggle', node.id)"
|
||||
>
|
||||
<svg v-if="isExpanded" viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M4 6l4 4 4-4z" /></svg>
|
||||
<svg v-else viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M6 4l4 4-4 4z" /></svg>
|
||||
</button>
|
||||
<span v-else class="chevron-placeholder"></span>
|
||||
|
||||
<span :class="getIconClass(node.type, node.name)"></span>
|
||||
|
||||
<input
|
||||
v-if="isRenaming"
|
||||
class="rename-input"
|
||||
:value="renameValue"
|
||||
@input="emit('update:rename-value', $event.target.value)"
|
||||
@keydown.enter="emit('finish-rename', node)"
|
||||
@keydown.esc="emit('cancel-rename')"
|
||||
@blur="emit('finish-rename', node)"
|
||||
/>
|
||||
<span v-else class="node-name">{{ node.name }}</span>
|
||||
|
||||
<div v-if="node.type === 'folder'" class="node-actions">
|
||||
<button class="action-btn" type="button" title="新建文件" @click.stop="emit('start-create', node.id, 'file')">
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h6.5a.25.25 0 00.25-.25V5.664a.25.25 0 00-.073-.177L8.513 2.573A.25.25 0 008.336 2.5H4.75zm0-1.5h3.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0111.25 16h-6.5A1.75 1.75 0 013 14.25V1.75A1.75 1.75 0 014.75 0z"/><path d="M8 6a.75.75 0 01.75.75v1.5h1.5a.75.75 0 010 1.5h-1.5v1.5a.75.75 0 01-1.5 0v-1.5h-1.5a.75.75 0 010-1.5h1.5v-1.5A.75.75 0 018 6z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn" type="button" title="新建文件夹" @click.stop="emit('start-create', node.id, 'folder')">
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 000 2.75v8.5C0 12.216.784 13 1.75 13h12.5A1.75 1.75 0 0016 11.25v-6.5A1.75 1.75 0 0014.25 3H7.31l-.97-.97A1.75 1.75 0 005.103 1H1.75zm0 1.5h3.353a.25.25 0 01.177.073l1.409 1.408c.14.141.332.22.53.22h7.03a.25.25 0 01.25.25v6.8a.25.25 0 01-.25.25H1.75a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25z"/><path d="M8 6a.75.75 0 01.75.75v1h1a.75.75 0 010 1.5h-1v1a.75.75 0 01-1.5 0v-1h-1a.75.75 0 010-1.5h1v-1A.75.75 0 018 6z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isCreating" class="tree-node tree-node-new" :style="{ paddingLeft: createPaddingLeft }">
|
||||
<span class="chevron-placeholder"></span>
|
||||
<span :class="creatingType === 'folder' ? 'icon-folder' : 'icon-file'"></span>
|
||||
<input
|
||||
class="rename-input"
|
||||
:value="creatingName"
|
||||
:placeholder="creatingType === 'folder' ? '输入文件夹名称' : '输入文件名,例如 note.md'"
|
||||
@input="emit('update:creating-name', $event.target.value)"
|
||||
@keydown.enter="emit('finish-create')"
|
||||
@keydown.esc="emit('cancel-create')"
|
||||
@blur="emit('finish-create')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="node.type === 'folder' && isExpanded">
|
||||
<TreeNodeItem
|
||||
v-for="child in node.children || []"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:level="level + 1"
|
||||
:selected-id="selectedId"
|
||||
:expanded-ids="expandedIds"
|
||||
:clipboard="clipboard"
|
||||
:get-icon-class="getIconClass"
|
||||
:rename-id="renameId"
|
||||
:rename-value="renameValue"
|
||||
:creating-in-folder="creatingInFolder"
|
||||
:creating-type="creatingType"
|
||||
:creating-name="creatingName"
|
||||
@select="emit('select', $event)"
|
||||
@toggle="emit('toggle', $event)"
|
||||
@start-rename="emit('start-rename', $event)"
|
||||
@finish-rename="emit('finish-rename', $event)"
|
||||
@cancel-rename="emit('cancel-rename')"
|
||||
@update:rename-value="emit('update:rename-value', $event)"
|
||||
@start-create="forwardStartCreate"
|
||||
@finish-create="emit('finish-create')"
|
||||
@cancel-create="emit('cancel-create')"
|
||||
@update:creating-name="emit('update:creating-name', $event)"
|
||||
@context-menu="forwardContextMenu"
|
||||
@drop="forwardDrop"
|
||||
@drag-start="forwardDragStart"
|
||||
@drag-over="forwardDragOver"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,504 +0,0 @@
|
||||
<template>
|
||||
<div class="univer-editor-container">
|
||||
<!-- 工具栏 -->
|
||||
<div class="univer-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="doc-name" :title="documentInfo?.name || ''">
|
||||
{{ documentInfo?.name || '未命名文档' }}
|
||||
</span>
|
||||
<span class="doc-format" v-if="documentInfo?.format">
|
||||
{{ getFormatLabel(documentInfo.format) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="toolbar-btn" @click="handleImport" :title="t('import') || '导入文件'">
|
||||
<svg width="16" height="16" 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="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<span>{{ t('import') || '导入' }}</span>
|
||||
</button>
|
||||
<button class="toolbar-btn" @click="handleExport" :title="t('export') || '导出文件'" :disabled="!hasDocument">
|
||||
<svg width="16" height="16" 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>
|
||||
<span>{{ t('export') || '导出' }}</span>
|
||||
</button>
|
||||
<button class="toolbar-btn" @click="handleSaveSnapshot" :title="t('saveSnapshot') || '保存快照'" :disabled="!editorInstance">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
<span>{{ t('saveSnapshot') || '快照' }}</span>
|
||||
</button>
|
||||
<button class="toolbar-btn back-btn" @click="handleBack" :title="t('backToEditor') || '返回编辑器'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
<span>{{ t('back') || '返回' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器容器 -->
|
||||
<div ref="editorContainer" class="univer-editor-body">
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="!editorInstance" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<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"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-text">{{ t('selectOfficeFile') || '请选择 Office 文件开始编辑' }}</p>
|
||||
<p class="empty-hint">{{ t('supportedFormats') || '支持 DOCX、XLSX、PPTX 格式' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入文件输入 -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="acceptTypes"
|
||||
@change="handleFileChange"
|
||||
style="display: none"
|
||||
/>
|
||||
|
||||
<!-- 导出格式选择对话框 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showExportDialog" class="export-dialog-overlay" @click.self="showExportDialog = false">
|
||||
<div class="export-dialog">
|
||||
<h3>{{ t('selectExportFormat') || '选择导出格式' }}</h3>
|
||||
<div class="export-options">
|
||||
<button
|
||||
v-for="format in exportFormats"
|
||||
:key="format.value"
|
||||
class="export-option"
|
||||
@click="confirmExport(format.value)"
|
||||
>
|
||||
<span class="format-icon">{{ format.icon }}</span>
|
||||
<span class="format-label">{{ format.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="cancel-btn" @click="showExportDialog = false">
|
||||
{{ t('cancel') || '取消' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOfficeStore } from '../stores/office'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { createUniverEditor, OfficeFormat, OfficePresetType } from '../services/univerBridge'
|
||||
import { isOfficeFile, getOfficeFormat, getFormatDisplayName } from '../services/officeDetection'
|
||||
|
||||
const emit = defineEmits(['back', 'document-loaded', 'document-changed'])
|
||||
|
||||
const router = useRouter()
|
||||
const officeStore = useOfficeStore()
|
||||
const settings = useSettingsStore()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const t = (key) => settings.t[key]
|
||||
|
||||
const editorContainer = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const editorInstance = ref(null)
|
||||
const showExportDialog = ref(false)
|
||||
|
||||
const acceptTypes = '.docx,.xlsx,.pptx'
|
||||
|
||||
const hasDocument = computed(() => officeStore.hasDocument)
|
||||
const documentInfo = computed(() => officeStore.documentInfo)
|
||||
|
||||
const exportFormats = computed(() => {
|
||||
const currentFormat = officeStore.currentFormat
|
||||
if (currentFormat === OfficeFormat.XLSX) {
|
||||
return [
|
||||
{ value: 'xlsx', label: 'Excel (.xlsx)', icon: '📊' },
|
||||
{ value: 'xlsx_snapshot', label: '快照 (JSON)', icon: '💾' }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ value: 'docx', label: 'Word (.docx)', icon: '📄' },
|
||||
{ value: 'snapshot', label: '快照 (JSON)', icon: '💾' }
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 初始化编辑器
|
||||
*/
|
||||
async function initEditor(format) {
|
||||
if (!editorContainer.value) return
|
||||
|
||||
try {
|
||||
if (editorInstance.value) {
|
||||
await editorInstance.value.destroy()
|
||||
}
|
||||
|
||||
editorInstance.value = createUniverEditor()
|
||||
await editorInstance.value.init(editorContainer.value, {
|
||||
format: format || OfficeFormat.DOCX,
|
||||
locale: settings.language === 'zh-CN' ? 'zh-CN' : 'en-US',
|
||||
theme: isDark.value ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
// 监听文档变化
|
||||
editorInstance.value.onChange((event) => {
|
||||
officeStore.markAsChanged()
|
||||
emit('document-changed', event)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化 Univer 编辑器失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导入
|
||||
*/
|
||||
function handleImport() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件选择
|
||||
*/
|
||||
async function handleFileChange(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!isOfficeFile(file)) {
|
||||
alert(t('invalidOfficeFormat') || '请选择有效的 Office 文件 (DOCX/XLSX/PPTX)')
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const format = getOfficeFormat(file)
|
||||
const bytes = await file.arrayBuffer()
|
||||
|
||||
officeStore.setCurrentDocument(file, bytes)
|
||||
|
||||
// 重新初始化编辑器
|
||||
await initEditor(format)
|
||||
|
||||
emit('document-loaded', {
|
||||
name: file.name,
|
||||
format,
|
||||
size: file.size
|
||||
})
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导出
|
||||
*/
|
||||
function handleExport() {
|
||||
showExportDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认导出
|
||||
*/
|
||||
async function confirmExport(format) {
|
||||
showExportDialog.value = false
|
||||
|
||||
if (!editorInstance.value) return
|
||||
|
||||
try {
|
||||
const snapshot = await editorInstance.value.exportSnapshot()
|
||||
const json = JSON.stringify(snapshot, null, 2)
|
||||
downloadFile(json, `${officeStore.currentFileName || 'document'}.json`, 'application/json')
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
alert(t('exportFailed') || '导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存快照
|
||||
*/
|
||||
async function handleSaveSnapshot() {
|
||||
if (!editorInstance.value) return
|
||||
|
||||
try {
|
||||
const snapshot = await editorInstance.value.exportSnapshot()
|
||||
officeStore.setSnapshot(snapshot)
|
||||
|
||||
// 保存到 localStorage
|
||||
const key = `univer_snapshot_${Date.now()}`
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
name: officeStore.currentFileName,
|
||||
format: officeStore.currentFormat,
|
||||
snapshot: snapshot,
|
||||
savedAt: new Date().toISOString()
|
||||
}))
|
||||
|
||||
alert(t('snapshotSaved') || '快照已保存')
|
||||
} catch (error) {
|
||||
console.error('保存快照失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回编辑器
|
||||
*/
|
||||
function handleBack() {
|
||||
emit('back')
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
function downloadFile(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取格式标签
|
||||
*/
|
||||
function getFormatLabel(format) {
|
||||
return getFormatDisplayName(format, settings.language)
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听主题变化
|
||||
*/
|
||||
watch(isDark, async (newVal) => {
|
||||
if (editorInstance.value) {
|
||||
// Univer 暂不支持动态主题切换,需要重新初始化
|
||||
await initEditor(officeStore.currentFormat)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 监听语言变化
|
||||
*/
|
||||
watch(() => settings.language, async (newVal) => {
|
||||
if (editorInstance.value) {
|
||||
await initEditor(officeStore.currentFormat)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// 如果有当前文档,初始化编辑器
|
||||
if (officeStore.currentFormat) {
|
||||
await initEditor(officeStore.currentFormat)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
if (editorInstance.value) {
|
||||
await editorInstance.value.destroy()
|
||||
editorInstance.value = null
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
initEditor,
|
||||
editorInstance
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.univer-editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.univer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: var(--panel-bg);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.doc-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-format {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
padding: 2px 8px;
|
||||
background: var(--ghost-code-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 6px;
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover:not(:disabled) {
|
||||
background: var(--btn-hover-bg);
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-left: 8px;
|
||||
border-color: var(--focus-ring);
|
||||
color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.univer-editor-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.export-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.export-dialog {
|
||||
background: var(--panel-bg);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--panel-border);
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.export-dialog h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.export-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.export-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: var(--app-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.export-option:hover {
|
||||
background: var(--btn-hover-bg);
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.format-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.format-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: var(--ghost-code-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,703 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const DB_NAME = 'llm-in-text-docs'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'nodes'
|
||||
const MAX_FILE_SIZE = 1024 * 1024 * 1024
|
||||
const MAX_TEXT_SIZE = 8 * 1024 * 1024
|
||||
const PREVIEW_TEXT_SIZE = 2 * 1024 * 1024
|
||||
const MAX_NODES = 5000
|
||||
|
||||
let dbPromise = null
|
||||
|
||||
function generateId() {
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function openDatabase() {
|
||||
if (dbPromise) return dbPromise
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error || new Error('打开本地数据库失败'))
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
async function withStore(mode, handler) {
|
||||
const db = await openDatabase()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, mode)
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
let result
|
||||
try {
|
||||
result = handler(store)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
transaction.oncomplete = () => resolve(result)
|
||||
transaction.onerror = () => reject(transaction.error || new Error('本地数据库写入失败'))
|
||||
transaction.onabort = () => reject(transaction.error || new Error('本地数据库操作已取消'))
|
||||
})
|
||||
}
|
||||
|
||||
function cloneRecord(record) {
|
||||
if (!record) return record
|
||||
return {
|
||||
...record,
|
||||
children: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getExtension(name = '') {
|
||||
const parts = String(name).split('.')
|
||||
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
||||
}
|
||||
|
||||
function isTextExtension(ext) {
|
||||
return [
|
||||
'md',
|
||||
'markdown',
|
||||
'txt',
|
||||
'json',
|
||||
'js',
|
||||
'jsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'css',
|
||||
'scss',
|
||||
'less',
|
||||
'html',
|
||||
'htm',
|
||||
'py',
|
||||
'vue',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'csv',
|
||||
'log',
|
||||
'sql',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'sh',
|
||||
'bat',
|
||||
'ps1',
|
||||
'java',
|
||||
'c',
|
||||
'cpp',
|
||||
'h',
|
||||
'hpp',
|
||||
'go',
|
||||
'rs'
|
||||
].includes(ext)
|
||||
}
|
||||
|
||||
function inferMimeType(name, fallback = '') {
|
||||
const ext = getExtension(name)
|
||||
const map = {
|
||||
md: 'text/markdown',
|
||||
markdown: 'text/markdown',
|
||||
txt: 'text/plain',
|
||||
json: 'application/json',
|
||||
js: 'text/javascript',
|
||||
jsx: 'text/javascript',
|
||||
ts: 'text/typescript',
|
||||
tsx: 'text/typescript',
|
||||
css: 'text/css',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
py: 'text/x-python',
|
||||
vue: 'text/plain',
|
||||
xml: 'application/xml',
|
||||
yaml: 'text/yaml',
|
||||
yml: 'text/yaml',
|
||||
csv: 'text/csv',
|
||||
log: 'text/plain',
|
||||
sql: 'text/plain',
|
||||
toml: 'text/plain',
|
||||
ini: 'text/plain',
|
||||
cfg: 'text/plain',
|
||||
conf: 'text/plain',
|
||||
sh: 'text/plain',
|
||||
bat: 'text/plain',
|
||||
ps1: 'text/plain',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf'
|
||||
}
|
||||
return fallback || map[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function isTextFile(record) {
|
||||
const ext = getExtension(record?.name)
|
||||
const mime = String(record?.mimeType || '')
|
||||
return isTextExtension(ext) || mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')
|
||||
}
|
||||
|
||||
function buildTree(records) {
|
||||
const map = new Map()
|
||||
const roots = []
|
||||
for (const record of records) {
|
||||
map.set(record.id, {
|
||||
...record,
|
||||
children: record.type === 'folder' ? [] : undefined
|
||||
})
|
||||
}
|
||||
for (const record of records) {
|
||||
const current = map.get(record.id)
|
||||
if (!record.parentId) {
|
||||
roots.push(current)
|
||||
continue
|
||||
}
|
||||
const parent = map.get(record.parentId)
|
||||
if (parent?.type === 'folder') {
|
||||
parent.children.push(current)
|
||||
} else {
|
||||
roots.push(current)
|
||||
}
|
||||
}
|
||||
const sorter = (a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
|
||||
return a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' })
|
||||
}
|
||||
const sortChildren = (nodes) => {
|
||||
nodes.sort(sorter)
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'folder' && Array.isArray(node.children)) {
|
||||
sortChildren(node.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
sortChildren(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
function findNode(nodes, id) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node
|
||||
if (node.type === 'folder') {
|
||||
const found = findNode(node.children || [], id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getPath(nodes, id, path = []) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return [...path, node]
|
||||
if (node.type === 'folder') {
|
||||
const found = getPath(node.children || [], id, [...path, node])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function estimateRecordSize(record) {
|
||||
if (typeof record?.size === 'number') return record.size
|
||||
if (typeof record?.content === 'string') return new Blob([record.content]).size
|
||||
if (typeof record?.previewText === 'string') return new Blob([record.previewText]).size
|
||||
return 0
|
||||
}
|
||||
|
||||
async function readFilePayload(file) {
|
||||
const mimeType = inferMimeType(file.name, file.type)
|
||||
const ext = getExtension(file.name)
|
||||
const textFile = isTextExtension(ext) || mimeType.startsWith('text/') || mimeType.includes('json') || mimeType.includes('xml')
|
||||
if (!textFile) {
|
||||
return {
|
||||
mimeType,
|
||||
size: file.size,
|
||||
storageKind: 'blob',
|
||||
blob: file
|
||||
}
|
||||
}
|
||||
if (file.size <= MAX_TEXT_SIZE) {
|
||||
const content = await file.text()
|
||||
return {
|
||||
mimeType,
|
||||
size: file.size,
|
||||
storageKind: 'text',
|
||||
content,
|
||||
previewText: content,
|
||||
isTruncatedPreview: false
|
||||
}
|
||||
}
|
||||
const previewText = await file.slice(0, PREVIEW_TEXT_SIZE).text()
|
||||
return {
|
||||
mimeType,
|
||||
size: file.size,
|
||||
storageKind: 'blob',
|
||||
blob: file,
|
||||
previewText,
|
||||
isTruncatedPreview: true
|
||||
}
|
||||
}
|
||||
|
||||
function createWelcomeRecords() {
|
||||
const folderId = generateId()
|
||||
const fileId = generateId()
|
||||
const now = Date.now()
|
||||
return [
|
||||
{
|
||||
id: folderId,
|
||||
name: '示例文件夹',
|
||||
type: 'folder',
|
||||
parentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
id: fileId,
|
||||
name: '欢迎使用.md',
|
||||
type: 'file',
|
||||
parentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
mimeType: 'text/markdown',
|
||||
storageKind: 'text',
|
||||
size: 258,
|
||||
content: [
|
||||
'# 欢迎使用文档模式',
|
||||
'',
|
||||
'这里已经切换为更接近 GitHub 的文件浏览体验。',
|
||||
'',
|
||||
'## 现在支持',
|
||||
'',
|
||||
'- 左侧文件树与快速上传',
|
||||
'- 浏览器本地持久化存储',
|
||||
'- 文本、Markdown、图片、PDF 预览',
|
||||
'- 大文件保留原始文件并显示截断预览'
|
||||
].join('\n'),
|
||||
previewText: '',
|
||||
isTruncatedPreview: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function useFileSystem() {
|
||||
const records = ref([])
|
||||
const selectedId = ref(null)
|
||||
const expandedIds = ref(new Set())
|
||||
const clipboard = ref(null)
|
||||
const contextMenu = ref(null)
|
||||
const error = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const tree = computed(() => buildTree(records.value))
|
||||
const stats = computed(() => {
|
||||
let fileCount = 0
|
||||
let folderCount = 0
|
||||
let usedBytes = 0
|
||||
for (const record of records.value) {
|
||||
if (record.type === 'folder') folderCount += 1
|
||||
else fileCount += 1
|
||||
usedBytes += estimateRecordSize(record)
|
||||
}
|
||||
return { fileCount, folderCount, usedBytes }
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const stored = await withStore('readonly', (store) => store.getAll())
|
||||
const request = stored
|
||||
const nextRecords = await new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : [])
|
||||
request.onerror = () => reject(request.error || new Error('读取本地文件失败'))
|
||||
})
|
||||
if (nextRecords.length === 0) {
|
||||
const seed = createWelcomeRecords()
|
||||
await Promise.all(seed.map((record) => persistRecord(record)))
|
||||
records.value = seed
|
||||
} else {
|
||||
records.value = nextRecords
|
||||
}
|
||||
error.value = null
|
||||
} catch {
|
||||
error.value = '读取本地文件失败,请刷新页面后重试'
|
||||
records.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function persistRecord(record) {
|
||||
return withStore('readwrite', (store) => store.put(cloneRecord(record)))
|
||||
}
|
||||
|
||||
async function deleteRecord(id) {
|
||||
return withStore('readwrite', (store) => store.delete(id))
|
||||
}
|
||||
|
||||
function touchParent(parentId) {
|
||||
if (!parentId) return
|
||||
const parent = records.value.find((item) => item.id === parentId)
|
||||
if (!parent) return
|
||||
parent.updatedAt = Date.now()
|
||||
persistRecord(parent).catch(() => {
|
||||
error.value = '更新目录时间失败'
|
||||
})
|
||||
}
|
||||
|
||||
function createFile(parentId, name, content = '', options = {}) {
|
||||
if (records.value.length >= MAX_NODES) {
|
||||
error.value = `文件数量不能超过 ${MAX_NODES} 个`
|
||||
return false
|
||||
}
|
||||
const size = typeof options.size === 'number' ? options.size : new Blob([content]).size
|
||||
if (size > MAX_FILE_SIZE) {
|
||||
error.value = '单个文件不能超过 1GB'
|
||||
return false
|
||||
}
|
||||
const now = Date.now()
|
||||
const file = {
|
||||
id: generateId(),
|
||||
name,
|
||||
type: 'file',
|
||||
parentId: parentId || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
mimeType: inferMimeType(name, options.mimeType),
|
||||
storageKind: options.storageKind || 'text',
|
||||
size,
|
||||
content: options.content ?? content,
|
||||
previewText: options.previewText ?? '',
|
||||
isTruncatedPreview: Boolean(options.isTruncatedPreview),
|
||||
blob: options.blob || null
|
||||
}
|
||||
records.value = [...records.value, file]
|
||||
if (parentId) {
|
||||
const next = new Set(expandedIds.value)
|
||||
next.add(parentId)
|
||||
expandedIds.value = next
|
||||
}
|
||||
selectedId.value = file.id
|
||||
error.value = null
|
||||
persistRecord(file).catch(() => {
|
||||
error.value = '保存文件失败,可能是浏览器存储空间不足'
|
||||
})
|
||||
touchParent(parentId)
|
||||
return true
|
||||
}
|
||||
|
||||
function createFolder(parentId, name) {
|
||||
if (records.value.length >= MAX_NODES) {
|
||||
error.value = `目录项数量不能超过 ${MAX_NODES} 个`
|
||||
return false
|
||||
}
|
||||
const now = Date.now()
|
||||
const folder = {
|
||||
id: generateId(),
|
||||
name,
|
||||
type: 'folder',
|
||||
parentId: parentId || null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
records.value = [...records.value, folder]
|
||||
if (parentId) {
|
||||
const next = new Set(expandedIds.value)
|
||||
next.add(parentId)
|
||||
expandedIds.value = next
|
||||
}
|
||||
selectedId.value = folder.id
|
||||
error.value = null
|
||||
persistRecord(folder).catch(() => {
|
||||
error.value = '保存文件夹失败'
|
||||
})
|
||||
touchParent(parentId)
|
||||
return true
|
||||
}
|
||||
|
||||
function rename(id, newName) {
|
||||
const node = records.value.find((item) => item.id === id)
|
||||
if (!node) return false
|
||||
node.name = newName
|
||||
node.updatedAt = Date.now()
|
||||
error.value = null
|
||||
persistRecord(node).catch(() => {
|
||||
error.value = '重命名失败'
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function collectDescendantIds(id) {
|
||||
const ids = new Set([id])
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const record of records.value) {
|
||||
if (record.parentId && ids.has(record.parentId) && !ids.has(record.id)) {
|
||||
ids.add(record.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const ids = new Set(collectDescendantIds(id))
|
||||
const deletingSelected = selectedId.value && ids.has(selectedId.value)
|
||||
records.value = records.value.filter((item) => !ids.has(item.id))
|
||||
if (deletingSelected) selectedId.value = null
|
||||
if (clipboard.value?.nodeId && ids.has(clipboard.value.nodeId)) {
|
||||
clipboard.value = null
|
||||
}
|
||||
if (clipboard.value?.node?.id && ids.has(clipboard.value.node.id)) {
|
||||
clipboard.value = null
|
||||
}
|
||||
error.value = null
|
||||
ids.forEach((currentId) => {
|
||||
deleteRecord(currentId).catch(() => {
|
||||
error.value = '删除文件失败'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
function toggleFolder(id) {
|
||||
const node = records.value.find((item) => item.id === id)
|
||||
if (!node || node.type !== 'folder') return
|
||||
const next = new Set(expandedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
expandedIds.value = next
|
||||
}
|
||||
|
||||
function copy(id) {
|
||||
const node = findNode(tree.value, id)
|
||||
if (!node) return
|
||||
clipboard.value = {
|
||||
mode: 'copy',
|
||||
node: JSON.parse(JSON.stringify(node))
|
||||
}
|
||||
}
|
||||
|
||||
function cut(id) {
|
||||
const node = records.value.find((item) => item.id === id)
|
||||
if (!node) return
|
||||
clipboard.value = {
|
||||
mode: 'cut',
|
||||
nodeId: node.id
|
||||
}
|
||||
}
|
||||
|
||||
function isDescendantOf(sourceId, targetParentId) {
|
||||
let current = records.value.find((item) => item.id === targetParentId)
|
||||
while (current) {
|
||||
if (current.parentId === sourceId) return true
|
||||
current = current.parentId ? records.value.find((item) => item.id === current.parentId) : null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function duplicateNode(node, targetParentId) {
|
||||
const now = Date.now()
|
||||
const clonedId = generateId()
|
||||
const record = {
|
||||
...cloneRecord(node),
|
||||
id: clonedId,
|
||||
parentId: targetParentId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
records.value = [...records.value, record]
|
||||
persistRecord(record).catch(() => {
|
||||
error.value = '复制文件失败'
|
||||
})
|
||||
if (node.type === 'folder') {
|
||||
for (const child of node.children || []) {
|
||||
duplicateNode(child, clonedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paste(targetParentId) {
|
||||
if (!clipboard.value) return
|
||||
if (clipboard.value.mode === 'cut') {
|
||||
const node = records.value.find((item) => item.id === clipboard.value.nodeId)
|
||||
if (!node) {
|
||||
clipboard.value = null
|
||||
return
|
||||
}
|
||||
if (node.id === targetParentId || (targetParentId && isDescendantOf(node.id, targetParentId))) {
|
||||
error.value = '不能移动到自身或子目录中'
|
||||
return
|
||||
}
|
||||
node.parentId = targetParentId || null
|
||||
node.updatedAt = Date.now()
|
||||
persistRecord(node).catch(() => {
|
||||
error.value = '移动文件失败'
|
||||
})
|
||||
touchParent(targetParentId)
|
||||
clipboard.value = null
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
const source = clipboard.value.node
|
||||
if (!source) return
|
||||
if (records.value.length >= MAX_NODES) {
|
||||
error.value = `目录项数量不能超过 ${MAX_NODES} 个`
|
||||
return
|
||||
}
|
||||
duplicateNode(source, targetParentId || null)
|
||||
touchParent(targetParentId)
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function canPaste() {
|
||||
return clipboard.value !== null
|
||||
}
|
||||
|
||||
function clearClipboard() {
|
||||
clipboard.value = null
|
||||
}
|
||||
|
||||
function getSelectedNode() {
|
||||
return selectedId.value ? findNode(tree.value, selectedId.value) : null
|
||||
}
|
||||
|
||||
function getBreadcrumbPath(id) {
|
||||
return getPath(tree.value, id) || []
|
||||
}
|
||||
|
||||
function showContextMenu(x, y, node) {
|
||||
contextMenu.value = { x, y, node }
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
contextMenu.value = null
|
||||
}
|
||||
|
||||
function getFileIcon(name) {
|
||||
const ext = getExtension(name)
|
||||
const iconMap = {
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
txt: 'text',
|
||||
json: 'json',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
css: 'css',
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
py: 'python',
|
||||
vue: 'vue',
|
||||
xml: 'xml',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
csv: 'csv',
|
||||
log: 'log',
|
||||
sql: 'sql',
|
||||
jpg: 'image',
|
||||
jpeg: 'image',
|
||||
png: 'image',
|
||||
gif: 'image',
|
||||
webp: 'image',
|
||||
svg: 'image',
|
||||
pdf: 'pdf',
|
||||
doc: 'word',
|
||||
docx: 'word',
|
||||
ppt: 'ppt',
|
||||
pptx: 'ppt',
|
||||
xls: 'excel',
|
||||
xlsx: 'excel',
|
||||
zip: 'zip'
|
||||
}
|
||||
return iconMap[ext] || 'file'
|
||||
}
|
||||
|
||||
async function uploadFiles(files, parentId = null) {
|
||||
const source = Array.from(files || [])
|
||||
if (source.length === 0) return { success: 0, failed: [] }
|
||||
const failed = []
|
||||
let success = 0
|
||||
for (const file of source) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
failed.push({ name: file.name, reason: '单个文件不能超过 1GB' })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const payload = await readFilePayload(file)
|
||||
const created = createFile(parentId, file.name, payload.content || '', payload)
|
||||
if (created) success += 1
|
||||
else failed.push({ name: file.name, reason: error.value || '创建文件失败' })
|
||||
} catch {
|
||||
failed.push({ name: file.name, reason: '读取文件失败' })
|
||||
}
|
||||
}
|
||||
if (success > 0 && parentId) {
|
||||
const next = new Set(expandedIds.value)
|
||||
next.add(parentId)
|
||||
expandedIds.value = next
|
||||
}
|
||||
return { success, failed }
|
||||
}
|
||||
|
||||
function getFileBlob(node) {
|
||||
if (!node || node.type !== 'file') return null
|
||||
if (node.blob instanceof Blob) return node.blob
|
||||
if (typeof node.content === 'string') {
|
||||
return new Blob([node.content], { type: inferMimeType(node.name, node.mimeType) })
|
||||
}
|
||||
if (typeof node.previewText === 'string' && node.previewText) {
|
||||
return new Blob([node.previewText], { type: inferMimeType(node.name, node.mimeType) })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
tree,
|
||||
selectedId,
|
||||
expandedIds,
|
||||
clipboard,
|
||||
contextMenu,
|
||||
error,
|
||||
loading,
|
||||
stats,
|
||||
load,
|
||||
createFile,
|
||||
createFolder,
|
||||
rename,
|
||||
remove,
|
||||
select,
|
||||
toggleFolder,
|
||||
copy,
|
||||
cut,
|
||||
paste,
|
||||
canPaste,
|
||||
clearClipboard,
|
||||
getSelectedNode,
|
||||
getBreadcrumbPath,
|
||||
showContextMenu,
|
||||
hideContextMenu,
|
||||
getFileIcon,
|
||||
getExtension,
|
||||
getFileBlob,
|
||||
isTextFile,
|
||||
uploadFiles,
|
||||
MAX_FILE_SIZE,
|
||||
MAX_NODES
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -9,7 +8,6 @@ import '@milkdown/crepe/theme/frame.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {
|
||||
|
||||
@@ -743,12 +743,7 @@ export function interruptCopilot(view: EditorView): void {
|
||||
}
|
||||
|
||||
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
|
||||
let size = view.state.doc.content.size
|
||||
view.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'doc_block' && node.attrs.content) {
|
||||
size += String(node.attrs.content).length
|
||||
}
|
||||
})
|
||||
const size = view.state.doc.content.size
|
||||
return { size, overLimit: size > SIZE_LIMIT }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
let mermaidReadyTheme = ''
|
||||
@@ -24,37 +24,14 @@ function ensureMermaid() {
|
||||
const theme = getMermaidTheme()
|
||||
if (mermaidReadyTheme === theme) return
|
||||
|
||||
const dark = theme === 'dark'
|
||||
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: dark ? 'dark' : 'base',
|
||||
theme: theme || (dark ? 'dark' : 'default'),
|
||||
securityLevel: 'loose',
|
||||
fontFamily: 'inherit',
|
||||
flowchart: { htmlLabels: false },
|
||||
themeVariables: dark ? {
|
||||
primaryColor: '#1e2d45',
|
||||
primaryTextColor: '#c9d6e8',
|
||||
primaryBorderColor: '#3b5278',
|
||||
lineColor: '#5a7aa8',
|
||||
secondaryColor: '#162236',
|
||||
tertiaryColor: '#0f1926',
|
||||
edgeLabelBackground: '#1a2a40',
|
||||
clusterBkg: '#111e2e',
|
||||
titleColor: '#c9d6e8',
|
||||
nodeBorder: '#3b5278',
|
||||
mainBkg: '#1e2d45',
|
||||
} : {
|
||||
primaryColor: '#e8f0fe',
|
||||
primaryTextColor: '#1e3a5f',
|
||||
primaryBorderColor: '#93b4d9',
|
||||
lineColor: '#4a7cb5',
|
||||
secondaryColor: '#dbeafe',
|
||||
tertiaryColor: '#f0f7ff',
|
||||
edgeLabelBackground: '#f0f7ff',
|
||||
clusterBkg: '#f5f8ff',
|
||||
titleColor: '#1e3a5f',
|
||||
nodeBorder: '#93b4d9',
|
||||
mainBkg: '#e8f0fe',
|
||||
flowchart: {
|
||||
htmlLabels: false,
|
||||
},
|
||||
})
|
||||
mermaidReadyTheme = theme
|
||||
@@ -92,26 +69,17 @@ function makeMermaidFilename() {
|
||||
}
|
||||
|
||||
function buildMermaidPreviewMarkup(code: string, token: number) {
|
||||
// 剥离首尾的 ```mermaid 或 ``` 标识符,防止其被误认为图表节点
|
||||
const cleanCode = code
|
||||
.replace(/^```[a-z]*\s*\n?/i, '')
|
||||
.replace(/\n?```\s*$/i, '')
|
||||
.trim()
|
||||
|
||||
const encoded = encodeMermaidCode(cleanCode)
|
||||
const encoded = encodeMermaidCode(code)
|
||||
const filename = makeMermaidFilename()
|
||||
|
||||
const zoomSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`
|
||||
const dlSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><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>`
|
||||
|
||||
return `
|
||||
<div class="mermaid-block" data-mermaid-code="${encoded}" data-mermaid-token="${token}">
|
||||
<div class="mermaid-controls">
|
||||
<button type="button" class="mermaid-action-btn mermaid-zoom-btn" data-mermaid-action="zoom" disabled aria-label="放大查看">${zoomSvg}</button>
|
||||
<button type="button" class="mermaid-action-btn mermaid-download-btn" data-mermaid-action="download" data-mermaid-filename="${filename}" disabled aria-label="下载图表">${dlSvg}<span>下载</span></button>
|
||||
<button type="button" class="mermaid-action-btn" data-mermaid-action="zoom" disabled>Zoom</button>
|
||||
<button type="button" class="mermaid-action-btn" data-mermaid-action="download" data-mermaid-filename="${filename}" disabled>Download PNG</button>
|
||||
</div>
|
||||
<div class="mermaid-inner">
|
||||
<div class="mermaid-loading"><span></span><span></span><span></span></div>
|
||||
<div class="mermaid-loading">...</div>
|
||||
</div>
|
||||
</div>`.trim()
|
||||
}
|
||||
@@ -134,6 +102,7 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
|
||||
}
|
||||
|
||||
if (action === 'download') {
|
||||
node.textContent = 'Download PNG'
|
||||
if (payload) {
|
||||
node.removeAttribute('disabled')
|
||||
node.setAttribute('data-mermaid-url', payload.downloadUrl || payload.previewUrl)
|
||||
@@ -161,7 +130,6 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
|
||||
}
|
||||
|
||||
function getSvgSize(svg: string) {
|
||||
// 优先读取 viewBox (取第三、四个值作为宽高)
|
||||
const viewBox = svg.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
|
||||
if (viewBox) {
|
||||
const width = Number(viewBox[1])
|
||||
@@ -171,16 +139,14 @@ function getSvgSize(svg: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 次选读取数字像素属性
|
||||
const widthAttr = svg.match(/\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/i)
|
||||
const heightAttr = svg.match(/\bheight\s*=\s*["']([\d.]+)(?:px)?["']/i)
|
||||
const attrW = widthAttr ? Number(widthAttr[1]) : 0
|
||||
const attrH = heightAttr ? Number(heightAttr[1]) : 0
|
||||
if (attrW > 0 && attrH > 0) {
|
||||
return { width: attrW, height: attrH }
|
||||
const widthAttr = svg.match(/width\s*=\s*["']([-\d.]+)(px)?["']/i)
|
||||
const heightAttr = svg.match(/height\s*=\s*["']([-\d.]+)(px)?["']/i)
|
||||
const width = widthAttr ? Number(widthAttr[1]) : 960
|
||||
const height = heightAttr ? Number(heightAttr[1]) : 540
|
||||
return {
|
||||
width: Number.isFinite(width) && width > 0 ? width : 960,
|
||||
height: Number.isFinite(height) && height > 0 ? height : 540,
|
||||
}
|
||||
|
||||
return { width: 960, height: 540 }
|
||||
}
|
||||
|
||||
function svgToDataUrl(svg: string) {
|
||||
@@ -193,38 +159,6 @@ function stripExternalSvgResources(svg: string) {
|
||||
.replace(/url\(\s*["']?https?:\/\/[^"')]*["']?\s*\)/gi, 'none')
|
||||
}
|
||||
|
||||
/** 给 SVG viewBox 四周加 padding,同步更新 width/height 并注入字体样式防止截断 */
|
||||
function padSvgViewBox(svg: string, pad = 48): string {
|
||||
let result = svg
|
||||
|
||||
// 注入显式字体样式,确保测量与渲染一致
|
||||
const styleInject = `
|
||||
<style>
|
||||
svg { font-family: 'Inter', system-ui, sans-serif !important; }
|
||||
.node text, .edgeLabel text { font-family: 'Inter', system-ui, sans-serif !important; }
|
||||
</style>`
|
||||
if (result.includes('</style>')) {
|
||||
result = result.replace('</style>', ` svg { font-family: 'Inter', system-ui, sans-serif !important; }\n .node text, .edgeLabel text { font-family: 'Inter', system-ui, sans-serif !important; }\n</style>`)
|
||||
} else {
|
||||
result = result.replace(/>/, `>${styleInject}`)
|
||||
}
|
||||
|
||||
// 匹配 viewBox
|
||||
const vbMatch = result.match(/viewBox\s*=\s*["']\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
|
||||
if (vbMatch) {
|
||||
const x = parseFloat(vbMatch[1]) - pad
|
||||
const y = parseFloat(vbMatch[2]) - pad
|
||||
const w = parseFloat(vbMatch[3]) + pad * 2
|
||||
const h = parseFloat(vbMatch[4]) + pad * 2
|
||||
result = result.replace(vbMatch[0], `viewBox="${x} ${y} ${w} ${h}"`)
|
||||
|
||||
// 覆盖外层 width/height 为实际像素值
|
||||
result = result.replace(/\bwidth\s*=\s*["'][^"']*["']/i, `width="${w}"`)
|
||||
result = result.replace(/\bheight\s*=\s*["'][^"']*["']/i, `height="${h}"`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getRasterDpr(width: number, height: number) {
|
||||
const rawDpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
|
||||
const baseDpr = Math.min(3, Math.max(1, rawDpr))
|
||||
@@ -262,8 +196,7 @@ async function rasterizeSvgToPngDataUrl(svg: string, width: number, height: numb
|
||||
}
|
||||
|
||||
async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
|
||||
const paddedSvg = padSvgViewBox(svg)
|
||||
const fallback = getSvgSize(paddedSvg)
|
||||
const fallback = getSvgSize(svg)
|
||||
const sourceWidth = fallback.width
|
||||
const sourceHeight = fallback.height
|
||||
|
||||
@@ -275,8 +208,8 @@ async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
|
||||
width = Math.max(1, Math.round(width * scale))
|
||||
height = Math.max(1, Math.round(height * scale))
|
||||
|
||||
const normalizedSvg = stripExternalSvgResources(paddedSvg)
|
||||
const candidates = normalizedSvg === paddedSvg ? [paddedSvg] : [paddedSvg, normalizedSvg]
|
||||
const normalizedSvg = stripExternalSvgResources(svg)
|
||||
const candidates = normalizedSvg === svg ? [svg] : [svg, normalizedSvg]
|
||||
let lastError: unknown = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -337,7 +270,7 @@ async function renderMermaidBlock(block: HTMLElement, token: number): Promise<vo
|
||||
const encodedCode = block.getAttribute('data-mermaid-code') || ''
|
||||
const code = decodeMermaidCode(encodedCode).trim() || 'graph TD\nA-->B'
|
||||
|
||||
inner.innerHTML = '<div class="mermaid-loading"><span></span><span></span><span></span></div>'
|
||||
inner.innerHTML = '<div class="mermaid-loading">...</div>'
|
||||
setMermaidActionsState(block, null)
|
||||
|
||||
try {
|
||||
|
||||
+2
-12
@@ -3,18 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Editor',
|
||||
component: () => import('../views/EditorView.vue')
|
||||
},
|
||||
{
|
||||
path: '/docs',
|
||||
name: 'Docs',
|
||||
component: () => import('../views/DocsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/univer',
|
||||
name: 'Univer',
|
||||
component: () => import('../views/UniverView.vue')
|
||||
name: 'Home',
|
||||
component: () => import('../App.vue')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* Office 文件类型检测工具
|
||||
*/
|
||||
import { OfficeFormat, OfficePresetType } from './univerBridge'
|
||||
|
||||
/**
|
||||
* 支持的 Office 文件扩展名
|
||||
*/
|
||||
export const SUPPORTED_EXTENSIONS = {
|
||||
[OfficeFormat.DOCX]: ['.docx'],
|
||||
[OfficeFormat.XLSX]: ['.xlsx'],
|
||||
[OfficeFormat.PPTX]: ['.pptx']
|
||||
}
|
||||
|
||||
/**
|
||||
* MIME 类型映射
|
||||
*/
|
||||
export const MIME_TYPES = {
|
||||
[OfficeFormat.DOCX]: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
[OfficeFormat.XLSX]: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
[OfficeFormat.PPTX]: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测文件是否为 Office 文件
|
||||
*/
|
||||
export function isOfficeFile(file) {
|
||||
if (!file) return false
|
||||
|
||||
const filename = file.name?.toLowerCase() || ''
|
||||
const type = file.type?.toLowerCase() || ''
|
||||
|
||||
// 检查扩展名
|
||||
for (const [format, exts] of Object.entries(SUPPORTED_EXTENSIONS)) {
|
||||
if (exts.some(ext => filename.endsWith(ext))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 MIME 类型
|
||||
for (const [format, mime] of Object.entries(MIME_TYPES)) {
|
||||
if (type === mime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的 Office 格式
|
||||
*/
|
||||
export function getOfficeFormat(file) {
|
||||
if (!file) return null
|
||||
|
||||
const filename = file.name?.toLowerCase() || ''
|
||||
const type = file.type?.toLowerCase() || ''
|
||||
|
||||
// 检查扩展名
|
||||
for (const [format, exts] of Object.entries(SUPPORTED_EXTENSIONS)) {
|
||||
if (exts.some(ext => filename.endsWith(ext))) {
|
||||
return format
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 MIME 类型
|
||||
for (const [format, mime] of Object.entries(MIME_TYPES)) {
|
||||
if (type === mime) {
|
||||
return format
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件图标类型
|
||||
*/
|
||||
export function getOfficeIcon(format) {
|
||||
switch (format) {
|
||||
case OfficeFormat.DOCX:
|
||||
return 'doc'
|
||||
case OfficeFormat.XLSX:
|
||||
return 'xls'
|
||||
case OfficeFormat.PPTX:
|
||||
return 'ppt'
|
||||
default:
|
||||
return 'file'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取格式显示名称
|
||||
*/
|
||||
export function getFormatDisplayName(format, locale = 'zh-CN') {
|
||||
const names = {
|
||||
'zh-CN': {
|
||||
[OfficeFormat.DOCX]: 'Word 文档',
|
||||
[OfficeFormat.XLSX]: 'Excel 表格',
|
||||
[OfficeFormat.PPTX]: 'PowerPoint 演示文稿'
|
||||
},
|
||||
'en-US': {
|
||||
[OfficeFormat.DOCX]: 'Word Document',
|
||||
[OfficeFormat.XLSX]: 'Excel Spreadsheet',
|
||||
[OfficeFormat.PPTX]: 'PowerPoint Presentation'
|
||||
}
|
||||
}
|
||||
|
||||
return names[locale]?.[format] || format?.toUpperCase() || '未知格式'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对应的 Preset 类型
|
||||
*/
|
||||
export function getPresetTypeByFormat(format) {
|
||||
switch (format) {
|
||||
case OfficeFormat.DOCX:
|
||||
case OfficeFormat.PPTX:
|
||||
return OfficePresetType.DOCS
|
||||
case OfficeFormat.XLSX:
|
||||
return OfficePresetType.SHEETS
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
isOfficeFile,
|
||||
getOfficeFormat,
|
||||
getOfficeIcon,
|
||||
getFormatDisplayName,
|
||||
getPresetTypeByFormat,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
MIME_TYPES
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* Univer 编辑器桥接服务
|
||||
* 封装 Univer 的初始化、加载、导出等操作
|
||||
*/
|
||||
import { createUniver, LocaleType, merge } from '@univerjs/presets'
|
||||
import { UniverDocsCorePreset } from '@univerjs/preset-docs-core'
|
||||
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'
|
||||
|
||||
// 导入样式
|
||||
import '@univerjs/preset-docs-core/lib/index.css'
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
// 导入语言包
|
||||
import DocsCoreEnUS from '@univerjs/preset-docs-core/locales/en-US'
|
||||
import SheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US'
|
||||
import DocsCoreZhCN from '@univerjs/preset-docs-core/locales/zh-CN'
|
||||
import SheetsCoreZhCN from '@univerjs/preset-sheets-core/locales/zh-CN'
|
||||
|
||||
export const OfficeFormat = {
|
||||
DOCX: 'docx',
|
||||
XLSX: 'xlsx',
|
||||
PPTX: 'pptx'
|
||||
}
|
||||
|
||||
export const OfficePresetType = {
|
||||
DOCS: 'docs',
|
||||
SHEETS: 'sheets',
|
||||
SLIDES: 'slides'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名判断 Office 格式
|
||||
*/
|
||||
export function detectOfficeFormat(filename) {
|
||||
const ext = filename?.toLowerCase().split('.').pop() || ''
|
||||
if (ext === 'docx') return OfficeFormat.DOCX
|
||||
if (ext === 'xlsx') return OfficeFormat.XLSX
|
||||
if (ext === 'pptx') return OfficeFormat.PPTX
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据格式获取对应的 Preset 类型
|
||||
*/
|
||||
export function getPresetType(format) {
|
||||
switch (format) {
|
||||
case OfficeFormat.DOCX:
|
||||
return OfficePresetType.DOCS
|
||||
case OfficeFormat.XLSX:
|
||||
return OfficePresetType.SHEETS
|
||||
case OfficeFormat.PPTX:
|
||||
return OfficePresetType.SLIDES
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Univer 实例
|
||||
*/
|
||||
export async function createUniverInstance(container, options = {}) {
|
||||
const {
|
||||
format = OfficeFormat.DOCX,
|
||||
locale = 'zh-CN',
|
||||
theme = 'light'
|
||||
} = options
|
||||
|
||||
const localeType = locale === 'zh-CN' ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const locales = locale === 'zh-CN'
|
||||
? { [LocaleType.ZH_CN]: merge(DocsCoreZhCN, SheetsCoreZhCN) }
|
||||
: { [LocaleType.EN_US]: merge(DocsCoreEnUS, SheetsCoreEnUS) }
|
||||
|
||||
const presets = []
|
||||
|
||||
// 根据格式添加对应的 Preset
|
||||
if (format === OfficeFormat.DOCX || format === OfficeFormat.PPTX) {
|
||||
presets.push(UniverDocsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
}
|
||||
|
||||
if (format === OfficeFormat.XLSX) {
|
||||
presets.push(UniverSheetsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
}
|
||||
|
||||
// 默认使用 Docs 作为兜底
|
||||
if (presets.length === 0) {
|
||||
presets.push(UniverDocsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
}
|
||||
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale: localeType,
|
||||
locales,
|
||||
presets,
|
||||
collaboration: false // 纯前端模式,不启用协作
|
||||
})
|
||||
|
||||
return { univer, univerAPI }
|
||||
}
|
||||
|
||||
/**
|
||||
* Univer 编辑器实例包装类
|
||||
*/
|
||||
export class UniverEditorInstance {
|
||||
constructor() {
|
||||
this.univer = null
|
||||
this.univerAPI = null
|
||||
this.container = null
|
||||
this.currentFormat = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化编辑器
|
||||
*/
|
||||
async init(container, options = {}) {
|
||||
if (this.univer) {
|
||||
await this.destroy()
|
||||
}
|
||||
|
||||
this.container = container
|
||||
this.currentFormat = options.format || OfficeFormat.DOCX
|
||||
|
||||
const result = await createUniverInstance(container, {
|
||||
format: this.currentFormat,
|
||||
...options
|
||||
})
|
||||
|
||||
this.univer = result.univer
|
||||
this.univerAPI = result.univerAPI
|
||||
|
||||
// 创建初始文档
|
||||
if (this.currentFormat === OfficeFormat.XLSX) {
|
||||
this.univerAPI.createWorkbook({})
|
||||
} else {
|
||||
this.univerAPI.createUniverDoc({})
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字节数组加载文档
|
||||
*/
|
||||
async loadFromBytes(bytes, format) {
|
||||
if (!this.univerAPI) {
|
||||
throw new Error('Univer 实例未初始化')
|
||||
}
|
||||
|
||||
// 注意:纯前端模式下,Univer 不支持直接从 DOCX/XLSX/PPTX 字节流加载
|
||||
// 这里需要使用快照模式或后端服务来解析
|
||||
// 当前实现为占位,实际需要配合快照格式
|
||||
console.warn('纯前端模式暂不支持从 DOCX/XLSX/PPTX 字节流加载,请使用快照模式')
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为快照数据
|
||||
*/
|
||||
async exportSnapshot() {
|
||||
if (!this.univerAPI) {
|
||||
throw new Error('Univer 实例未初始化')
|
||||
}
|
||||
|
||||
const activeDoc = this.univerAPI.getActiveDocument()
|
||||
const activeSheet = this.univerAPI.getActiveWorkbook()
|
||||
|
||||
if (activeSheet) {
|
||||
return {
|
||||
type: OfficePresetType.SHEETS,
|
||||
format: OfficeFormat.XLSX,
|
||||
data: activeSheet.getSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
if (activeDoc) {
|
||||
return {
|
||||
type: OfficePresetType.DOCS,
|
||||
format: OfficeFormat.DOCX,
|
||||
data: activeDoc.getSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从快照数据导入
|
||||
*/
|
||||
async importSnapshot(snapshot) {
|
||||
if (!this.univerAPI || !snapshot?.data) {
|
||||
throw new Error('无效的快照数据')
|
||||
}
|
||||
|
||||
// 快照数据可以直接用于恢复文档状态
|
||||
// 具体实现取决于 Univer API
|
||||
console.log('导入快照:', snapshot.type, snapshot.format)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听文档变化
|
||||
*/
|
||||
onChange(callback) {
|
||||
if (!this.univerAPI) return
|
||||
|
||||
// Univer API 的事件监听
|
||||
this.univerAPI.addEvent(this.univerAPI.Event.CommandExecuted, (event) => {
|
||||
callback({
|
||||
type: 'command',
|
||||
data: event
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁实例
|
||||
*/
|
||||
async destroy() {
|
||||
if (this.univer) {
|
||||
this.univer.dispose()
|
||||
this.univer = null
|
||||
this.univerAPI = null
|
||||
this.container = null
|
||||
this.currentFormat = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前格式
|
||||
*/
|
||||
getFormat() {
|
||||
return this.currentFormat
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
*/
|
||||
isInitialized() {
|
||||
return this.univer !== null && this.univerAPI !== null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Univer 编辑器实例
|
||||
*/
|
||||
export function createUniverEditor() {
|
||||
return new UniverEditorInstance()
|
||||
}
|
||||
|
||||
export default {
|
||||
createUniverInstance,
|
||||
createUniverEditor,
|
||||
detectOfficeFormat,
|
||||
getPresetType,
|
||||
OfficeFormat,
|
||||
OfficePresetType,
|
||||
UniverEditorInstance
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { OfficeFormat, OfficePresetType } from '../services/univerBridge'
|
||||
|
||||
export const useOfficeStore = defineStore('office', () => {
|
||||
// 当前文档状态
|
||||
const currentFileName = ref('')
|
||||
const currentFormat = ref(null)
|
||||
const currentFileSize = ref(0)
|
||||
const currentBytes = ref(null)
|
||||
|
||||
// 快照模式
|
||||
const isSnapshotMode = ref(true) // 默认启用快照模式
|
||||
const currentSnapshot = ref(null)
|
||||
|
||||
// 编辑状态
|
||||
const isEditing = ref(false)
|
||||
const hasUnsavedChanges = ref(false)
|
||||
|
||||
// 视图状态
|
||||
const activeView = ref('milkdown') // 'milkdown' | 'univer'
|
||||
|
||||
// 计算属性
|
||||
const hasDocument = computed(() => {
|
||||
return currentFileName.value && currentFormat.value
|
||||
})
|
||||
|
||||
const documentInfo = computed(() => {
|
||||
if (!hasDocument.value) return null
|
||||
return {
|
||||
name: currentFileName.value,
|
||||
format: currentFormat.value,
|
||||
size: currentFileSize.value,
|
||||
isSnapshot: isSnapshotMode.value
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 设置当前文档
|
||||
*/
|
||||
function setCurrentDocument(file, bytes) {
|
||||
if (!file) {
|
||||
clearCurrentDocument()
|
||||
return
|
||||
}
|
||||
|
||||
currentFileName.value = file.name || '未命名'
|
||||
currentFormat.value = getFormatFromFileName(file.name)
|
||||
currentFileSize.value = file.size || 0
|
||||
currentBytes.value = bytes
|
||||
hasUnsavedChanges.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前文档
|
||||
*/
|
||||
function clearCurrentDocument() {
|
||||
currentFileName.value = ''
|
||||
currentFormat.value = null
|
||||
currentFileSize.value = 0
|
||||
currentBytes.value = null
|
||||
currentSnapshot.value = null
|
||||
hasUnsavedChanges.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置快照数据
|
||||
*/
|
||||
function setSnapshot(snapshot) {
|
||||
currentSnapshot.value = snapshot
|
||||
hasUnsavedChanges.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记有未保存的更改
|
||||
*/
|
||||
function markAsChanged() {
|
||||
hasUnsavedChanges.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换视图
|
||||
*/
|
||||
function switchView(view) {
|
||||
activeView.value = view
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换快照模式
|
||||
*/
|
||||
function toggleSnapshotMode() {
|
||||
isSnapshotMode.value = !isSnapshotMode.value
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentFileName,
|
||||
currentFormat,
|
||||
currentFileSize,
|
||||
currentBytes,
|
||||
isSnapshotMode,
|
||||
currentSnapshot,
|
||||
isEditing,
|
||||
hasUnsavedChanges,
|
||||
activeView,
|
||||
|
||||
// 计算属性
|
||||
hasDocument,
|
||||
documentInfo,
|
||||
|
||||
// 方法
|
||||
setCurrentDocument,
|
||||
clearCurrentDocument,
|
||||
setSnapshot,
|
||||
markAsChanged,
|
||||
switchView,
|
||||
toggleSnapshotMode
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 从文件名获取格式
|
||||
*/
|
||||
function getFormatFromFileName(filename) {
|
||||
const ext = filename?.toLowerCase().split('.').pop() || ''
|
||||
switch (ext) {
|
||||
case 'docx':
|
||||
return OfficeFormat.DOCX
|
||||
case 'xlsx':
|
||||
return OfficeFormat.XLSX
|
||||
case 'pptx':
|
||||
return OfficeFormat.PPTX
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default useOfficeStore
|
||||
@@ -23,8 +23,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
|
||||
const backgroundImage = ref('')
|
||||
const backgroundOpacity = ref(0.2) // 0.05 - 0.50
|
||||
// TTS Voice
|
||||
const ttsInstruct = ref('')
|
||||
|
||||
// --- Getters ---
|
||||
const uiLanguage = computed(() => {
|
||||
@@ -72,7 +70,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
if (data.backgroundImage) backgroundImage.value = data.backgroundImage
|
||||
if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
|
||||
if (typeof data.ttsInstruct === 'string') ttsInstruct.value = data.ttsInstruct
|
||||
}
|
||||
} catch {
|
||||
// Failed to load settings, use defaults
|
||||
@@ -92,7 +89,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
backgroundType: backgroundType.value,
|
||||
backgroundImage: backgroundImage.value,
|
||||
backgroundOpacity: backgroundOpacity.value,
|
||||
ttsInstruct: ttsInstruct.value,
|
||||
}
|
||||
localStorage.setItem('llm-in-text-settings', JSON.stringify(data))
|
||||
} catch {
|
||||
@@ -111,7 +107,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
backgroundType.value = 'default'
|
||||
backgroundImage.value = ''
|
||||
backgroundOpacity.value = 0.2
|
||||
ttsInstruct.value = ''
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
@@ -127,7 +122,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
backgroundType,
|
||||
backgroundImage,
|
||||
backgroundOpacity,
|
||||
ttsInstruct,
|
||||
],
|
||||
() => {
|
||||
saveSettings()
|
||||
@@ -147,7 +141,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
backgroundType,
|
||||
backgroundImage,
|
||||
backgroundOpacity,
|
||||
ttsInstruct,
|
||||
uiLanguage,
|
||||
t,
|
||||
initialMarkdown,
|
||||
|
||||
+75
-257
@@ -37,21 +37,16 @@
|
||||
--toggle-moon: #475569;
|
||||
--ghost-text: #7d8796;
|
||||
--ghost-code-bg: rgba(15, 23, 42, 0.06);
|
||||
--code-inline-bg: rgba(15, 23, 42, 0.06);
|
||||
--code-block-bg: #f8fafc;
|
||||
--code-block-border: rgba(148, 163, 184, 0.28);
|
||||
--code-text: #0f172a;
|
||||
--mermaid-max-height: 480px;
|
||||
--mermaid-max-height: 420px;
|
||||
--mermaid-mobile-max-height: 320px;
|
||||
--mermaid-glass-bg: rgba(255, 255, 255, 0.55);
|
||||
--mermaid-glass-border: rgba(180, 200, 230, 0.5);
|
||||
--mermaid-glass-border-hover: rgba(59, 130, 246, 0.45);
|
||||
--mermaid-glass-shadow: 0 4px 24px rgba(15, 23, 42, 0.08), 0 1px 4px rgba(15, 23, 42, 0.05);
|
||||
--mermaid-glass-shadow-hover: 0 8px 32px rgba(59, 130, 246, 0.12), 0 2px 8px rgba(15, 23, 42, 0.08);
|
||||
--mermaid-btn-bg: rgba(255, 255, 255, 0.8);
|
||||
--mermaid-btn-hover-bg: rgba(239, 246, 255, 0.95);
|
||||
--mermaid-btn-fg: #374151;
|
||||
--mermaid-btn-border: rgba(180, 200, 230, 0.6);
|
||||
--mermaid-action-bg: linear-gradient(180deg, #ffffff 0%, #f4f7fc 100%);
|
||||
--mermaid-action-hover-bg: linear-gradient(180deg, #ffffff 0%, #e9f2ff 100%);
|
||||
--mermaid-action-fg: #1f2937;
|
||||
--mermaid-action-border: #cfd8e6;
|
||||
--mermaid-action-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
--mermaid-action-shadow-hover: 0 4px 10px rgba(37, 99, 235, 0.16);
|
||||
--mermaid-action-disabled-bg: rgba(148, 163, 184, 0.18);
|
||||
--mermaid-action-disabled-fg: #9aa4b2;
|
||||
|
||||
--crepe-color-background: #ffffff;
|
||||
--crepe-color-on-background: #000000;
|
||||
@@ -72,30 +67,6 @@
|
||||
--crepe-color-inline-area: #cacaca;
|
||||
}
|
||||
|
||||
/* GitHub-like light tokens (used by DocsView.gitHub styled components) */
|
||||
:root {
|
||||
--github-bg: #ffffff;
|
||||
--github-border: #d0d7de;
|
||||
--github-text: #24292f;
|
||||
--github-text-secondary: #57606a;
|
||||
--github-accent: #54aeff;
|
||||
--github-hover: #f6f8fa;
|
||||
--github-selected: #ddf4ff;
|
||||
--github-code-bg: #f6f8fa;
|
||||
}
|
||||
|
||||
/* Dark theme variants for GitHub-like tokens. Keep parity with project colors. */
|
||||
:root[data-theme='dark'] {
|
||||
--github-bg: #0f1117;
|
||||
--github-border: #2f3644;
|
||||
--github-text: #e5e7eb;
|
||||
--github-text-secondary: #a9b0bd;
|
||||
--github-accent: #58a6ff;
|
||||
--github-hover: #1b2130;
|
||||
--github-selected: #1e2a4c;
|
||||
--github-code-bg: #111827;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--app-bg: #0f1117;
|
||||
@@ -124,21 +95,14 @@
|
||||
--toggle-moon: #e2e8f0;
|
||||
--ghost-text: #95a0b4;
|
||||
--ghost-code-bg: rgba(226, 232, 240, 0.12);
|
||||
--code-inline-bg: rgba(30, 41, 59, 0.92);
|
||||
--code-block-bg: rgba(15, 23, 42, 0.92);
|
||||
--code-block-border: rgba(71, 85, 105, 0.55);
|
||||
--code-text: #e2e8f0;
|
||||
--mermaid-max-height: 480px;
|
||||
--mermaid-mobile-max-height: 320px;
|
||||
--mermaid-glass-bg: rgba(20, 28, 44, 0.6);
|
||||
--mermaid-glass-border: rgba(60, 80, 120, 0.4);
|
||||
--mermaid-glass-border-hover: rgba(96, 165, 250, 0.45);
|
||||
--mermaid-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
--mermaid-glass-shadow-hover: 0 8px 32px rgba(96, 165, 250, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--mermaid-btn-bg: rgba(40, 52, 75, 0.85);
|
||||
--mermaid-btn-hover-bg: rgba(55, 70, 100, 0.95);
|
||||
--mermaid-btn-fg: #c9d6e8;
|
||||
--mermaid-btn-border: rgba(70, 95, 140, 0.55);
|
||||
--mermaid-action-bg: linear-gradient(180deg, #30394b 0%, #242c3a 100%);
|
||||
--mermaid-action-hover-bg: linear-gradient(180deg, #3a4760 0%, #2a3445 100%);
|
||||
--mermaid-action-fg: #e5e7eb;
|
||||
--mermaid-action-border: #3e4a61;
|
||||
--mermaid-action-shadow: 0 1px 2px rgba(2, 6, 23, 0.45);
|
||||
--mermaid-action-shadow-hover: 0 5px 12px rgba(2, 6, 23, 0.55);
|
||||
--mermaid-action-disabled-bg: rgba(82, 93, 110, 0.3);
|
||||
--mermaid-action-disabled-fg: #9aa4b2;
|
||||
|
||||
--crepe-color-background: #1a1a1a;
|
||||
--crepe-color-on-background: #e6e6e6;
|
||||
@@ -246,158 +210,114 @@ body {
|
||||
|
||||
/* ── Mermaid diagram blocks ─────────────────────────────────────────── */
|
||||
.mermaid-block {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 1.25em 0;
|
||||
padding: 0;
|
||||
background: var(--mermaid-glass-bg);
|
||||
border: 1px solid var(--mermaid-glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--mermaid-glass-shadow);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: border-color 240ms ease, box-shadow 240ms ease;
|
||||
overflow: hidden;
|
||||
margin: 1em auto;
|
||||
padding: 12px;
|
||||
background: var(--crepe-color-surface, #f7f7f7);
|
||||
border: 1px solid var(--panel-border, #d7deea);
|
||||
border-radius: 10px;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.mermaid-block:hover {
|
||||
border-color: var(--mermaid-glass-border-hover);
|
||||
box-shadow: var(--mermaid-glass-shadow-hover);
|
||||
border-color: var(--focus-ring, #3b82f6);
|
||||
}
|
||||
|
||||
/* 悬浮控制栏:右上角,hover 时渐现 */
|
||||
.mermaid-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mermaid-block:hover .mermaid-controls {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mermaid-action-btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--mermaid-btn-border);
|
||||
background: var(--mermaid-btn-bg);
|
||||
color: var(--mermaid-btn-fg);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.mermaid-zoom-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mermaid-download-btn {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--mermaid-action-border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
background: var(--mermaid-action-bg);
|
||||
color: var(--mermaid-action-fg);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--mermaid-action-shadow);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.mermaid-action-btn:hover:not([disabled]) {
|
||||
background: var(--mermaid-btn-hover-bg);
|
||||
border-color: var(--focus-ring);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.18), 0 2px 8px rgba(0,0,0,0.2);
|
||||
background: var(--mermaid-action-hover-bg);
|
||||
box-shadow: var(--mermaid-action-shadow-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mermaid-action-btn:active:not([disabled]) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.mermaid-action-btn[disabled] {
|
||||
opacity: 0.35;
|
||||
background: var(--mermaid-action-disabled-bg);
|
||||
color: var(--mermaid-action-disabled-fg);
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mermaid-inner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: var(--mermaid-max-height);
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: color-mix(in srgb, var(--crepe-color-background, #fff) 88%, transparent);
|
||||
}
|
||||
|
||||
.mermaid-inner::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.mermaid-inner::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.mermaid-inner::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mermaid-inner::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--scrollbar-thumb-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mermaid-image {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-width: none;
|
||||
margin: 0 auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mermaid-inner svg { display: block; margin: 0 auto; }
|
||||
.mermaid-inner svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 三点弹跳加载动画 */
|
||||
.mermaid-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 36px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: 1.4em;
|
||||
color: var(--muted-text, #6b7280);
|
||||
letter-spacing: 0.2em;
|
||||
animation: mermaid-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mermaid-loading span {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted-text, #6b7280);
|
||||
animation: mermaid-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.mermaid-loading span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.mermaid-loading span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes mermaid-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.55); opacity: 0.35; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
@keyframes mermaid-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.mermaid-error {
|
||||
margin: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
margin: 0;
|
||||
background: color-mix(in srgb, var(--danger-text, #dc2626) 8%, transparent);
|
||||
border: 1px solid var(--danger-text, #dc2626);
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--danger-text, #dc2626);
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
|
||||
@@ -405,122 +325,20 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:root .milkdown .katex {
|
||||
color: var(--crepe-color-on-background);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .katex .mord,
|
||||
:root[data-theme='dark'] .milkdown .katex .mbin,
|
||||
:root[data-theme='dark'] .milkdown .katex .mrel,
|
||||
:root[data-theme='dark'] .milkdown .katex .minner {
|
||||
color: var(--crepe-color-on-background);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .katex .mord.text {
|
||||
:root[data-theme='dark'] .milkdown .katex {
|
||||
color: var(--crepe-color-on-background);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-editor,
|
||||
:root[data-theme='dark'] .milkdown .cm-scroller {
|
||||
background-color: var(--code-block-bg);
|
||||
color: var(--code-text);
|
||||
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
|
||||
color: var(--crepe-color-on-surface);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-gutters {
|
||||
background-color: var(--code-block-bg);
|
||||
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
|
||||
color: var(--crepe-color-on-surface-variant);
|
||||
border-right: 1px solid var(--code-block-border);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-activeLine {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-activeLineGutter {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-selectionBackground,
|
||||
:root[data-theme='dark'] .milkdown .cm-selectionBackground::selection {
|
||||
background-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-cursor {
|
||||
border-left-color: var(--crepe-color-on-background);
|
||||
}
|
||||
|
||||
:root .milkdown .ProseMirror pre,
|
||||
:root .milkdown .ProseMirror code {
|
||||
background: var(--code-inline-bg);
|
||||
color: var(--code-text);
|
||||
border: 1px solid var(--code-block-border);
|
||||
}
|
||||
|
||||
:root .milkdown .ProseMirror pre {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--code-block-bg);
|
||||
}
|
||||
|
||||
:root .milkdown .ProseMirror pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.comment,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.prolog,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.doctype,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.cdata {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.punctuation {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.property,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.tag,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.boolean,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.number,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.constant,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.symbol,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.deleted {
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.selector,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.attr-name,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.string,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.char,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.builtin,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.inserted {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.operator,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.entity,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.url,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .language-css .token.string,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .style .token.string {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.atrule,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.attr-value,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.keyword {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.function,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.class-name {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.regex,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.important,
|
||||
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.variable {
|
||||
color: #fb923c;
|
||||
border-right-color: var(--panel-border);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
+33
-47
@@ -1,4 +1,4 @@
|
||||
import { API_URL, API_KEY, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL } from './config.js'
|
||||
import { API_URL } from './config.js'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
function generateRequestId() {
|
||||
@@ -30,7 +30,6 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_id: requestId,
|
||||
@@ -74,7 +73,6 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Id': requestId,
|
||||
'X-API-Key': API_KEY,
|
||||
}
|
||||
|
||||
const body = {
|
||||
@@ -102,8 +100,38 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data.content || ''
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('No reader available')
|
||||
}
|
||||
|
||||
let text = ''
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += new TextDecoder().decode(value)
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const jsonStr = line.slice(6).trim()
|
||||
if (!jsonStr) continue
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
if (data.content) {
|
||||
text += data.content
|
||||
}
|
||||
if (data.done || data.error) break
|
||||
} catch (e) {
|
||||
// skip invalid lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
// ignore abort
|
||||
@@ -116,45 +144,3 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTTS(text, instruct = '', apiUrl = TTS_URL) {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
},
|
||||
body: JSON.stringify({ text, instruct, speaker: 'Vivian', format: 'wav' }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`TTS HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchTTSStatus(apiUrl = TTS_STATUS_URL) {
|
||||
const res = await fetch(apiUrl, {
|
||||
headers: { 'X-API-Key': API_KEY },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`TTS Status HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchTTSConfig(apiUrl = TTS_CONFIG_URL) {
|
||||
const res = await fetch(apiUrl, {
|
||||
headers: { 'X-API-Key': API_KEY },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`TTS Config HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
+1
-7
@@ -1,13 +1,7 @@
|
||||
export const DEBUG = import.meta.env.DEV
|
||||
|
||||
const DEFAULT_API_BASE_URL = import.meta.env.DEV ? '' : 'https://api.imageteach.tech:8002'
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteach.tech:8002'
|
||||
|
||||
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'
|
||||
export const TTS_URL = import.meta.env.VITE_TTS_URL || `${API_BASE_URL}/v1/tts-asr/tts`
|
||||
export const TTS_STATUS_URL = import.meta.env.VITE_TTS_STATUS_URL || `${API_BASE_URL}/v1/tts-asr/status`
|
||||
export const TTS_CONFIG_URL = import.meta.env.VITE_TTS_CONFIG_URL || `${API_BASE_URL}/v1/tts-asr/config`
|
||||
export const API_KEY = import.meta.env.VITE_API_KEY || 'your-secret-key-here'
|
||||
|
||||
@@ -23,7 +23,6 @@ export async function convertFileToMarkdown(file) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-secret-key-here',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file: base64,
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
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 === 'json') return 'json'
|
||||
if (lower === 'toml') return 'toml'
|
||||
if (lower === 'yaml' || lower === 'yml') return 'yaml'
|
||||
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'
|
||||
if (lower.endsWith('.json')) return 'json'
|
||||
if (lower.endsWith('.toml')) return 'toml'
|
||||
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'
|
||||
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('.json') ||
|
||||
name.endsWith('.toml') ||
|
||||
name.endsWith('.yaml') ||
|
||||
name.endsWith('.yml') ||
|
||||
name.endsWith('.docx') ||
|
||||
name.endsWith('.pptx') ||
|
||||
name.endsWith('.pdf') ||
|
||||
type === 'text/plain' ||
|
||||
type === 'application/json' ||
|
||||
type === 'text/yaml' ||
|
||||
type === 'text/x-yaml' ||
|
||||
type === 'application/x-yaml' ||
|
||||
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)}`
|
||||
})
|
||||
}
|
||||
+17
-236
@@ -33,63 +33,25 @@ export const translations = {
|
||||
exportMd: 'Export Markdown',
|
||||
exportDocx: 'Export DOCX',
|
||||
exportPdf: 'Export PDF',
|
||||
upload: 'Upload',
|
||||
uploadImg: 'Upload Image',
|
||||
uploadFile: 'Upload File',
|
||||
uploadDoc: 'Upload Document',
|
||||
uploadDocTypeWarning: 'Only txt, json, toml, yaml, docx, pptx, pdf formats are supported.',
|
||||
uploadDocTypeWarning: 'Only txt, docx, pptx, pdf formats are supported.',
|
||||
uploadDocSizeWarning: 'File size cannot exceed 10MB.',
|
||||
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
|
||||
uploadDocError: 'Document conversion failed:',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
uploadBatchLimit: 'Maximum 10 files at once',
|
||||
uploadSizeLimit: 'File exceeds 50MB limit',
|
||||
uploading: 'Uploading files...',
|
||||
enableAI: 'Enable AI',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
enableAI: 'Enable AI',
|
||||
disableAI: 'Disable AI',
|
||||
insertUrl: 'Insert Image from URL',
|
||||
insert: 'Insert',
|
||||
cancel: 'Cancel',
|
||||
imgTooLarge: 'Image too large',
|
||||
docTooLarge: 'Document too large, AI disabled',
|
||||
initialMarkdown: '# Welcome to LLM-IN-TEXT\n\nAn instant LLM system\n\nStart your creative work below...',
|
||||
view: 'View',
|
||||
editor: 'Editor',
|
||||
docs: 'Docs',
|
||||
docsManagement: 'Document Management',
|
||||
docsEmptyDesc: 'Document management interface is under development...',
|
||||
files: 'Files',
|
||||
noFiles: '暂无文件',
|
||||
newFile: '新建文件',
|
||||
newFolder: '新建文件夹',
|
||||
untitledFile: 'untitled.md',
|
||||
untitledFolder: '新建文件夹',
|
||||
rename: '重命名',
|
||||
delete: '删除',
|
||||
copy: '复制',
|
||||
cut: '剪切',
|
||||
paste: '粘贴',
|
||||
confirmDelete: '确认删除',
|
||||
confirmDeleteDesc: '确定要删除',
|
||||
confirmDeleteFolderDesc: '此操作将删除文件夹内的所有内容。',
|
||||
confirmDeleteFileDesc: '此操作不可撤销。',
|
||||
cancel: '取消',
|
||||
rootDir: '根目录',
|
||||
expandSidebar: '展开侧边栏',
|
||||
collapseSidebar: '收起侧边栏',
|
||||
fileLimitReached: '文件数量已达上限',
|
||||
folderLimitReached: '文件夹数量已达上限',
|
||||
fileSizeLimit: '文件大小不能超过 50MB',
|
||||
storageError: '存储空间不足',
|
||||
selectFileToView: '选择一个文件以查看内容',
|
||||
folderContains: '包含',
|
||||
items: '个项目',
|
||||
unsupportedPreview: '暂不支持预览此文件类型',
|
||||
fileNamePlaceholder: '文件名.md',
|
||||
folderNamePlaceholder: '文件夹名'
|
||||
initialMarkdown: '# Welcome to LLM-IN-TEXT\n\nAn instant LLM system\n\nStart your creative work below...'
|
||||
},
|
||||
zh: {
|
||||
settings: '设置',
|
||||
@@ -125,63 +87,25 @@ export const translations = {
|
||||
exportMd: '导出 Markdown',
|
||||
exportDocx: '导出 DOCX',
|
||||
exportPdf: '导出 PDF',
|
||||
upload: '上传',
|
||||
uploadImg: '上传图片',
|
||||
uploadFile: '上传文件',
|
||||
uploadDoc: '上传文档',
|
||||
uploadDocTypeWarning: '仅支持 txt、json、toml、yaml、docx、pptx、pdf 格式的文档',
|
||||
uploadDocTypeWarning: '仅支持 txt、docx、pptx、pdf 格式的文档',
|
||||
uploadDocSizeWarning: '文件大小不能超过 10MB',
|
||||
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
|
||||
uploadDocError: '文档转换失败:',
|
||||
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
|
||||
uploadMdTypeWarning: '仅支持 Markdown(.md)和图片文件。',
|
||||
uploadFileError: '文件上传失败',
|
||||
uploadConvertError: '文件转换失败',
|
||||
uploadBatchLimit: '一次最多上传10个文件',
|
||||
uploadSizeLimit: '文件超过50MB限制',
|
||||
uploading: '正在上传文件...',
|
||||
enableAI: '启用 AI',
|
||||
uploadFileError: '文件上传失败',
|
||||
uploadConvertError: '文件转换失败',
|
||||
enableAI: '启用 AI',
|
||||
disableAI: '禁用 AI',
|
||||
insertUrl: '通过 URL 插入图片',
|
||||
insert: '插入',
|
||||
cancel: '取消',
|
||||
imgTooLarge: '图片过大',
|
||||
docTooLarge: '文档过大,AI已禁用',
|
||||
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...',
|
||||
view: '视图',
|
||||
editor: '编辑器',
|
||||
docs: '文档',
|
||||
docsManagement: '文档管理',
|
||||
docsEmptyDesc: '文档管理界面开发中...',
|
||||
files: '文件',
|
||||
noFiles: '暂无文件',
|
||||
newFile: '新建文件',
|
||||
newFolder: '新建文件夹',
|
||||
untitledFile: '未命名.md',
|
||||
untitledFolder: '新建文件夹',
|
||||
rename: '重命名',
|
||||
delete: '删除',
|
||||
copy: '复制',
|
||||
cut: '剪切',
|
||||
paste: '粘贴',
|
||||
confirmDelete: '确认删除',
|
||||
confirmDeleteDesc: '确定要删除',
|
||||
confirmDeleteFolderDesc: '此操作将删除文件夹内的所有内容。',
|
||||
confirmDeleteFileDesc: '此操作不可撤销。',
|
||||
cancel: '取消',
|
||||
rootDir: '根目录',
|
||||
expandSidebar: '展开侧边栏',
|
||||
collapseSidebar: '收起侧边栏',
|
||||
fileLimitReached: '文件数量已达上限',
|
||||
folderLimitReached: '文件夹数量已达上限',
|
||||
fileSizeLimit: '文件大小不能超过 50MB',
|
||||
storageError: '存储空间不足',
|
||||
selectFileToView: '选择一个文件以查看内容',
|
||||
folderContains: '包含',
|
||||
items: '个项目',
|
||||
unsupportedPreview: '暂不支持预览此文件类型',
|
||||
fileNamePlaceholder: '文件名.md',
|
||||
folderNamePlaceholder: '文件夹名'
|
||||
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...'
|
||||
},
|
||||
ja: {
|
||||
settings: '設定',
|
||||
@@ -217,58 +141,20 @@ export const translations = {
|
||||
exportMd: 'Markdownをエクスポート',
|
||||
exportDocx: 'DOCXをエクスポート',
|
||||
exportPdf: 'PDFをエクスポート',
|
||||
upload: 'アップロード',
|
||||
uploadImg: '画像をアップロード',
|
||||
uploadFile: 'Upload File',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
uploadBatchLimit: 'Maximum 10 files at once',
|
||||
uploadSizeLimit: 'File exceeds 50MB limit',
|
||||
uploading: 'Uploading files...',
|
||||
enableAI: 'AIを有効化',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
enableAI: 'AIを有効化',
|
||||
disableAI: 'AIを無効化',
|
||||
insertUrl: 'URLから画像を挿入',
|
||||
insert: '挿入',
|
||||
cancel: 'キャンセル',
|
||||
imgTooLarge: '画像が大きすぎます',
|
||||
docTooLarge: 'ドキュメントが大きすぎます、AI無効',
|
||||
initialMarkdown: '# LLM-IN-TEXTへようこそ\n\nすぐに使えるLLMシステム\n\n下から創作を始めましょう...',
|
||||
view: 'ビュー',
|
||||
editor: 'エディター',
|
||||
docs: 'ドキュメント',
|
||||
docsManagement: 'ドキュメント管理',
|
||||
docsEmptyDesc: 'ドキュメント管理画面は開発中です...',
|
||||
files: 'ファイル',
|
||||
noFiles: 'ファイルはありません',
|
||||
newFile: '新規ファイル',
|
||||
newFolder: '新規フォルダー',
|
||||
untitledFile: '無題.md',
|
||||
untitledFolder: '新しいフォルダー',
|
||||
rename: '名前を変更',
|
||||
delete: '削除',
|
||||
copy: 'コピー',
|
||||
cut: '切り取り',
|
||||
paste: '貼り付け',
|
||||
confirmDelete: '削除の確認',
|
||||
confirmDeleteDesc: '本当に削除しますか',
|
||||
confirmDeleteFolderDesc: 'フォルダー内のすべてのコンテンツが削除されます。',
|
||||
confirmDeleteFileDesc: 'この操作は元に戻せません。',
|
||||
cancel: 'キャンセル',
|
||||
rootDir: 'ルートディレクトリ',
|
||||
expandSidebar: 'サイドバーを展開',
|
||||
collapseSidebar: 'サイドバーを折りたたむ',
|
||||
fileLimitReached: 'ファイル数の上限に達しました',
|
||||
folderLimitReached: 'フォルダー数の上限に達しました',
|
||||
fileSizeLimit: 'ファイルサイズは50MBを超えられません',
|
||||
storageError: 'ストレージが不足しています',
|
||||
selectFileToView: 'ファイルを選択して内容を表示',
|
||||
folderContains: '含む',
|
||||
items: '項目',
|
||||
unsupportedPreview: 'このファイルタイプはプレビューに対応していません',
|
||||
fileNamePlaceholder: 'ファイル名.md',
|
||||
folderNamePlaceholder: 'フォルダー名'
|
||||
initialMarkdown: '# LLM-IN-TEXTへようこそ\n\nすぐに使えるLLMシステム\n\n下から創作を始めましょう...'
|
||||
},
|
||||
ko: {
|
||||
settings: '설정',
|
||||
@@ -304,7 +190,6 @@ export const translations = {
|
||||
exportMd: 'Markdown 내보내기',
|
||||
exportDocx: 'DOCX 내보내기',
|
||||
exportPdf: 'PDF 내보내기',
|
||||
upload: '업로드',
|
||||
uploadImg: '이미지 업로드',
|
||||
uploadFile: 'Upload File',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
@@ -318,41 +203,7 @@ export const translations = {
|
||||
cancel: '취소',
|
||||
imgTooLarge: '이미지가 너무 큽니다',
|
||||
docTooLarge: '문서가 너무 큽니다, AI 비활성화됨',
|
||||
initialMarkdown: '# LLM-IN-TEXT에 오신 것을 환영합니다\n\n즉시 사용할 수 있는 LLM 시스템\n\n아래에서 창작을 시작하세요...',
|
||||
view: '보기',
|
||||
editor: '에디터',
|
||||
docs: '문서',
|
||||
docsManagement: '문서 관리',
|
||||
docsEmptyDesc: '문서 관리 화면은 개발 중입니다...',
|
||||
files: '파일',
|
||||
noFiles: '파일이 없습니다',
|
||||
newFile: '새 파일',
|
||||
newFolder: '새 폴더',
|
||||
untitledFile: '제목 없음.md',
|
||||
untitledFolder: '새 폴더',
|
||||
rename: '이름 변경',
|
||||
delete: '삭제',
|
||||
copy: '복사',
|
||||
cut: '잘라내기',
|
||||
paste: '붙여넣기',
|
||||
confirmDelete: '삭제 확인',
|
||||
confirmDeleteDesc: '정말 삭제하시겠습니까',
|
||||
confirmDeleteFolderDesc: '폴더의 모든 콘텐츠가 삭제됩니다.',
|
||||
confirmDeleteFileDesc: '이 작업은 취소할 수 없습니다.',
|
||||
cancel: '취소',
|
||||
rootDir: '루트 디렉토리',
|
||||
expandSidebar: '사이드바 펼치기',
|
||||
collapseSidebar: '사이드바 접기',
|
||||
fileLimitReached: '파일 수上限에 도달했습니다',
|
||||
folderLimitReached: '폴더 수上限에 도달했습니다',
|
||||
fileSizeLimit: '파일 크기는 50MB를 초과할 수 없습니다',
|
||||
storageError: '저장 공간이 부족합니다',
|
||||
selectFileToView: '파일을 선택하여 내용 보기',
|
||||
folderContains: '포함',
|
||||
items: '항목',
|
||||
unsupportedPreview: '이 파일 유형은 미리보기를 지원하지 않습니다',
|
||||
fileNamePlaceholder: '파일명.md',
|
||||
folderNamePlaceholder: '폴더명'
|
||||
initialMarkdown: '# LLM-IN-TEXT에 오신 것을 환영합니다\n\n즉시 사용할 수 있는 LLM 시스템\n\n아래에서 창작을 시작하세요...'
|
||||
},
|
||||
de: {
|
||||
settings: 'Einstellungen',
|
||||
@@ -388,7 +239,6 @@ export const translations = {
|
||||
exportMd: 'Markdown exportieren',
|
||||
exportDocx: 'DOCX exportieren',
|
||||
exportPdf: 'PDF exportieren',
|
||||
upload: 'Hochladen',
|
||||
uploadImg: 'Bild hochladen',
|
||||
uploadFile: 'Upload File',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
@@ -402,41 +252,7 @@ export const translations = {
|
||||
cancel: 'Abbrechen',
|
||||
imgTooLarge: 'Bild zu groß',
|
||||
docTooLarge: 'Dokument zu groß, KI deaktiviert',
|
||||
initialMarkdown: '# Willkommen bei LLM-IN-TEXT\n\nEin sofort verfügbares LLM-System\n\nStarten Sie Ihre kreative Arbeit unten...',
|
||||
view: 'Ansicht',
|
||||
editor: 'Editor',
|
||||
docs: 'Dokumente',
|
||||
docsManagement: 'Dokumentenverwaltung',
|
||||
docsEmptyDesc: 'Die Dokumentenverwaltung ist in Entwicklung...',
|
||||
files: 'Dateien',
|
||||
noFiles: 'Keine Dateien',
|
||||
newFile: 'Neue Datei',
|
||||
newFolder: 'Neuer Ordner',
|
||||
untitledFile: 'Unbenannt.md',
|
||||
untitledFolder: 'Neuer Ordner',
|
||||
rename: 'Umbenennen',
|
||||
delete: 'Löschen',
|
||||
copy: 'Kopieren',
|
||||
cut: 'Ausschneiden',
|
||||
paste: 'Einfügen',
|
||||
confirmDelete: 'Löschen bestätigen',
|
||||
confirmDeleteDesc: 'Möchten Sie wirklich löschen',
|
||||
confirmDeleteFolderDesc: 'Dies entfernt alle Inhalte im Ordner.',
|
||||
confirmDeleteFileDesc: 'Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
cancel: 'Abbrechen',
|
||||
rootDir: 'Stammverzeichnis',
|
||||
expandSidebar: 'Seitenleiste erweitern',
|
||||
collapseSidebar: 'Seitenleiste einklappen',
|
||||
fileLimitReached: 'Maximale Dateianzahl erreicht',
|
||||
folderLimitReached: 'Maximale Ordneranzahl erreicht',
|
||||
fileSizeLimit: 'Dateigröße darf 50MB nicht überschreiten',
|
||||
storageError: 'Speicherplatz unzureichend',
|
||||
selectFileToView: 'Datei auswählen zum Anzeigen',
|
||||
folderContains: 'Enthält',
|
||||
items: 'Elemente',
|
||||
unsupportedPreview: 'Dieser Dateityp wird nicht in der Vorschau unterstützt',
|
||||
fileNamePlaceholder: 'Dateiname.md',
|
||||
folderNamePlaceholder: 'Ordnername'
|
||||
initialMarkdown: '# Willkommen bei LLM-IN-TEXT\n\nEin sofort verfügbares LLM-System\n\nStarten Sie Ihre kreative Arbeit unten...'
|
||||
},
|
||||
fr: {
|
||||
settings: 'Paramètres',
|
||||
@@ -472,7 +288,6 @@ export const translations = {
|
||||
exportMd: 'Exporter Markdown',
|
||||
exportDocx: 'Exporter DOCX',
|
||||
exportPdf: 'Exporter PDF',
|
||||
upload: 'Télécharger',
|
||||
uploadImg: 'Télécharger image',
|
||||
uploadFile: 'Upload File',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
@@ -486,40 +301,6 @@ export const translations = {
|
||||
cancel: 'Annuler',
|
||||
imgTooLarge: 'Image trop grande',
|
||||
docTooLarge: 'Document trop grand, IA désactivée',
|
||||
initialMarkdown: '# Bienvenue sur LLM-IN-TEXT\n\nUn système LLM instantané\n\nCommencez votre création ci-dessous...',
|
||||
view: 'Vue',
|
||||
editor: 'Éditeur',
|
||||
docs: 'Documents',
|
||||
docsManagement: 'Gestion des documents',
|
||||
docsEmptyDesc: 'L\'interface de gestion des documents est en développement...',
|
||||
files: 'Fichiers',
|
||||
noFiles: 'Aucun fichier',
|
||||
newFile: 'Nouveau fichier',
|
||||
newFolder: 'Nouveau dossier',
|
||||
untitledFile: 'Sans titre.md',
|
||||
untitledFolder: 'Nouveau dossier',
|
||||
rename: 'Renommer',
|
||||
delete: 'Supprimer',
|
||||
copy: 'Copier',
|
||||
cut: 'Couper',
|
||||
paste: 'Coller',
|
||||
confirmDelete: 'Confirmer la suppression',
|
||||
confirmDeleteDesc: 'Êtes-vous sûr de vouloir supprimer',
|
||||
confirmDeleteFolderDesc: 'Cela supprimera tout le contenu du dossier.',
|
||||
confirmDeleteFileDesc: 'Cette action est irréversible.',
|
||||
cancel: 'Annuler',
|
||||
rootDir: 'Répertoire racine',
|
||||
expandSidebar: 'Développer la barre latérale',
|
||||
collapseSidebar: 'Réduire la barre latérale',
|
||||
fileLimitReached: 'Nombre maximum de fichiers atteint',
|
||||
folderLimitReached: 'Nombre maximum de dossiers atteint',
|
||||
fileSizeLimit: 'La taille du fichier ne peut pas dépasser 50 Mo',
|
||||
storageError: 'Espace de stockage insuffisant',
|
||||
selectFileToView: 'Sélectionnez un fichier pour afficher le contenu',
|
||||
folderContains: 'Contient',
|
||||
items: 'éléments',
|
||||
unsupportedPreview: 'Ce type de fichier n\'est pas pris en charge pour l\'aperçu',
|
||||
fileNamePlaceholder: 'Nom du fichier.md',
|
||||
folderNamePlaceholder: 'Nom du dossier'
|
||||
initialMarkdown: '# Bienvenue sur LLM-IN-TEXT\n\nUn système LLM instantané\n\nCommencez votre création ci-dessous...'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import FileTree from '../components/FileTree.vue'
|
||||
import FileContent from '../components/FileContent.vue'
|
||||
import ContextMenu from '../components/ContextMenu.vue'
|
||||
import { useFileSystem } from '../composables/useFileSystem'
|
||||
|
||||
const fs = useFileSystem()
|
||||
const sidebarCollapsed = ref(false)
|
||||
const confirmDialog = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
fs.load()
|
||||
})
|
||||
|
||||
const selectedNode = computed(() => fs.getSelectedNode())
|
||||
const breadcrumb = computed(() => (fs.selectedId.value ? fs.getBreadcrumbPath(fs.selectedId.value) : []))
|
||||
const storageSummary = computed(() => {
|
||||
const bytes = fs.stats.value.usedBytes
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
return `${value >= 100 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`
|
||||
})
|
||||
|
||||
function handleCreateFile(parentId, name = 'untitled.md') {
|
||||
fs.createFile(parentId, name)
|
||||
}
|
||||
|
||||
function handleCreateFolder(parentId, name = '新建文件夹') {
|
||||
fs.createFolder(parentId, name)
|
||||
}
|
||||
|
||||
function handleRename(id, newName) {
|
||||
fs.rename(id, newName)
|
||||
}
|
||||
|
||||
function findNode(nodes, id) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node
|
||||
if (node.type === 'folder') {
|
||||
const found = findNode(node.children || [], id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function handleDelete(id) {
|
||||
const node = findNode(fs.tree.value, id)
|
||||
if (!node) return
|
||||
confirmDialog.value = {
|
||||
id,
|
||||
name: node.name,
|
||||
type: node.type
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!confirmDialog.value) return
|
||||
fs.remove(confirmDialog.value.id)
|
||||
confirmDialog.value = null
|
||||
}
|
||||
|
||||
function handleContextMenu(x, y, node) {
|
||||
fs.showContextMenu(x, y, node)
|
||||
}
|
||||
|
||||
function handleDrop(draggedId, targetParentId) {
|
||||
if (draggedId === targetParentId) return
|
||||
fs.cut(draggedId)
|
||||
fs.paste(targetParentId)
|
||||
}
|
||||
|
||||
async function handleUploadFiles(files) {
|
||||
const parentId = selectedNode.value?.type === 'folder' ? selectedNode.value.id : selectedNode.value?.parentId || null
|
||||
const result = await fs.uploadFiles(files, parentId)
|
||||
if (result.failed.length > 0) {
|
||||
fs.error.value = result.failed.map((item) => `${item.name}:${item.reason}`).join(';')
|
||||
}
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
confirmDialog.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-view">
|
||||
<div class="docs-layout">
|
||||
<aside v-show="!sidebarCollapsed" class="docs-sidebar">
|
||||
<FileTree
|
||||
:nodes="fs.tree.value"
|
||||
:selected-id="fs.selectedId.value"
|
||||
:expanded-ids="fs.expandedIds.value"
|
||||
:clipboard="fs.clipboard.value"
|
||||
:get-file-icon="fs.getFileIcon"
|
||||
:loading="fs.loading.value"
|
||||
:stats="fs.stats.value"
|
||||
@select="fs.select"
|
||||
@toggle="fs.toggleFolder"
|
||||
@create-file="handleCreateFile"
|
||||
@create-folder="handleCreateFolder"
|
||||
@rename="handleRename"
|
||||
@remove="handleDelete"
|
||||
@copy="fs.copy"
|
||||
@cut="fs.cut"
|
||||
@paste="fs.paste"
|
||||
@context-menu="handleContextMenu"
|
||||
@drop="handleDrop"
|
||||
@drag-start="() => {}"
|
||||
@drag-over="() => {}"
|
||||
@upload-files="handleUploadFiles"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section class="docs-main">
|
||||
<header class="docs-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button
|
||||
class="sidebar-toggle"
|
||||
type="button"
|
||||
:title="sidebarCollapsed ? '展开左侧栏' : '收起左侧栏'"
|
||||
@click="sidebarCollapsed = !sidebarCollapsed"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor"><path d="M2.75 2A1.75 1.75 0 001 3.75v8.5C1 13.216 1.784 14 2.75 14h10.5A1.75 1.75 0 0015 12.25v-8.5A1.75 1.75 0 0013.25 2H2.75zm0 1.5h2.5v9h-2.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25zm4 9v-9h6.5a.25.25 0 01.25.25v8.5a.25.25 0 01-.25.25h-6.5z"/></svg>
|
||||
</button>
|
||||
<div class="toolbar-breadcrumb">
|
||||
<button class="crumb-link" type="button" @click="fs.select(null)">workspace</button>
|
||||
<template v-for="item in breadcrumb" :key="item.id">
|
||||
<span class="crumb-sep">/</span>
|
||||
<button
|
||||
v-if="item.id !== selectedNode?.id"
|
||||
class="crumb-link"
|
||||
type="button"
|
||||
@click="fs.select(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
<span v-else class="crumb-current">{{ item.name }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<span class="storage-pill">本地存储 {{ storageSummary }}</span>
|
||||
<button
|
||||
v-if="fs.canPaste()"
|
||||
class="toolbar-btn"
|
||||
type="button"
|
||||
@click="fs.paste(selectedNode?.type === 'folder' ? selectedNode.id : selectedNode?.parentId || null)"
|
||||
>
|
||||
粘贴
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<FileContent
|
||||
:node="selectedNode"
|
||||
:breadcrumb="breadcrumb"
|
||||
:root-nodes="fs.tree.value"
|
||||
:get-file-icon="fs.getFileIcon"
|
||||
:get-file-blob="fs.getFileBlob"
|
||||
@navigate="fs.select"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
:visible="!!fs.contextMenu.value"
|
||||
:x="fs.contextMenu.value?.x || 0"
|
||||
:y="fs.contextMenu.value?.y || 0"
|
||||
:node="fs.contextMenu.value?.node || null"
|
||||
:can-paste="fs.canPaste()"
|
||||
@close="fs.hideContextMenu()"
|
||||
@rename="fs.hideContextMenu()"
|
||||
@delete="(id) => { fs.hideContextMenu(); handleDelete(id) }"
|
||||
@copy="(id) => { fs.hideContextMenu(); fs.copy(id) }"
|
||||
@cut="(id) => { fs.hideContextMenu(); fs.cut(id) }"
|
||||
@paste="(parentId) => { fs.hideContextMenu(); fs.paste(parentId) }"
|
||||
@new-file="(parentId) => { fs.hideContextMenu(); handleCreateFile(parentId) }"
|
||||
@new-folder="(parentId) => { fs.hideContextMenu(); handleCreateFolder(parentId) }"
|
||||
/>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="confirmDialog" class="confirm-overlay">
|
||||
<div class="confirm-dialog">
|
||||
<h3>确认删除</h3>
|
||||
<p>
|
||||
确定要删除 <strong>{{ confirmDialog.name }}</strong>
|
||||
{{ confirmDialog.type === 'folder' ? ' 以及其中的全部内容' : '' }} 吗?
|
||||
</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn btn-secondary" type="button" @click="closeConfirm">取消</button>
|
||||
<button class="btn btn-danger" type="button" @click="confirmDelete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="fs.error.value" class="error-toast">
|
||||
<div class="error-content">
|
||||
<span>{{ fs.error.value }}</span>
|
||||
<button class="error-close" type="button" @click="fs.error.value = null">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.docs-view {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--github-bg);
|
||||
}
|
||||
|
||||
.docs-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.docs-sidebar {
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--github-border);
|
||||
background: var(--github-bg);
|
||||
}
|
||||
|
||||
.docs-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.docs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 56px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid var(--github-border);
|
||||
background: var(--github-bg);
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right,
|
||||
.toolbar-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-breadcrumb {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar-toggle,
|
||||
.toolbar-btn,
|
||||
.crumb-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 8px;
|
||||
color: var(--github-text-secondary);
|
||||
background: var(--github-bg);
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover,
|
||||
.toolbar-btn:hover {
|
||||
background: var(--github-hover);
|
||||
}
|
||||
|
||||
.crumb-link {
|
||||
color: #0969da;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crumb-current {
|
||||
color: var(--github-text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.crumb-sep {
|
||||
color: var(--github-text-secondary);
|
||||
}
|
||||
|
||||
.storage-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 999px;
|
||||
background: var(--github-hover);
|
||||
color: var(--github-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 8px;
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
padding: 24px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 16px;
|
||||
background: var(--github-bg);
|
||||
box-shadow: 0 28px 60px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.confirm-dialog h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.confirm-dialog p {
|
||||
margin: 0;
|
||||
color: var(--github-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--github-border);
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--github-bg);
|
||||
color: var(--github-text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #cf222e;
|
||||
background: #cf222e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: min(720px, calc(100vw - 32px));
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: #cf222e;
|
||||
color: #fff;
|
||||
box-shadow: 0 18px 40px rgba(207, 34, 46, 0.28);
|
||||
}
|
||||
|
||||
.error-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.docs-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.docs-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
width: min(88vw, 320px);
|
||||
box-shadow: 20px 0 40px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.docs-toolbar {
|
||||
padding: 10px 14px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const MilkdownEditor = defineAsyncComponent(() => import('../components/MilkdownEditor.vue'))
|
||||
|
||||
const markdown = ref('')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-view">
|
||||
<MilkdownEditor v-model:markdown="markdown" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-view {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -1,46 +0,0 @@
|
||||
<template>
|
||||
<div class="univer-view">
|
||||
<UniverEditor
|
||||
ref="editorRef"
|
||||
@back="handleBack"
|
||||
@document-loaded="handleDocumentLoaded"
|
||||
@document-changed="handleDocumentChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOfficeStore } from '../stores/office'
|
||||
import UniverEditor from '../components/UniverEditor.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const officeStore = useOfficeStore()
|
||||
const editorRef = ref(null)
|
||||
|
||||
function handleBack() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function handleDocumentLoaded(doc) {
|
||||
console.log('文档已加载:', doc)
|
||||
}
|
||||
|
||||
function handleDocumentChanged(event) {
|
||||
console.log('文档已更改:', event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 设置当前视图为 univer
|
||||
officeStore.switchView('univer')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.univer-view {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
import asyncio
|
||||
from backend.tts_asr import _tts_sync_with_retry, _load_asr_pipeline_with_retry, _asr_pipeline
|
||||
import base64
|
||||
|
||||
async def main():
|
||||
text = "早上好"
|
||||
print(f"Testing TTS with text: {text}")
|
||||
audio_bytes, sr = await _tts_sync_with_retry(text, rate=1.0)
|
||||
print(f"TTS generated {len(audio_bytes)} bytes of audio.")
|
||||
|
||||
print("Testing ASR...")
|
||||
await _load_asr_pipeline_with_retry()
|
||||
asr = _asr_pipeline
|
||||
|
||||
# Needs to process audio_bytes. ASR expects float32 numpy array or bytes?
|
||||
# the pipeline takes bytes or dict with raw array
|
||||
result = asr(audio_bytes)
|
||||
print("ASR output:", result)
|
||||
|
||||
asyncio.run(main())
|
||||
+1
-1
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: 'https://api.imageteach.tech:8002',
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user