Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05b1cbf80d |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"label": "Run",
|
||||||
|
"command": "npm run dev",
|
||||||
|
"icon": "play"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
-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
-15
@@ -1,4 +1,4 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -23,9 +23,6 @@ env/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
htmlcov/
|
|
||||||
.coverage
|
|
||||||
api_performance_report.md
|
|
||||||
|
|
||||||
# Env files
|
# Env files
|
||||||
.env
|
.env
|
||||||
@@ -42,14 +39,3 @@ api_performance_report.md
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
|
||||||
# IDE directories
|
|
||||||
.kilocode/
|
|
||||||
.kilo/
|
|
||||||
.codex/
|
|
||||||
|
|
||||||
# Agent/runtime state and local verification artifacts
|
|
||||||
.omx/
|
|
||||||
.tmp-*.png
|
|
||||||
tmp-*.txt
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# rules.md
|
||||||
|
|
||||||
|
在构建这个LLM应用网页时,你需要基于VUE3开发。我需要前端只运行渲染和数据回传,后端负责llm api调用,类似copilet的auto inline suggustions实现和数据解析。
|
||||||
|
|
||||||
|
## 指导原则
|
||||||
|
|
||||||
|
- 不要擅自用npm或者yarn运行网页,你既看不到网页的内容,也无法阻止命令暂停。但是,你可以用npm run build检查代码。
|
||||||
|
- 应该保证代码效率,不多定义变量,不写冗余注释,把降低延迟放在第一位。
|
||||||
|
- 每次完成任务前都要反复阅读检查代码,确保代码准确无误。
|
||||||
|
- 尽量不要搜索关键字,而是了解代码结构后查询整个问题代码明确问题所在。
|
||||||
|
- @/milkdown-docs/ 代表milkdown的最新官方文档,不要修改,涉及到前端编辑器的指令时要核对官方文档。
|
||||||
@@ -1,101 +1,52 @@
|
|||||||
# LLM in Text 仓库指引
|
# 仓库指南
|
||||||
|
|
||||||
本文件适用于整个仓库。进入更深层目录后,子目录中的 AGENTS.md 优先于本文件。
|
## 语言约定
|
||||||
|
项目文档、日志、错误提示以及对外返回的文字信息统一使用 **中文**。前端 UI 默认展示中文,若需多语言支持请在相应模块实现。
|
||||||
|
|
||||||
## 项目定位
|
## 项目结构 \& 模块组织
|
||||||
|
```
|
||||||
|
backend/ # FastAPI 后端(Python)
|
||||||
|
├─ main.py # API 入口
|
||||||
|
├─ llm.py # LLM 包装工具
|
||||||
|
├─ prompt.py # Prompt 构建辅助
|
||||||
|
└─ tests/ # pytest 测试套件
|
||||||
|
public/ # 前端静态资源
|
||||||
|
src/ # 前端源码(Vite + React)
|
||||||
|
dist/ # 构建产出(生成文件)
|
||||||
|
```
|
||||||
|
生产代码主要位于 `backend/`(Python)和 `src/`(JS/TS)。测试文件与被测模块并置。
|
||||||
|
|
||||||
- 这是一个智能 Markdown 编辑器,前端负责编辑器 UI、上传导出、补全交互和设置状态,后端负责 LLM、OCR、文件转换和 TTS 接口。
|
## 构建、测试、开发命令
|
||||||
- 前端技术栈:Vue 3 + Vite + Milkdown/Crepe + Pinia + Vue Router。
|
| 命令 | 说明 |
|
||||||
- 后端技术栈:FastAPI + Python + Ollama。
|
|----------------------------------------------|--------------------------------------------------|
|
||||||
- 当前代码中可以确认的主功能是:AI 补全、OCR、文档转 Markdown、TTS、Markdown/DOCX/PDF 导入导出。
|
| `npm install` | 安装前端依赖 |
|
||||||
- 历史文档中有一部分 TTS/ASR、Apple Silicon、Whisper、离线模式说明已经落后于当前代码;出现冲突时以实际代码和测试为准。
|
| `npm run dev` | 启动 Vite 开发服务器 |
|
||||||
|
| `uvicorn backend.main:app --reload` | 本地运行 FastAPI 服务 |
|
||||||
|
| `pytest` | 运行 Python 测试套件 |
|
||||||
|
| `npm run build` | 生成生产环境构建产物至 `dist/` |
|
||||||
|
|
||||||
## 先看哪里
|
## 编码风格 \& 命名约定
|
||||||
|
- **Python**:使用 4 空格缩进,`snake_case` 命名函数/变量,`PascalCase` 命名类。提交前请使用 `ruff`/`black` 格式化。
|
||||||
|
- **JavaScript/TypeScript**:使用 2 空格缩进,`camelCase` 命名变量/函数,`PascalCase` 命名 React 组件。使用 `eslint` 与 `prettier` 检查。
|
||||||
|
- 文件名采用全小写加短横线,例如 `my-module.py`、`my-component.tsx`。
|
||||||
|
|
||||||
- 项目概览和运行说明:README.md
|
## 测试指南
|
||||||
- 前端入口:src/main.js
|
- 后端使用 **pytest**,测试文件放在对应模块目录下,命名为 `test_<module>.py`。
|
||||||
- 路由:src/router/index.js
|
- 目标覆盖率 ≥ 80%(`pytest --cov=backend`)。
|
||||||
- 编辑器主组件:src/components/MilkdownEditor.vue
|
- 在虚拟环境中运行:`pip install -r backend/requirements.txt && pytest`。
|
||||||
- AI 补全核心:src/plugins/copilotPlugin.ts
|
|
||||||
- 前端请求层:src/utils/api.js
|
|
||||||
- 前端配置:src/utils/config.js
|
|
||||||
- 设置状态:src/stores/settings.js
|
|
||||||
- 后端入口和主路由:backend/main.py
|
|
||||||
- LLM 和 OCR 调用:backend/llm.py
|
|
||||||
- Prompt 组装:backend/prompt.py
|
|
||||||
- TTS 路由:backend/tts_asr.py
|
|
||||||
- 测试配置和入口:pytest.ini、backend/tests/run_tests.py
|
|
||||||
|
|
||||||
## 稳定事实
|
## 提交 \& Pull Request 规范
|
||||||
|
- 提交信息遵循 **Conventional Commits**:`feat:` 新功能、`fix:` 修复、`docs:` 文档、`refactor:` 重构等。
|
||||||
|
- PR 必须包含:
|
||||||
|
- 与提交信息匹配的标题。
|
||||||
|
- 关联的 Issue(如 `Fixes #123`)。
|
||||||
|
- UI 变更或 API 示例的截图/示例。
|
||||||
|
- 所有 CI 检查(代码检查、测试、类型检查)均通过。
|
||||||
|
|
||||||
- 补全接口当前不是 SSE;前端用普通 POST 请求拿 JSON 响应。
|
## 安全 \& 配置建议
|
||||||
- 前端会生成 X-Request-Id,并在请求被中止时额外调用 /v1/completions/cancel。
|
- 敏感信息请放入 `.env` 并确保已在 `.gitignore` 中。
|
||||||
- 文档超过 32 KB 时,AI 补全会在前端和插件层被禁用。
|
- 按照 `backend/main.py` 中的实现,对上传文件的大小和类型进行校验,防止滥用。
|
||||||
- OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出。
|
- 定期审计依赖安全(`npm audit`、`pip-audit`)。
|
||||||
- /v1/convert 当前支持 txt、docx、pptx、pdf,非 txt 文件通过 MarkItDown 转成 Markdown,之后会清理图片标记。
|
|
||||||
- 前端存在 /v1/export/pdf 调用点,但当前后端主路由中看不到同名端点;排查 PDF 导出问题前先确认服务端是否真正提供该接口。
|
|
||||||
- 当前 tts_asr.py 主要提供 TTS 相关能力。不要直接沿用 README 或历史修复文档里关于 ASR、Whisper、MPS/offline 的描述。
|
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
- 前端安装:npm install
|
|
||||||
- 前端开发:npm run dev
|
|
||||||
- 前端构建:npm run build
|
|
||||||
- 后端安装:pip install -r backend/requirements.txt
|
|
||||||
- 后端启动:python backend/main.py
|
|
||||||
- 可选启动方式:uvicorn backend.main:app --reload --port 8001
|
|
||||||
- 全量测试:pytest
|
|
||||||
- 常用窄测试:
|
|
||||||
- pytest backend/tests/test_main_endpoints.py -v
|
|
||||||
- pytest backend/tests/test_main_cancel.py -v
|
|
||||||
- pytest backend/tests/test_prompt.py -v
|
|
||||||
- pytest backend/tests/test_llm.py -v
|
|
||||||
|
|
||||||
## 代码约定
|
|
||||||
|
|
||||||
- 不要把整个仓库当成“全小写+短横线命名”项目。当前实际情况是:
|
|
||||||
- Vue 组件和视图多为 PascalCase
|
|
||||||
- 前端工具模块多为小写 .js
|
|
||||||
- 插件层使用 TypeScript
|
|
||||||
- Python 使用 snake_case
|
|
||||||
- 以就地风格为准,不要顺手做全仓格式统一。
|
|
||||||
- UI 文案和代理回复默认使用中文。
|
|
||||||
- 不要修改 milkdown-docs/,它是只读参考资料。
|
|
||||||
- 不要新增硬编码密钥、空 catch/except、as any、@ts-ignore 之类的扩散式技术债。
|
|
||||||
- 代理在这个仓库里应优先做局部、可验证的修改,不要做无关重构。
|
|
||||||
|
|
||||||
## 调试路径
|
|
||||||
|
|
||||||
- 补全问题:
|
|
||||||
src/components/MilkdownEditor.vue
|
|
||||||
-> src/plugins/copilotPlugin.ts
|
|
||||||
-> src/utils/api.js
|
|
||||||
-> backend/main.py
|
|
||||||
-> backend/prompt.py / backend/llm.py
|
|
||||||
|
|
||||||
- OCR 问题:
|
|
||||||
src/components/MilkdownEditor.vue
|
|
||||||
-> backend/main.py
|
|
||||||
-> backend/llm.py
|
|
||||||
|
|
||||||
- 文档转换问题:
|
|
||||||
src/utils/convert.js
|
|
||||||
-> backend/main.py
|
|
||||||
|
|
||||||
- TTS 问题:
|
|
||||||
src/components/TTSMenu.vue / src/components/TTSPlayer.vue / src/components/MilkdownEditor.vue
|
|
||||||
-> src/utils/api.js
|
|
||||||
-> backend/tts_asr.py
|
|
||||||
|
|
||||||
## 测试和产物
|
|
||||||
|
|
||||||
- pytest.ini 对 backend.main、backend.llm、backend.prompt、backend.geoip、backend.prompts、backend.tts_asr 设了覆盖率门槛,低于 90% 会失败。
|
|
||||||
- 默认测试目录是 backend/tests。
|
|
||||||
- 常见生成产物包括 dist、htmlcov、.pytest_cache、api_performance_report.md;它们不是源代码。
|
|
||||||
|
|
||||||
## 文档注意事项
|
|
||||||
|
|
||||||
- README.md 对产品功能有参考价值,但其中补全、TTS/ASR 和部分接口说明已经比代码旧。
|
|
||||||
- backend/TTS_ASR_MACOS_FIX.md 和 backend/tests/TESTING_GUIDE.md 更适合作为历史背景,不应在与代码冲突时被当成事实来源。
|
|
||||||
- 修改行为时,优先参考实现代码和对应测试,再决定是否同步普通文档。
|
|
||||||
|
|
||||||
|
---
|
||||||
|
以上指南旨在保持贡献一致性并维护代码库健康,欢迎通过 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 - 智能写作助手
|
# LLM in Text - 智能写作助手
|
||||||
|
|
||||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能。
|
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验。
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### Markdown 编辑器
|
### Markdown 编辑器
|
||||||
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
||||||
- 支持 Markdown 语法和 LaTeX 公式
|
- 支持完整 Markdown 语法和 LaTeX 公式
|
||||||
- 支持 Mermaid 图表渲染
|
|
||||||
- 导入/导出 Markdown 文件
|
- 导入/导出 Markdown 文件
|
||||||
- 导出 DOCX 和 PDF 格式
|
|
||||||
|
|
||||||
### AI 智能补全
|
### AI 智能补全
|
||||||
- 实时生成文本补全建议(灰色显示)
|
- 实时生成文本补全建议(灰色显示)
|
||||||
- 流式响应,低延迟体验
|
- 流式响应,低延迟体验
|
||||||
- 多种交互方式:Tab接受、Esc拒绝、点击接受
|
- 多种交互方式:
|
||||||
|
- **Tab 键**:接受建议
|
||||||
|
- **Esc 键**:拒绝建议
|
||||||
|
- **点击灰色文本**:接受建议
|
||||||
|
- **继续输入**:自动拒绝建议
|
||||||
|
|
||||||
### 文档处理
|
### AI 开关控制
|
||||||
- OCR 图片识别:上传图片自动识别文字
|
- 右下角 AI 开关按钮
|
||||||
- 文档转换:PDF、DOCX、PPTX、TXT 转 Markdown
|
- 白色 = AI 启用,黑色 = AI 禁用
|
||||||
- 文档块嵌入:可折叠的文档预览块
|
- 禁用时自动清除灰色文本并停止 API 调用
|
||||||
- 智能大小限制:32KB自动禁用AI
|
|
||||||
|
|
||||||
### 设置面板
|
|
||||||
- 外观主题:亮色/暗色/跟随系统
|
|
||||||
- 背景模式:默认/暖色/阅读灯/自定义图片
|
|
||||||
- 模型智能:低/中/高思考级别
|
|
||||||
- 隐私控制:隐私模式防止发送IP
|
|
||||||
- 多语言界面:中英日韩德法
|
|
||||||
|
|
||||||
### 语音功能
|
|
||||||
- TTS文字转语音(macOS优化,支持Apple Silicon M1/M2/M3)
|
|
||||||
- STT语音转文字(支持多种模型大小和量化)
|
|
||||||
- 自动设备检测(MPS/CUDA/CPU智能切换)
|
|
||||||
- 离线模式支持(模型缓存检查)
|
|
||||||
|
|
||||||
## 技术架构
|
## 技术架构
|
||||||
|
|
||||||
前端: Vue3 + Vite + Milkdown + ProseMirror
|
```mermaid
|
||||||
后端: FastAPI + Python + Ollama
|
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
|
|
||||||
|
|
||||||
启动:
|
```bash
|
||||||
- 后端: python backend/main.py (端口8001)
|
# 前端
|
||||||
- 前端: npm run dev (端口5173)
|
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优化建议**:
|
```bash
|
||||||
- 系统自动检测Apple Silicon并推荐使用`small`模型
|
# 后端(端口 8000)
|
||||||
- MPS内存限制默认为系统内存的60%
|
cd backend
|
||||||
- 建议使用`small`或`medium`模型以获得更好的性能
|
python main.py
|
||||||
- 可通过`TTS_ASR_MODEL_SIZE=medium`手动指定模型大小
|
|
||||||
|
# 前端(端口 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量化支持
|
|
||||||
- 离线模式支持
|
|
||||||
- 健壮的音频重采样
|
|
||||||
|
|
||||||
### 前端
|
#### main.py - FastAPI 服务器
|
||||||
- copilotPlugin.ts: ProseMirror Mark系统
|
- 定义 `/v1/completions` 端点
|
||||||
- 关键函数: scheduleFetch、insertGhostText
|
- 使用 `StreamingResponse` 返回 SSE 流式响应
|
||||||
- Pinia Store状态管理
|
- 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. 前后端分离
|
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
|
||||||
2. 低延迟优化:防抖+SSE+AbortController
|
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求
|
||||||
3. ProseMirror Mark系统
|
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
|
||||||
4. 多种交互方式
|
4. **多种交互方式**:Tab/Esc/点击/输入,用户体验友好
|
||||||
5. 智能大小限制
|
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能
|
||||||
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,121 +0,0 @@
|
|||||||
# Backend 后端指引
|
|
||||||
|
|
||||||
本文件适用于 backend/ 下的后端实现。进入 backend/tests/ 后,以子目录 AGENTS.md 为准。
|
|
||||||
|
|
||||||
## 后端职责
|
|
||||||
|
|
||||||
- 对外提供补全、取消补全、OCR、文档转换和 TTS 相关接口。
|
|
||||||
- 组织 Prompt,上下文清洗,调用 Ollama 模型。
|
|
||||||
- 负责 API Key 校验、日志记录和部分启动预热逻辑。
|
|
||||||
|
|
||||||
## 先看哪里
|
|
||||||
|
|
||||||
- API 入口和路由:main.py
|
|
||||||
- Ollama 调用封装:llm.py
|
|
||||||
- Prompt 清洗和拼装:prompt.py
|
|
||||||
- 数据模型:models.py
|
|
||||||
- 地理位置:geoip.py
|
|
||||||
- TTS 路由:tts_asr.py
|
|
||||||
- Prompt 模板:prompts/
|
|
||||||
- 后端测试:tests/
|
|
||||||
|
|
||||||
## 当前接口面
|
|
||||||
|
|
||||||
- POST /v1/completions
|
|
||||||
- POST /v1/completions/cancel
|
|
||||||
- POST /v1/ocr
|
|
||||||
- POST /v1/convert
|
|
||||||
- /v1/tts-asr/* 由 tts_asr.py 延迟注册
|
|
||||||
|
|
||||||
## 请求流转
|
|
||||||
|
|
||||||
### /v1/completions
|
|
||||||
|
|
||||||
- 读取或生成 request_id。
|
|
||||||
- privacy_mode 为 false 时,尝试根据客户端 IP 生成 location 文本。
|
|
||||||
- 调用 prepare_prompt_context 清洗 prefix 和 suffix。
|
|
||||||
- 调用 build_completion_prompts 生成 system_prompt 和 user_prompt。
|
|
||||||
- 创建异步任务调用 call_ollama。
|
|
||||||
- 用 request_id 把任务登记到 ACTIVE_COMPLETIONS。
|
|
||||||
- 成功时返回 JSON:content 和 request_id。
|
|
||||||
- finally 中清理当前 request_id 对应任务。
|
|
||||||
|
|
||||||
### /v1/completions/cancel
|
|
||||||
|
|
||||||
- 通过 request_id 在 ACTIVE_COMPLETIONS 中查找任务。
|
|
||||||
- 未找到返回 not_found。
|
|
||||||
- 已完成返回 already_done。
|
|
||||||
- 仍在执行则调用 task.cancel() 并返回 ok。
|
|
||||||
|
|
||||||
### /v1/ocr
|
|
||||||
|
|
||||||
- 把 base64 图片解码成字节。
|
|
||||||
- 调用 call_vlm_ocr。
|
|
||||||
- 返回识别文本和原始文件名。
|
|
||||||
|
|
||||||
### /v1/convert
|
|
||||||
|
|
||||||
- 接收 base64 文件内容和文件名。
|
|
||||||
- 当前允许的扩展名只有 txt、docx、pptx、pdf。
|
|
||||||
- txt 直接解码后清洗。
|
|
||||||
- 其他格式写入临时文件,用 MarkItDown 转换,再做 Markdown 清洗。
|
|
||||||
- 清洗逻辑会移除图片 Markdown 和 img HTML 标签,并压缩多余空行。
|
|
||||||
|
|
||||||
### /v1/tts-asr/*
|
|
||||||
|
|
||||||
- 通过 _register_tts_asr_routes 延迟导入并挂到主应用。
|
|
||||||
- 当前代码里的 tts_asr.py 主要是 TTS 能力,不要自行假设存在完整 ASR 实现。
|
|
||||||
|
|
||||||
## 开发命令
|
|
||||||
|
|
||||||
- 安装依赖:pip install -r backend/requirements.txt
|
|
||||||
- 启动:python backend/main.py
|
|
||||||
- 开发启动:uvicorn backend.main:app --reload --port 8001
|
|
||||||
- 路由相关测试:
|
|
||||||
- pytest backend/tests/test_main_endpoints.py -v
|
|
||||||
- pytest backend/tests/test_main_cancel.py -v
|
|
||||||
- Prompt 测试:
|
|
||||||
- pytest backend/tests/test_prompt.py -v
|
|
||||||
- pytest backend/tests/test_prompt_extended.py -v
|
|
||||||
- LLM 测试:
|
|
||||||
- pytest backend/tests/test_llm.py -v
|
|
||||||
- pytest backend/tests/test_llm_extended.py -v
|
|
||||||
|
|
||||||
## 编码约定
|
|
||||||
|
|
||||||
- Python 使用 4 空格缩进。
|
|
||||||
- 函数、变量使用 snake_case,类使用 PascalCase。
|
|
||||||
- 新逻辑优先保留显式类型和明确的输入输出。
|
|
||||||
- 异步边界要清晰;阻塞操作优先放进 asyncio.to_thread,而不是直接阻塞事件循环。
|
|
||||||
- 异常要么转成 HTTPException,要么转成结构化 JSONResponse;不要静默吞掉后端错误。
|
|
||||||
- 日志尽量带 request_id 或短 tag,便于把前后端一次请求串起来。
|
|
||||||
|
|
||||||
## 容易误判的点
|
|
||||||
|
|
||||||
- 补全接口当前不是流式响应,不要按 SSE 方式改造周边代码。
|
|
||||||
- ACTIVE_COMPLETIONS 在补全和取消路径里都被读写,任务生命周期要谨慎处理。
|
|
||||||
- main.py 里虽然有 _convert_docx_to_pdf 辅助函数,但当前 /v1/convert 路径实际走的是 MarkItDown,不要误以为 DOCX 转 PDF 桥接脚本已接入主流程。
|
|
||||||
- API_KEY 存在占位默认值,这更像本地开发兜底,不是推荐的安全模式。
|
|
||||||
- 历史 TTS/ASR 文档和部分测试覆盖的是旧实现;代码与文档冲突时,先确认产品方向,再决定修代码还是修文档。
|
|
||||||
|
|
||||||
## 改动时的定位建议
|
|
||||||
|
|
||||||
- 如果问题是补全结果不对,先查 prompt.py,再查 llm.py,不要只盯着 main.py。
|
|
||||||
- 如果问题是取消不生效,先查 main.py 里的 request_id 生命周期,再对照前端的 X-Request-Id 和 cancel 调用。
|
|
||||||
- 如果问题是 OCR 识别为空,先看 main.py 的 base64 解码,再看 llm.py 的 call_vlm_ocr。
|
|
||||||
- 如果问题是转换结果脏,重点看 main.py 里的 _sanitize_converted_markdown。
|
|
||||||
- 如果问题是 TTS 行为和文档不一致,以 tts_asr.py 为准,不要以 README 为准。
|
|
||||||
|
|
||||||
## 测试映射
|
|
||||||
|
|
||||||
- 路由主行为:tests/test_main_endpoints.py
|
|
||||||
- 取消逻辑:tests/test_main_cancel.py
|
|
||||||
- Prompt 逻辑:tests/test_prompt.py、tests/test_prompt_extended.py
|
|
||||||
- LLM 包装层:tests/test_llm.py、tests/test_llm_extended.py
|
|
||||||
- GeoIP:tests/test_geoip.py
|
|
||||||
- TTS 相关:tests/test_tts_asr_*.py
|
|
||||||
|
|
||||||
## 文档使用原则
|
|
||||||
|
|
||||||
- README.md、TTS_ASR_MACOS_FIX.md、tests/TESTING_GUIDE.md 可以作为背景材料。
|
|
||||||
- 一旦这些文档和 main.py、llm.py、prompt.py、tts_asr.py 冲突,以代码为准。
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+50
-179
@@ -3,26 +3,47 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import AsyncIterator
|
|
||||||
import ollama
|
import ollama
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from prompts import get_vlm_ocr_prompt
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||||
PRO_OLLAMA_MODEL = os.getenv('PRO_OLLAMA_MODEL', OLLAMA_MODEL)
|
|
||||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
||||||
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
||||||
|
|
||||||
# Timeouts in seconds (10 minutes for large model loading)
|
# Timeouts in seconds
|
||||||
COMPLETION_TIMEOUT = 600
|
COMPLETION_TIMEOUT = 30
|
||||||
OCR_TIMEOUT = 600
|
OCR_TIMEOUT = 60
|
||||||
|
CONVERT_TIMEOUT = 30
|
||||||
|
|
||||||
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
||||||
logger = logging.getLogger("llm")
|
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]:
|
def _extract_message(response) -> tuple[str, str]:
|
||||||
content = ""
|
content = ""
|
||||||
@@ -31,93 +52,31 @@ def _extract_message(response) -> tuple[str, str]:
|
|||||||
if hasattr(response, 'message') and response.message:
|
if hasattr(response, 'message') and response.message:
|
||||||
content = response.message.content or ""
|
content = response.message.content or ""
|
||||||
thinking = getattr(response.message, 'thinking', '') or ""
|
thinking = getattr(response.message, 'thinking', '') or ""
|
||||||
elif isinstance(response, dict) and 'message' in response:
|
elif isinstance(response, dict):
|
||||||
msg = response.get('message', {})
|
msg = response.get('message', {})
|
||||||
content = msg.get('content', '') or ""
|
content = msg.get('content', '') or ""
|
||||||
thinking = msg.get('thinking', '') or ""
|
thinking = msg.get('thinking', '') or ""
|
||||||
|
|
||||||
# fallback for generate
|
|
||||||
if not content:
|
|
||||||
if hasattr(response, 'response'):
|
|
||||||
content = getattr(response, 'response', '') or ""
|
|
||||||
elif isinstance(response, dict) and 'response' in response:
|
|
||||||
content = response.get('response', '') or ""
|
|
||||||
|
|
||||||
return content, thinking
|
return content, thinking
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(prompt: str, system_prompt: str | None = None) -> str:
|
|
||||||
if system_prompt and system_prompt.strip():
|
|
||||||
return f"{system_prompt}\n\n{prompt}"
|
|
||||||
return prompt
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str:
|
|
||||||
candidate = (model or '').strip()
|
|
||||||
if candidate:
|
|
||||||
return candidate
|
|
||||||
return PRO_OLLAMA_MODEL if use_pro_model else OLLAMA_MODEL
|
|
||||||
|
|
||||||
|
|
||||||
def _build_generate_kwargs(
|
|
||||||
prompt: str,
|
|
||||||
*,
|
|
||||||
system_prompt: str | None = None,
|
|
||||||
temperature: float = 0.7,
|
|
||||||
thinking: str | None = None,
|
|
||||||
model: str | None = None,
|
|
||||||
use_pro_model: bool = False,
|
|
||||||
stream: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
kwargs = {
|
|
||||||
"model": _resolve_model_name(model, use_pro_model=use_pro_model),
|
|
||||||
"prompt": _build_prompt(prompt, system_prompt),
|
|
||||||
"stream": stream,
|
|
||||||
"raw": True,
|
|
||||||
"options": {
|
|
||||||
'temperature': temperature,
|
|
||||||
'repeat_penalty': 1.1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if thinking:
|
|
||||||
kwargs["think"] = thinking
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_stream_text(chunk) -> str:
|
|
||||||
content, _ = _extract_message(chunk)
|
|
||||||
if content:
|
|
||||||
return content
|
|
||||||
|
|
||||||
if isinstance(chunk, dict):
|
|
||||||
return chunk.get('response', '') or ''
|
|
||||||
|
|
||||||
if hasattr(chunk, 'response'):
|
|
||||||
return getattr(chunk, 'response', '') or ''
|
|
||||||
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
async def call_ollama(
|
async def call_ollama(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
*,
|
*,
|
||||||
system_prompt: str | None = None,
|
system_prompt: str = None,
|
||||||
tag: str = "default",
|
tag: str = "default",
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking: str | None = None,
|
thinking: str = None,
|
||||||
model: str | None = None,
|
|
||||||
use_pro_model: bool = False,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
调用 Ollama API 并返回 content 和 thinking。
|
调用 Ollama API 并返回 content 和 thinking。
|
||||||
"""
|
"""
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
start_dt = datetime.now()
|
start_dt = datetime.now()
|
||||||
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
|
"[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
|
||||||
tag,
|
tag,
|
||||||
model_name,
|
OLLAMA_MODEL,
|
||||||
OLLAMA_HOST,
|
OLLAMA_HOST,
|
||||||
len(prompt),
|
len(prompt),
|
||||||
len(system_prompt or ""),
|
len(system_prompt or ""),
|
||||||
@@ -126,17 +85,24 @@ async def call_ollama(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
kwargs = _build_generate_kwargs(
|
messages = []
|
||||||
prompt,
|
if system_prompt and system_prompt.strip():
|
||||||
system_prompt=system_prompt,
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
temperature=temperature,
|
messages.append({"role": "user", "content": prompt})
|
||||||
thinking=thinking,
|
|
||||||
model=model,
|
|
||||||
use_pro_model=use_pro_model,
|
|
||||||
stream=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await asyncio.wait_for(client.generate(**kwargs), timeout=COMPLETION_TIMEOUT)
|
kwargs = {
|
||||||
|
"model": OLLAMA_MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
'temperature': temperature,
|
||||||
|
'repeat_penalty': 1.1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if thinking:
|
||||||
|
kwargs["think"] = thinking
|
||||||
|
|
||||||
|
response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||||
end_dt = datetime.now()
|
end_dt = datetime.now()
|
||||||
@@ -183,101 +149,6 @@ async def call_ollama(
|
|||||||
|
|
||||||
return {"content": content, "think": thinking}
|
return {"content": content, "think": thinking}
|
||||||
|
|
||||||
|
|
||||||
async def stream_ollama(
|
|
||||||
prompt: str,
|
|
||||||
*,
|
|
||||||
system_prompt: str | None = None,
|
|
||||||
tag: str = "default-stream",
|
|
||||||
temperature: float = 0.7,
|
|
||||||
thinking: str | None = None,
|
|
||||||
model: str | None = None,
|
|
||||||
use_pro_model: bool = False,
|
|
||||||
) -> AsyncIterator[str]:
|
|
||||||
start = time.perf_counter()
|
|
||||||
start_dt = datetime.now()
|
|
||||||
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
|
|
||||||
yielded_chars = 0
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[LLM][%s] stream request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
|
|
||||||
tag,
|
|
||||||
model_name,
|
|
||||||
OLLAMA_HOST,
|
|
||||||
len(prompt),
|
|
||||||
len(system_prompt or ""),
|
|
||||||
temperature,
|
|
||||||
thinking,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
kwargs = _build_generate_kwargs(
|
|
||||||
prompt,
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
temperature=temperature,
|
|
||||||
thinking=thinking,
|
|
||||||
model=model,
|
|
||||||
use_pro_model=use_pro_model,
|
|
||||||
stream=True,
|
|
||||||
)
|
|
||||||
stream = await client.generate(**kwargs)
|
|
||||||
iterator = stream.__aiter__()
|
|
||||||
deadline = time.perf_counter() + COMPLETION_TIMEOUT
|
|
||||||
|
|
||||||
while True:
|
|
||||||
remaining = deadline - time.perf_counter()
|
|
||||||
if remaining <= 0:
|
|
||||||
raise TimeoutError("LLM stream timed out")
|
|
||||||
|
|
||||||
try:
|
|
||||||
chunk = await asyncio.wait_for(iterator.__anext__(), timeout=remaining)
|
|
||||||
except StopAsyncIteration:
|
|
||||||
break
|
|
||||||
|
|
||||||
text = _extract_stream_text(chunk)
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
|
|
||||||
yielded_chars += len(text)
|
|
||||||
yield text
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
||||||
end_dt = datetime.now()
|
|
||||||
logger.info(
|
|
||||||
"[LLM][%s] stream_time [%s --> %s]",
|
|
||||||
tag,
|
|
||||||
start_dt.strftime("%H:%M:%S"),
|
|
||||||
end_dt.strftime("%H:%M:%S"),
|
|
||||||
)
|
|
||||||
logger.warning("[LLM][%s] stream cancelled after %.1fms", tag, elapsed_ms)
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
||||||
end_dt = datetime.now()
|
|
||||||
logger.info(
|
|
||||||
"[LLM][%s] stream_time [%s --> %s]",
|
|
||||||
tag,
|
|
||||||
start_dt.strftime("%H:%M:%S"),
|
|
||||||
end_dt.strftime("%H:%M:%S"),
|
|
||||||
)
|
|
||||||
logger.exception("[LLM][%s] stream failed after %.1fms", tag, elapsed_ms)
|
|
||||||
raise
|
|
||||||
|
|
||||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
||||||
end_dt = datetime.now()
|
|
||||||
logger.info(
|
|
||||||
"[LLM][%s] stream_time [%s --> %s]",
|
|
||||||
tag,
|
|
||||||
start_dt.strftime("%H:%M:%S"),
|
|
||||||
end_dt.strftime("%H:%M:%S"),
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"[LLM][%s] stream finished in %.1fms yielded_chars=%d",
|
|
||||||
tag,
|
|
||||||
elapsed_ms,
|
|
||||||
yielded_chars,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
start_dt = datetime.now()
|
start_dt = datetime.now()
|
||||||
@@ -295,7 +166,7 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
|||||||
model=VLM_MODEL,
|
model=VLM_MODEL,
|
||||||
messages=[{
|
messages=[{
|
||||||
'role': 'user',
|
'role': 'user',
|
||||||
'content': get_vlm_ocr_prompt(),
|
'content': VLM_OCR_CONTEXT_PROMPT,
|
||||||
'images': [image_bytes]
|
'images': [image_bytes]
|
||||||
}],
|
}],
|
||||||
stream=False,
|
stream=False,
|
||||||
|
|||||||
+128
-280
@@ -1,24 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Security
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from fastapi.security import APIKeyHeader
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from geoip import get_ip_location_text
|
from llm import call_ollama, call_vlm_ocr
|
||||||
from llm import call_ollama, call_vlm_ocr, stream_ollama
|
|
||||||
from models import UserPreferences
|
|
||||||
from prompt import build_completion_prompts, prepare_prompt_context
|
from prompt import build_completion_prompts, prepare_prompt_context
|
||||||
import markitdown
|
import markitdown
|
||||||
|
|
||||||
@@ -28,48 +22,41 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("api")
|
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 = FastAPI()
|
||||||
|
|
||||||
@app.on_event("startup") # pragma: no cover
|
app.add_middleware(
|
||||||
async def startup_event():
|
CORSMiddleware,
|
||||||
logger.info("Starting blocking preload for TTS and ASR models...")
|
allow_origins=[
|
||||||
try:
|
"http://localhost:5173",
|
||||||
from tts_asr import _warmup_all
|
"http://localhost:3000",
|
||||||
await _warmup_all()
|
"https://www.imageteach.tech",
|
||||||
except Exception as e:
|
"https://chat.imageteach.tech",
|
||||||
logger.warning(f"Failed to initiate model warmup: {e}")
|
],
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["POST", "OPTIONS"],
|
||||||
|
allow_headers=["Content-Type", "X-Request-Id"],
|
||||||
|
)
|
||||||
|
|
||||||
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
|
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
|
||||||
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
|
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
app.add_middleware(
|
# Rate limiting
|
||||||
CORSMiddleware,
|
MAX_CONCURRENT_COMPLETIONS = 4
|
||||||
allow_origins=["*"],
|
COMPLETION_RATE_LIMIT = 60 # per minute
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
API_KEY = os.getenv("API_KEY", "your-secret-key-here")
|
# File size limits (bytes)
|
||||||
api_key_header = APIKeyHeader(name="X-API-Key")
|
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||||
|
MAX_CONVERT_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
# Allowed file extensions
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
ALLOWED_CONVERT_EXTENSIONS = {".pdf", ".docx", ".pptx", ".xlsx", ".md", ".txt"}
|
||||||
|
|
||||||
|
|
||||||
async def get_api_key(api_key: str = Security(api_key_header)): # pragma: no cover
|
class UserPreferences(BaseModel):
|
||||||
if api_key != API_KEY:
|
language: str = "auto"
|
||||||
raise HTTPException(
|
currency: str = "auto"
|
||||||
status_code=403,
|
timezone: str = "auto"
|
||||||
detail="Could not validate credentials",
|
|
||||||
)
|
|
||||||
return api_key
|
|
||||||
|
|
||||||
|
|
||||||
class CompletionRequest(BaseModel):
|
class CompletionRequest(BaseModel):
|
||||||
@@ -79,8 +66,6 @@ class CompletionRequest(BaseModel):
|
|||||||
model_thinking: str = "low"
|
model_thinking: str = "low"
|
||||||
privacy_mode: bool = False
|
privacy_mode: bool = False
|
||||||
user_preferences: Optional[UserPreferences] = None
|
user_preferences: Optional[UserPreferences] = None
|
||||||
model: Optional[str] = None
|
|
||||||
temperature: float = 0.7
|
|
||||||
|
|
||||||
|
|
||||||
class CancelCompletionRequest(BaseModel):
|
class CancelCompletionRequest(BaseModel):
|
||||||
@@ -99,32 +84,6 @@ class ConvertRequest(BaseModel):
|
|||||||
filename: str = "document.pdf"
|
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:
|
def _preview(text: str, limit: int = 80) -> str:
|
||||||
value = (text or "").replace("\n", "\\n")
|
value = (text or "").replace("\n", "\\n")
|
||||||
if len(value) <= limit:
|
if len(value) <= limit:
|
||||||
@@ -132,49 +91,34 @@ def _preview(text: str, limit: int = 80) -> str:
|
|||||||
return value[:limit] + "..."
|
return value[:limit] + "..."
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_converted_markdown(text: str) -> str:
|
def _error_response(request_id: str, code: str, message: str, status_code: int = 500) -> JSONResponse:
|
||||||
value = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
return JSONResponse(
|
||||||
value = IMAGE_MARKDOWN_RE.sub("", value)
|
content={
|
||||||
value = IMAGE_HTML_RE.sub("", value)
|
"error": {
|
||||||
value = re.sub(r"\n{3,}", "\n\n", value)
|
"code": code,
|
||||||
return value.strip()
|
"message": message,
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status_code=status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request: Request) -> str:
|
def _sse_payload(payload: dict) -> str:
|
||||||
if request.client:
|
return f"data: {json.dumps(payload)}\n\n"
|
||||||
return request.headers.get("X-Client-IP") or request.client.host
|
|
||||||
return request.headers.get("X-Client-IP") or "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _clamp_temperature(value: float, default: float = 0.7) -> float:
|
|
||||||
try:
|
|
||||||
numeric = float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
return max(0.0, min(numeric, 1.2))
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/completions")
|
@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_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||||
request_tag = request_id[:8]
|
request_tag = request_id[:8]
|
||||||
inference_task: Optional[asyncio.Task] = None
|
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:
|
try:
|
||||||
logger.info(
|
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_tag,
|
||||||
request_id,
|
request_id,
|
||||||
client_ip,
|
|
||||||
len(req.prefix or ""),
|
len(req.prefix or ""),
|
||||||
len(req.suffix or ""),
|
len(req.suffix or ""),
|
||||||
req.languageId,
|
req.languageId,
|
||||||
@@ -190,7 +134,6 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
|||||||
req.prefix,
|
req.prefix,
|
||||||
req.suffix,
|
req.suffix,
|
||||||
req.languageId,
|
req.languageId,
|
||||||
location=location,
|
|
||||||
thinking_level=req.model_thinking,
|
thinking_level=req.model_thinking,
|
||||||
preferences=req.user_preferences,
|
preferences=req.user_preferences,
|
||||||
)
|
)
|
||||||
@@ -200,16 +143,16 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
|||||||
user_prompt,
|
user_prompt,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tag=f"{request_tag}-primary",
|
tag=f"{request_tag}-primary",
|
||||||
temperature=_clamp_temperature(req.temperature, 0.7),
|
temperature=0.7,
|
||||||
thinking=req.model_thinking if req.model_thinking != "none" else None,
|
thinking=req.model_thinking if req.model_thinking != "none" else None,
|
||||||
model=req.model,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
existing = ACTIVE_COMPLETIONS.get(request_id)
|
async with ACTIVE_COMPLETIONS_LOCK:
|
||||||
if existing and not existing.done():
|
existing = ACTIVE_COMPLETIONS.get(request_id)
|
||||||
existing.cancel()
|
if existing and not existing.done():
|
||||||
ACTIVE_COMPLETIONS[request_id] = inference_task
|
existing.cancel()
|
||||||
|
ACTIVE_COMPLETIONS[request_id] = inference_task
|
||||||
|
|
||||||
result = await inference_task
|
result = await inference_task
|
||||||
content = result["content"] or ""
|
content = result["content"] or ""
|
||||||
@@ -223,139 +166,30 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
|||||||
_preview(content, 120),
|
_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:
|
except asyncio.CancelledError:
|
||||||
logger.info("[%s] /v1/completions cancelled request_id=%s", request_tag, request_id)
|
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:
|
except Exception as e:
|
||||||
logger.exception("[%s] /v1/completions failed request_id=%s: %s", request_tag, request_id, 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:
|
finally:
|
||||||
active = ACTIVE_COMPLETIONS.get(request_id)
|
async with ACTIVE_COMPLETIONS_LOCK:
|
||||||
if active is not None and active is inference_task:
|
|
||||||
ACTIVE_COMPLETIONS.pop(request_id, None)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/pro/completions/stream")
|
|
||||||
async def create_pro_completion_stream(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
|
|
||||||
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
|
||||||
request_tag = request_id[:8]
|
|
||||||
queue: asyncio.Queue[Optional[tuple[str, str]]] = asyncio.Queue()
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[%s] /v1/pro/completions/stream request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s model=%s temp=%.2f",
|
|
||||||
request_tag,
|
|
||||||
request_id,
|
|
||||||
client_ip,
|
|
||||||
len(req.prefix or ""),
|
|
||||||
len(req.suffix or ""),
|
|
||||||
req.languageId,
|
|
||||||
req.model_thinking,
|
|
||||||
req.privacy_mode,
|
|
||||||
req.model or "",
|
|
||||||
_clamp_temperature(req.temperature, 0.7),
|
|
||||||
)
|
|
||||||
|
|
||||||
llm_prefix, llm_suffix = prepare_prompt_context(req.prefix or "", req.suffix or "")
|
|
||||||
logger.info("[%s] pro_llm_input_prefix=%r", request_tag, llm_prefix)
|
|
||||||
logger.info("[%s] pro_llm_input_suffix=%r", request_tag, llm_suffix)
|
|
||||||
|
|
||||||
system_prompt, user_prompt = build_completion_prompts(
|
|
||||||
req.prefix,
|
|
||||||
req.suffix,
|
|
||||||
req.languageId,
|
|
||||||
location=location,
|
|
||||||
thinking_level=req.model_thinking,
|
|
||||||
preferences=req.user_preferences,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def producer() -> None:
|
|
||||||
chunks: list[str] = []
|
|
||||||
try:
|
|
||||||
async for delta in stream_ollama(
|
|
||||||
user_prompt,
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
tag=f"{request_tag}-pro",
|
|
||||||
temperature=_clamp_temperature(req.temperature, 0.7),
|
|
||||||
thinking=req.model_thinking if req.model_thinking != "none" else None,
|
|
||||||
model=req.model,
|
|
||||||
use_pro_model=True,
|
|
||||||
):
|
|
||||||
chunks.append(delta)
|
|
||||||
await queue.put(("chunk", json.dumps({"delta": delta}, ensure_ascii=False)))
|
|
||||||
|
|
||||||
content = "".join(chunks)
|
|
||||||
logger.info(
|
|
||||||
"[%s] pro stream resolved request_id=%s content_chars=%d content_preview='%s'",
|
|
||||||
request_tag,
|
|
||||||
request_id,
|
|
||||||
len(content),
|
|
||||||
_preview(content, 120),
|
|
||||||
)
|
|
||||||
await queue.put((
|
|
||||||
"done",
|
|
||||||
json.dumps({"content": content, "request_id": request_id}, ensure_ascii=False),
|
|
||||||
))
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info("[%s] /v1/pro/completions/stream cancelled request_id=%s", request_tag, request_id)
|
|
||||||
await queue.put((
|
|
||||||
"cancelled",
|
|
||||||
json.dumps({"cancelled": True, "request_id": request_id}, ensure_ascii=False),
|
|
||||||
))
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("[%s] /v1/pro/completions/stream failed request_id=%s: %s", request_tag, request_id, e)
|
|
||||||
await queue.put((
|
|
||||||
"error",
|
|
||||||
json.dumps({"error": str(e), "request_id": request_id}, ensure_ascii=False),
|
|
||||||
))
|
|
||||||
finally:
|
|
||||||
await queue.put(None)
|
|
||||||
|
|
||||||
producer_task = asyncio.create_task(producer())
|
|
||||||
existing = ACTIVE_COMPLETIONS.get(request_id)
|
|
||||||
if existing and not existing.done():
|
|
||||||
existing.cancel()
|
|
||||||
ACTIVE_COMPLETIONS[request_id] = producer_task
|
|
||||||
|
|
||||||
async def event_stream():
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
item = await queue.get()
|
|
||||||
if item is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
event_name, data = item
|
|
||||||
yield f"event: {event_name}\ndata: {data}\n\n"
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
producer_task.cancel()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
active = ACTIVE_COMPLETIONS.get(request_id)
|
active = ACTIVE_COMPLETIONS.get(request_id)
|
||||||
if active is producer_task:
|
if active is not None and active is inference_task:
|
||||||
ACTIVE_COMPLETIONS.pop(request_id, None)
|
ACTIVE_COMPLETIONS.pop(request_id, None)
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
event_stream(),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/completions/cancel")
|
@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_tag = str(uuid.uuid4())[:8]
|
||||||
request_id = req.request_id or ""
|
request_id = req.request_id or ""
|
||||||
|
|
||||||
@@ -391,7 +225,7 @@ async def cancel_completion(req: CancelCompletionRequest, api_key: str = Securit
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/v1/ocr")
|
@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]
|
request_id = str(uuid.uuid4())[:8]
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -401,9 +235,24 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
|||||||
request.language,
|
request.language,
|
||||||
len(request.image or ""),
|
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)
|
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))
|
logger.info("[%s] /v1/ocr decoded image_bytes=%d", request_id, len(image_bytes))
|
||||||
result = await call_vlm_ocr(image_bytes, request.language)
|
result = await call_vlm_ocr(image_bytes, request.language)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] /v1/ocr success text_chars=%d text_preview='%s'",
|
"[%s] /v1/ocr success text_chars=%d text_preview='%s'",
|
||||||
request_id,
|
request_id,
|
||||||
@@ -413,14 +262,14 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
|||||||
return {"text": result, "filename": request.filename}
|
return {"text": result, "filename": request.filename}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[%s] /v1/ocr failed: %s", request_id, 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")
|
@app.post("/v1/convert")
|
||||||
async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(get_api_key)):
|
async def convert_to_markdown(request: ConvertRequest):
|
||||||
"""Convert file to markdown"""
|
"""将文件转换为Markdown格式"""
|
||||||
request_id = str(uuid.uuid4())[:8]
|
request_id = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] /v1/convert filename=%s file_base64_chars=%d",
|
"[%s] /v1/convert filename=%s file_base64_chars=%d",
|
||||||
@@ -428,75 +277,74 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
|||||||
request.filename,
|
request.filename,
|
||||||
len(request.file or ""),
|
len(request.file or ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Decode base64
|
# Check file size before decoding
|
||||||
file_bytes = base64.b64decode(request.file)
|
if len(request.file or "") > MAX_CONVERT_SIZE * 4 // 3:
|
||||||
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
|
return _error_response(request_id, "FILE_TOO_LARGE", "File exceeds 50MB limit", 413)
|
||||||
|
|
||||||
# Get file extension
|
# Get file extension and validate
|
||||||
ext = os.path.splitext(request.filename)[1].lower()
|
ext = os.path.splitext(request.filename)[1].lower()
|
||||||
|
|
||||||
if ext not in ALLOWED_CONVERT_EXTENSIONS:
|
if ext not in ALLOWED_CONVERT_EXTENSIONS:
|
||||||
raise ValueError("仅支持 txt、docx、pptx、pdf 格式")
|
return _error_response(request_id, "INVALID_FILE_TYPE", "Only pdf/docx/pptx/xlsx/md/txt allowed", 415)
|
||||||
|
|
||||||
if ext == ".txt":
|
# 解码Base64文件内容
|
||||||
markdown_text = _sanitize_converted_markdown(file_bytes.decode("utf-8", errors="ignore"))
|
file_bytes = base64.b64decode(request.file)
|
||||||
return {
|
|
||||||
"markdown": markdown_text,
|
# Check actual decoded size
|
||||||
"filename": request.filename
|
if len(file_bytes) > MAX_CONVERT_SIZE:
|
||||||
}
|
return _error_response(request_id, "FILE_TOO_LARGE", "File exceeds 50MB limit", 413)
|
||||||
|
|
||||||
# Create temporary file
|
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
|
||||||
|
|
||||||
|
# 创建临时文件
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
||||||
tmp.write(file_bytes)
|
tmp.write(file_bytes)
|
||||||
tmp_path = tmp.name
|
tmp_path = tmp.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Convert using MarkItDown
|
# 使用MarkItDown转换为Markdown
|
||||||
md = _get_markitdown()
|
md = markitdown.MarkItDown()
|
||||||
result = await asyncio.to_thread(md.convert, tmp_path)
|
result = md.convert(tmp_path)
|
||||||
markdown_text = _sanitize_converted_markdown(result.text_content)
|
markdown_text = result.text_content
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] /v1/convert success text_chars=%d text_preview='%s'",
|
"[%s] /v1/convert success text_chars=%d text_preview='%s'",
|
||||||
request_id,
|
request_id,
|
||||||
len(markdown_text or ""),
|
len(markdown_text or ""),
|
||||||
_preview(markdown_text, 120),
|
_preview(markdown_text, 120),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"markdown": markdown_text,
|
"markdown": markdown_text,
|
||||||
"filename": request.filename
|
"filename": request.filename
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
# Clean up temporary file
|
# 清理临时文件
|
||||||
if os.path.exists(tmp_path):
|
if os.path.exists(tmp_path):
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[%s] /v1/convert failed: %s", request_id, 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():
|
|
||||||
try:
|
|
||||||
from tts_asr import register_tts_asr_routes
|
|
||||||
except ModuleNotFoundError as exc:
|
|
||||||
logger.warning("Skipping TTS/ASR route registration because a dependency is missing: %s", exc)
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Skipping TTS/ASR route registration because import failed: %s", exc)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
register_tts_asr_routes(app)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to register TTS/ASR routes: %s", exc)
|
|
||||||
|
|
||||||
_register_tts_asr_routes()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
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)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
"""共享的 Pydantic 模型定义"""
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class UserPreferences(BaseModel):
|
|
||||||
"""用户偏好设置"""
|
|
||||||
language: str = "auto"
|
|
||||||
currency: str = "auto"
|
|
||||||
timezone: str = "auto"
|
|
||||||
+311
-53
@@ -2,9 +2,6 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import re
|
import re
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from models import UserPreferences
|
|
||||||
from prompts import get_language_guidance_map, get_system_prompt_template, get_inline_examples
|
|
||||||
|
|
||||||
|
|
||||||
def _get_current_datetime(timezone_pref: str = "auto") -> str:
|
def _get_current_datetime(timezone_pref: str = "auto") -> str:
|
||||||
# Default to UTC+8 if auto or not specified.
|
# Default to UTC+8 if auto or not specified.
|
||||||
@@ -217,36 +214,304 @@ def _canonical_language_id(language_id: str) -> str:
|
|||||||
return LANGUAGE_SYNONYMS.get(safe, safe)
|
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:
|
def _language_guidance(language_id: str) -> str:
|
||||||
canonical = _canonical_language_id(language_id)
|
canonical = _canonical_language_id(language_id)
|
||||||
if canonical == "markdown":
|
if canonical == "markdown":
|
||||||
return ""
|
return ""
|
||||||
guidance_map = get_language_guidance_map()
|
if canonical == "mermaid":
|
||||||
guidance = guidance_map.get(canonical)
|
return """
|
||||||
if guidance:
|
Language-specific guidance (mermaid):
|
||||||
return guidance
|
- Output valid Mermaid syntax only.
|
||||||
if canonical in _JS_LANGS:
|
- Prefer concise, syntactically correct diagram statements.
|
||||||
return guidance_map.get("_js_code", "").replace("{lang}", canonical)
|
- Avoid prose unless the user prompt explicitly requires it."""
|
||||||
if canonical in _CODE_LANGS:
|
if canonical == "latex":
|
||||||
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
|
return """
|
||||||
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
|
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:
|
def build_inline_system_prompt(language_id: str = "markdown") -> str:
|
||||||
safe_language_id = _canonical_language_id(language_id)
|
safe_language_id = _canonical_language_id(language_id)
|
||||||
language_guidance = _language_guidance(safe_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:
|
if language_guidance:
|
||||||
system_prompt = f"{system_prompt.rstrip()}\n{language_guidance.strip()}"
|
system_prompt = f"{system_prompt.rstrip()}\n{language_guidance.strip()}"
|
||||||
|
|
||||||
return system_prompt.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(
|
def build_completion_prompts(
|
||||||
@@ -255,7 +520,7 @@ def build_completion_prompts(
|
|||||||
language_id: str = "markdown",
|
language_id: str = "markdown",
|
||||||
location: str = "",
|
location: str = "",
|
||||||
thinking_level: str = "low",
|
thinking_level: str = "low",
|
||||||
preferences: UserPreferences | None = None,
|
preferences: object = None,
|
||||||
) -> Tuple[str, str]:
|
) -> Tuple[str, str]:
|
||||||
safe_language_id = _canonical_language_id(language_id)
|
safe_language_id = _canonical_language_id(language_id)
|
||||||
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
|
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
|
||||||
@@ -286,52 +551,45 @@ def build_completion_prompts(
|
|||||||
preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}"
|
preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}"
|
||||||
|
|
||||||
user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction}
|
user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction}
|
||||||
Reasoning level: {thinking_level}
|
Reasoning hint: {thinking_level}
|
||||||
Editor language: {safe_language_id}
|
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_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"}
|
||||||
- CURSOR_FENCE_LANGUAGE: {cursor_fence_language}
|
- CURSOR_FENCE_LANGUAGE: {cursor_fence_language}
|
||||||
- MERMAID_CONTEXT: {"true" if mermaid_context else "false"}
|
- MERMAID_CONTEXT: {"true" if mermaid_context else "false"}
|
||||||
- PREFIX_ENDS_WITH_NEWLINE: {"true" if prefix_ends_with_newline 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"}
|
- SUFFIX_STARTS_WITH_NEWLINE: {"true" if suffix_starts_with_newline else "false"}
|
||||||
|
|
||||||
=== TASK ===
|
Task:
|
||||||
Produce the best insertion text between PREFIX and SUFFIX.
|
- Produce the best insertion text at the cursor between PREFIX and SUFFIX.
|
||||||
Requirements:
|
- Keep insertion meaningful and non-empty.
|
||||||
- Non-empty and meaningful
|
- Keep insertion concise unless structure requires more content.
|
||||||
- Concise unless structure needs more
|
|
||||||
- Follows markdown rules in system prompt
|
|
||||||
- Use real line breaks instead of spelled-out escape sequences unless PREFIX or SUFFIX clearly requires that text
|
|
||||||
|
|
||||||
=== 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
|
Decision policy:
|
||||||
If false, ask: "Does output need to start on a new line?"
|
- Prioritize seamless join: PREFIX + OUTPUT + SUFFIX must read naturally.
|
||||||
- YES if PREFIX ends with: ":", "steps:", "items:", heading text, or complete sentence before heading
|
- Do not repeat SUFFIX-leading text.
|
||||||
- If YES: make the first character of OUTPUT a real newline
|
- If uncertain, prefer a complete short phrase/sentence with clear meaning.
|
||||||
|
|
||||||
Step 2: Check SUFFIX_STARTS_WITH_NEWLINE
|
Comprehensive examples:
|
||||||
If false, ask: "Does output need to end with a newline?"
|
{INLINE_EXAMPLES}
|
||||||
- YES if SUFFIX starts with: heading (##), new paragraph, or list marker
|
|
||||||
- If YES: make the last character of OUTPUT a real newline
|
|
||||||
|
|
||||||
Step 3: Choose newline type
|
Now produce the insertion.
|
||||||
- Use a blank line for: new paragraphs, before headings, starting lists
|
|
||||||
- Use a single line break for: continuing within blocks, list items, table cells
|
|
||||||
- Exception: inside code fences, use real newline characters freely
|
|
||||||
|
|
||||||
=== CONTEXT NOTES ===
|
<PREFIX>
|
||||||
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output
|
{recent_prefix}
|
||||||
- Match PREFIX tone, style, and indentation
|
</PREFIX>
|
||||||
- Do not repeat text from SUFFIX beginning
|
|
||||||
|
|
||||||
=== EXAMPLES BY CATEGORY ===
|
<SUFFIX>
|
||||||
{_INLINE_EXAMPLES}
|
{recent_suffix}
|
||||||
|
</SUFFIX>
|
||||||
|
|
||||||
=== NOW COMPLETE THE TASK ===
|
Output:"""
|
||||||
|
|
||||||
<|fim_prefix|>{recent_prefix}<|fim_suffix|>{recent_suffix}<|fim_middle|>"""
|
|
||||||
|
|
||||||
system_prompt = build_inline_system_prompt(safe_language_id)
|
system_prompt = build_inline_system_prompt(safe_language_id)
|
||||||
return system_prompt.strip(), user_prompt.strip()
|
return system_prompt.strip(), user_prompt.strip()
|
||||||
@@ -343,7 +601,7 @@ def build_prompt(
|
|||||||
language_id: str = "markdown",
|
language_id: str = "markdown",
|
||||||
location: str = "",
|
location: str = "",
|
||||||
thinking_level: str = "low",
|
thinking_level: str = "low",
|
||||||
preferences: UserPreferences | None = None,
|
preferences: object = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Backward-compatible helper. Returns only the user prompt body.
|
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- Separate code lines with actual newline characters\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- Use actual line breaks in output, not spelled-out escape sequences, unless the surrounding content explicitly needs that text\n- A single line break usually continues the current block\n- A blank line starts a new paragraph or block\n- Use blank lines for: new paragraphs, before headings, starting lists/tables\n- Use single line breaks for: continuation within blocks (list items, table cells)\n- Exception: inside code blocks, use actual newline characters 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 on a new line\n\n- If SUFFIX lacks needed newline: end OUTPUT with a trailing line break\n\n- Common cases requiring a leading line break:\n* Starting a list after \"Steps:\"\n* Creating new paragraph after text\n* Adding heading after paragraph\n- Common cases requiring a trailing line break:\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
-14
@@ -1,14 +1,12 @@
|
|||||||
fastapi>=0.95.0
|
fastapi
|
||||||
uvicorn[standard]>=0.23.0
|
uvicorn
|
||||||
pydantic>=1.10.0
|
ollama
|
||||||
numpy>=1.23.0
|
pydantic
|
||||||
soundfile>=0.10.3
|
python-dotenv
|
||||||
torch>=1.12.0
|
httpx
|
||||||
torchaudio>=0.12.0
|
geoip2
|
||||||
transformers>=4.25.0
|
markitdown[all]
|
||||||
whisper>=1.0.0
|
python-docx
|
||||||
qwen-tts>=0.0.0
|
python-pptx
|
||||||
modelscope>=1.20.0
|
openpyxl
|
||||||
|
pypdf
|
||||||
# testing
|
|
||||||
pytest>=7.0.0
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
GeoIP2 IP归属地查询测试脚本
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
1. 安装依赖:pip install geoip2
|
||||||
|
2. 下载数据库:https://dev.maxmind.com/geoip/geoip2/geolite2/
|
||||||
|
3. 运行测试:python test_geoip.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import geoip2.database
|
||||||
|
except ImportError:
|
||||||
|
print("请先安装 geoip2: pip install geoip2")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "GeoLite2-City.mmdb")
|
||||||
|
|
||||||
|
TEST_IPS = [
|
||||||
|
"8.8.8.8", # Google DNS (美国)
|
||||||
|
"114.114.114.114", # 114 DNS (中国南京)
|
||||||
|
"223.5.5.5", # 阿里DNS (中国杭州)
|
||||||
|
"1.1.1.1", # Cloudflare DNS (澳大利亚)
|
||||||
|
"119.29.29.29", # 腾讯DNS (中国)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_location(reader, ip: str) -> dict:
|
||||||
|
try:
|
||||||
|
response = reader.city(ip)
|
||||||
|
return {
|
||||||
|
"ip": ip,
|
||||||
|
"country": response.country.name,
|
||||||
|
"country_code": response.country.iso_code,
|
||||||
|
"region": response.subdivisions.most_specific.name if response.subdivisions else None,
|
||||||
|
"city": response.city.name,
|
||||||
|
"latitude": response.location.latitude,
|
||||||
|
"longitude": response.location.longitude,
|
||||||
|
"timezone": response.location.time_zone,
|
||||||
|
}
|
||||||
|
except geoip2.errors.AddressNotFoundError:
|
||||||
|
return {"ip": ip, "error": "IP未在数据库中找到"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ip": ip, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"数据库文件不存在: {DB_PATH}")
|
||||||
|
print("请从 https://dev.maxmind.com/geoip/geoip2/geolite2/ 下载 GeoLite2-City.mmdb")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"加载数据库: {DB_PATH}")
|
||||||
|
reader = geoip2.database.Reader(DB_PATH)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("IP归属地查询测试")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for ip in TEST_IPS:
|
||||||
|
result = get_location(reader, ip)
|
||||||
|
if "error" in result:
|
||||||
|
print(f"\n{ip}: {result['error']}")
|
||||||
|
else:
|
||||||
|
print(f"\n{ip}:")
|
||||||
|
print(f" 国家: {result['country']} ({result['country_code']})")
|
||||||
|
print(f" 地区: {result['region'] or '未知'}")
|
||||||
|
print(f" 城市: {result['city'] or '未知'}")
|
||||||
|
print(f" 坐标: {result['latitude']}, {result['longitude']}")
|
||||||
|
print(f" 时区: {result['timezone']}")
|
||||||
|
|
||||||
|
reader.close()
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("测试完成")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Backend Tests 测试指引
|
|
||||||
|
|
||||||
本文件适用于 backend/tests/ 下的测试和测试脚本。
|
|
||||||
|
|
||||||
## 测试入口
|
|
||||||
|
|
||||||
- pytest.ini 指定默认测试目录为 backend/tests,并设置后端覆盖率门槛为 90%。
|
|
||||||
- run_tests.py 提供 unit、integration、simulate、all 几种快捷入口。
|
|
||||||
- 默认优先使用 pytest 跑窄测试;只有在需要脚本封装参数时再用 run_tests.py。
|
|
||||||
|
|
||||||
## 测试分布
|
|
||||||
|
|
||||||
- test_main_endpoints.py:主 API 路由行为
|
|
||||||
- test_main_cancel.py:补全取消和任务生命周期
|
|
||||||
- test_prompt.py、test_prompt_extended.py:Prompt 上下文与规则
|
|
||||||
- test_llm.py、test_llm_extended.py:LLM 包装层
|
|
||||||
- test_geoip.py:GeoIP 逻辑
|
|
||||||
- test_tts_asr_*.py:TTS 相关与历史 TTS/ASR 面
|
|
||||||
- simulate_macos.py:历史模拟脚本
|
|
||||||
- quick_verify.py、verify_cross.py、play_audio.py:人工验证或辅助脚本
|
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
- pytest
|
|
||||||
- pytest backend/tests/test_main_endpoints.py -v
|
|
||||||
- pytest backend/tests/test_main_cancel.py -v
|
|
||||||
- pytest backend/tests/test_prompt.py -v
|
|
||||||
- pytest backend/tests/test_llm.py -v
|
|
||||||
- python backend/tests/run_tests.py unit
|
|
||||||
- python backend/tests/run_tests.py integration --url http://localhost:8001 --key your-secret-key-here
|
|
||||||
|
|
||||||
## 测试原则
|
|
||||||
|
|
||||||
- 优先跑与改动直接对应的窄测试,不要动不动全量跑。
|
|
||||||
- 单元测试尽量 mock 掉外部依赖,不要直连真实 Ollama。
|
|
||||||
- 涉及 main.py 时,优先用 monkeypatch 或 fake 对象替代:
|
|
||||||
- call_ollama
|
|
||||||
- call_vlm_ocr
|
|
||||||
- MarkItDown
|
|
||||||
- GeoIP 查询
|
|
||||||
- TTS 模型加载
|
|
||||||
- 测试要保持确定性,不依赖全局状态、环境顺序或人工输入。
|
|
||||||
|
|
||||||
## 容易误判的点
|
|
||||||
|
|
||||||
- 覆盖率门槛是针对多个 backend 模块一起算的,改核心文件时,即使单测通过也可能因为覆盖率不够失败。
|
|
||||||
- htmlcov、.pytest_cache、api_performance_report.md 属于生成产物,不是需要维护的源码。
|
|
||||||
- 这一目录里有一批 TTS/ASR 测试和说明明显继承自旧实现;当它们与当前 backend/tts_asr.py 冲突时,不要默认代码错了,先确认目标产品面。
|
|
||||||
- integration 脚本通常假设本地服务在 http://localhost:8001,且默认 API Key 还是占位值。
|
|
||||||
|
|
||||||
## 改动定位建议
|
|
||||||
|
|
||||||
- 路由返回值不对:先看 test_main_endpoints.py 和 test_main_cancel.py
|
|
||||||
- Prompt 规则不对:先看 test_prompt.py 和 test_prompt_extended.py
|
|
||||||
- Ollama 调用包装不对:先看 test_llm.py 和 test_llm_extended.py
|
|
||||||
- TTS 面变化:先确认当前 backend/tts_asr.py 是不是仍然以旧文档描述为目标,再决定修测试还是修实现
|
|
||||||
|
|
||||||
## 维护原则
|
|
||||||
|
|
||||||
- 新增后端行为时,优先给对应模块补测试,不要只依赖全量回归。
|
|
||||||
- 如果变更的是历史 TTS/ASR 面,先把“当前规范是什么”确定下来,再批量修测试。
|
|
||||||
- 如果覆盖率策略变化,记得同步这个文件,而不是只改 pytest.ini。
|
|
||||||
@@ -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,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,504 +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 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()
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import sys
|
|
||||||
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") == ""
|
|
||||||
+15
-12
@@ -19,11 +19,11 @@ except ModuleNotFoundError:
|
|||||||
def test_call_ollama_messages_roles_with_system(monkeypatch):
|
def test_call_ollama_messages_roles_with_system(monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
async def fake_generate(**kwargs):
|
async def fake_chat(**kwargs):
|
||||||
captured["kwargs"] = kwargs
|
captured["messages"] = kwargs["messages"]
|
||||||
return {"response": "ok"}
|
return {"message": {"content": "ok", "thinking": ""}}
|
||||||
|
|
||||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||||
|
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
llm.call_ollama(
|
llm.call_ollama(
|
||||||
@@ -35,18 +35,20 @@ def test_call_ollama_messages_roles_with_system(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result["content"] == "ok"
|
assert result["content"] == "ok"
|
||||||
assert captured["kwargs"]["prompt"] == "system prompt body\n\nuser prompt body"
|
assert captured["messages"][0]["role"] == "system"
|
||||||
assert captured["kwargs"]["raw"] is True
|
assert captured["messages"][0]["content"] == "system prompt body"
|
||||||
|
assert captured["messages"][1]["role"] == "user"
|
||||||
|
assert captured["messages"][1]["content"] == "user prompt body"
|
||||||
|
|
||||||
|
|
||||||
def test_call_ollama_messages_roles_without_system(monkeypatch):
|
def test_call_ollama_messages_roles_without_system(monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
async def fake_generate(**kwargs):
|
async def fake_chat(**kwargs):
|
||||||
captured["kwargs"] = kwargs
|
captured["messages"] = kwargs["messages"]
|
||||||
return {"response": "ok"}
|
return {"message": {"content": "ok", "thinking": ""}}
|
||||||
|
|
||||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||||
|
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
llm.call_ollama(
|
llm.call_ollama(
|
||||||
@@ -58,5 +60,6 @@ def test_call_ollama_messages_roles_without_system(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result["content"] == "ok"
|
assert result["content"] == "ok"
|
||||||
assert captured["kwargs"]["prompt"] == "user prompt only"
|
assert len(captured["messages"]) == 1
|
||||||
assert captured["kwargs"]["raw"] is True
|
assert captured["messages"][0]["role"] == "user"
|
||||||
|
assert captured["messages"][0]["content"] == "user prompt only"
|
||||||
|
|||||||
@@ -1,255 +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_generate(**kwargs):
|
|
||||||
captured["kwargs"] = kwargs
|
|
||||||
return {"response": "ok"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
llm.call_ollama("user prompt body", system_prompt=None, tag="no-system", temperature=0.1)
|
|
||||||
)
|
|
||||||
assert result["content"] == "ok"
|
|
||||||
assert captured["kwargs"]["prompt"] == "user prompt body"
|
|
||||||
assert captured["kwargs"]["raw"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_call_ollama_whitespace_system_message(monkeypatch):
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_generate(**kwargs):
|
|
||||||
captured["kwargs"] = kwargs
|
|
||||||
return {"response": "ok"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
llm.call_ollama("user prompt", system_prompt=" ", tag="whitespace-system", temperature=0.1)
|
|
||||||
)
|
|
||||||
assert result["content"] == "ok"
|
|
||||||
assert captured["kwargs"]["prompt"] == "user prompt"
|
|
||||||
assert captured["kwargs"]["raw"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_call_ollama_thinking_in_kwargs(monkeypatch):
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_generate(**kwargs):
|
|
||||||
captured.update(kwargs)
|
|
||||||
return {"response": "ok", "message": {"thinking": "boom"}} # keep thinking for backward test though it might not be perfect
|
|
||||||
|
|
||||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
|
||||||
|
|
||||||
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_generate(**kwargs):
|
|
||||||
raise asyncio.CancelledError
|
|
||||||
|
|
||||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
|
||||||
|
|
||||||
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_generate(**kwargs):
|
|
||||||
raise ValueError("boom")
|
|
||||||
|
|
||||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
|
||||||
|
|
||||||
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_generate(**kwargs):
|
|
||||||
return {"response": "final", "message": {"thinking": "process"}}
|
|
||||||
|
|
||||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
|
||||||
|
|
||||||
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_stream_ollama_uses_requested_model_and_yields_chunks(monkeypatch):
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
class FakeStream:
|
|
||||||
def __init__(self, chunks):
|
|
||||||
self._chunks = iter(chunks)
|
|
||||||
|
|
||||||
def __aiter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __anext__(self):
|
|
||||||
try:
|
|
||||||
return next(self._chunks)
|
|
||||||
except StopIteration:
|
|
||||||
raise StopAsyncIteration
|
|
||||||
|
|
||||||
async def fake_generate(**kwargs):
|
|
||||||
captured["kwargs"] = kwargs
|
|
||||||
return FakeStream([
|
|
||||||
{"response": "深度"},
|
|
||||||
{"response": "回答"},
|
|
||||||
])
|
|
||||||
|
|
||||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
|
|
||||||
async def collect_stream():
|
|
||||||
async for chunk in llm.stream_ollama(
|
|
||||||
"prompt",
|
|
||||||
system_prompt="system",
|
|
||||||
tag="stream",
|
|
||||||
temperature=0.8,
|
|
||||||
model="pro-model",
|
|
||||||
use_pro_model=True,
|
|
||||||
):
|
|
||||||
chunks.append(chunk)
|
|
||||||
|
|
||||||
asyncio.run(collect_stream())
|
|
||||||
|
|
||||||
assert "".join(chunks) == "深度回答"
|
|
||||||
assert captured["kwargs"]["model"] == "pro-model"
|
|
||||||
assert captured["kwargs"]["stream"] is True
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
assert cancelled.wait(timeout=2.0)
|
||||||
|
|
||||||
completion_response = response_box["response"]
|
completion_response = response_box["response"]
|
||||||
# 499 = client disconnected (TestClient timeout during cancel)
|
assert completion_response.status_code == 200
|
||||||
assert completion_response.status_code in (200, 499)
|
assert '"cancelled": true' in completion_response.text
|
||||||
if completion_response.status_code == 200:
|
|
||||||
assert completion_response.json()["cancelled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_not_found():
|
def test_cancel_not_found():
|
||||||
@@ -115,7 +113,6 @@ def test_completion_normal_flow(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert '"content": "completion text"' in response.text
|
||||||
assert data["content"] == "completion text"
|
assert '"done": true' in response.text
|
||||||
assert data["request_id"] is not None
|
|
||||||
assert main.ACTIVE_COMPLETIONS == {}
|
assert main.ACTIVE_COMPLETIONS == {}
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
import base64
|
|
||||||
import types
|
|
||||||
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)
|
|
||||||
|
|
||||||
if "tts_asr" not in sys.modules:
|
|
||||||
fake_tts_asr = types.ModuleType("tts_asr")
|
|
||||||
fake_tts_asr.register_tts_asr_routes = lambda app: None
|
|
||||||
sys.modules["tts_asr"] = fake_tts_asr
|
|
||||||
|
|
||||||
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_pro_stream_returns_sse(monkeypatch):
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_stream(*args, **kwargs):
|
|
||||||
captured["kwargs"] = kwargs
|
|
||||||
yield "深度"
|
|
||||||
yield "回答"
|
|
||||||
|
|
||||||
monkeypatch.setattr(main, "stream_ollama", fake_stream)
|
|
||||||
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)
|
|
||||||
with client.stream("POST", "/v1/pro/completions/stream", headers=HEADERS, json={
|
|
||||||
"prefix": "hello",
|
|
||||||
"suffix": "",
|
|
||||||
"languageId": "markdown",
|
|
||||||
"model_thinking": "high",
|
|
||||||
"privacy_mode": True,
|
|
||||||
"model": "pro-model",
|
|
||||||
"temperature": 0.95,
|
|
||||||
}) as resp:
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = "".join(resp.iter_text())
|
|
||||||
|
|
||||||
assert "event: chunk" in body
|
|
||||||
assert "event: done" in body
|
|
||||||
assert "深度" in body
|
|
||||||
assert "回答" in body
|
|
||||||
assert captured["kwargs"]["model"] == "pro-model"
|
|
||||||
assert captured["kwargs"]["use_pro_model"] is True
|
|
||||||
assert main.ACTIVE_COMPLETIONS == {}
|
|
||||||
|
|
||||||
|
|
||||||
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,25 +16,20 @@ def test_prompt_builds_system_and_user():
|
|||||||
language_id="markdown",
|
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 "$$...$$" in system_prompt
|
assert "$$...$$" in system_prompt
|
||||||
assert "```{language}" 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 "CURSOR_FENCE_LANGUAGE" in system_prompt
|
||||||
assert "MERMAID_CONTEXT" 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_IN_FENCED_CODE_BLOCK" in user_prompt
|
||||||
assert "CURSOR_FENCE_LANGUAGE" in user_prompt
|
assert "CURSOR_FENCE_LANGUAGE" in user_prompt
|
||||||
assert "MERMAID_CONTEXT" in user_prompt
|
assert "MERMAID_CONTEXT" in user_prompt
|
||||||
assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt
|
assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt
|
||||||
assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt
|
assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt
|
||||||
assert "actual line breaks" in system_prompt
|
|
||||||
assert "start OUTPUT on a new line" in system_prompt
|
|
||||||
assert "Use real line breaks instead of spelled-out escape sequences" in user_prompt
|
|
||||||
assert "make the first character of OUTPUT a real newline" in user_prompt
|
|
||||||
assert "make the last character of OUTPUT a real newline" in user_prompt
|
|
||||||
assert "start output with \\n" not in user_prompt
|
|
||||||
assert "Use single \\n" not in system_prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_cursor_in_fence_detection():
|
def test_cursor_in_fence_detection():
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import sys
|
|
||||||
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,393 +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 base64
|
|
||||||
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,376 +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 patch
|
|
||||||
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,255 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# 设置 Hugging Face 镜像源为国内镜像
|
|
||||||
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
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
|
|
||||||
|
|
||||||
# Model paths for loading
|
|
||||||
MODEL_ID_HF = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
|
||||||
MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
|
||||||
|
|
||||||
|
|
||||||
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 as e:
|
|
||||||
logger.debug("MPS check failed: %s", e)
|
|
||||||
return "cpu"
|
|
||||||
|
|
||||||
|
|
||||||
def _download_model_from_modelscope() -> Optional[str]:
|
|
||||||
"""从 ModelScope 下载模型到本地临时目录"""
|
|
||||||
try:
|
|
||||||
from modelscope import snapshot_download
|
|
||||||
cache_dir = os.path.join(os.path.dirname(__file__), "models")
|
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
|
||||||
model_dir = snapshot_download(
|
|
||||||
MODEL_ID_MS,
|
|
||||||
cache_dir=cache_dir,
|
|
||||||
revision="master"
|
|
||||||
)
|
|
||||||
logger.info("ModelScope 模型下载完成: %s", model_dir)
|
|
||||||
return model_dir
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("ModelScope 下载失败: %s", e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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 模型")
|
|
||||||
|
|
||||||
device_map = _get_device_map()
|
|
||||||
last_err = None
|
|
||||||
|
|
||||||
# 策略1: 尝试从 ModelScope 下载后加载
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
logger.info("尝试从 ModelScope 下载模型...")
|
|
||||||
model_path = _download_model_from_modelscope()
|
|
||||||
if model_path and os.path.isdir(model_path):
|
|
||||||
_tts_model = Qwen3TTSModel.from_pretrained(
|
|
||||||
model_path,
|
|
||||||
device_map=device_map,
|
|
||||||
dtype=torch.float16,
|
|
||||||
)
|
|
||||||
logger.info("ModelScope 模型加载成功: %s", model_path)
|
|
||||||
return _tts_model
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("ModelScope 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
|
|
||||||
last_err = e
|
|
||||||
|
|
||||||
# 策略2: 尝试从 HuggingFace 镜像加载
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
logger.info("尝试从 HuggingFace 镜像加载模型...")
|
|
||||||
_tts_model = Qwen3TTSModel.from_pretrained(
|
|
||||||
MODEL_ID_HF,
|
|
||||||
device_map=device_map,
|
|
||||||
dtype=torch.float16,
|
|
||||||
)
|
|
||||||
logger.info("HuggingFace 模型加载成功")
|
|
||||||
return _tts_model
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("HuggingFace 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
|
|
||||||
last_err = e
|
|
||||||
|
|
||||||
raise RuntimeError(f"无法加载 TTS 模型: {last_err}") 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 ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# VoiceDesign 模型使用 generate_voice_design 方法
|
|
||||||
# 返回 (wavs, sr),其中 wavs 是列表,wavs[0] 是第一个音频数据
|
|
||||||
wavs, sr = model.generate_voice_design(
|
|
||||||
text=text,
|
|
||||||
language="Chinese",
|
|
||||||
instruct=instruct,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("TTS 推理失败")
|
|
||||||
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}")
|
|
||||||
|
|
||||||
# 获取第一个音频数据
|
|
||||||
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
|
|
||||||
|
|
||||||
# 转换为 numpy 数组
|
|
||||||
if hasattr(wav_data, 'numpy'):
|
|
||||||
wav_data = wav_data.cpu().numpy()
|
|
||||||
wav_data = np.asarray(wav_data, dtype=np.float32)
|
|
||||||
|
|
||||||
logger.debug("wav_data shape: %s, dtype: %s, sr: %s", wav_data.shape, wav_data.dtype, sr)
|
|
||||||
|
|
||||||
# 编码 WAV 到内存
|
|
||||||
tmp_path = None
|
|
||||||
try:
|
|
||||||
import soundfile as sf
|
|
||||||
# 创建临时文件
|
|
||||||
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
|
|
||||||
os.close(fd)
|
|
||||||
sf.write(tmp_path, wav_data, sr)
|
|
||||||
with open(tmp_path, "rb") as f:
|
|
||||||
audio_bytes = f.read()
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("音频编码失败")
|
|
||||||
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}")
|
|
||||||
finally:
|
|
||||||
# 清理临时文件
|
|
||||||
if tmp_path and os.path.exists(tmp_path):
|
|
||||||
try:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 计算时长(毫秒)
|
|
||||||
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
+4
-4068
File diff suppressed because it is too large
Load Diff
@@ -11,31 +11,24 @@
|
|||||||
"check": "npm run build"
|
"check": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocknote/xl-docx-exporter": "^0.47.3",
|
|
||||||
"@milkdown/core": "^7.18.0",
|
"@milkdown/core": "^7.18.0",
|
||||||
"@milkdown/crepe": "^7.18.0",
|
"@milkdown/crepe": "^7.18.0",
|
||||||
"@milkdown/kit": "^7.18.0",
|
"@milkdown/kit": "^7.18.0",
|
||||||
"@milkdown/theme-nord": "^7.18.0",
|
"@milkdown/theme-nord": "^7.18.0",
|
||||||
"@milkdown/vue": "^7.18.0",
|
"@milkdown/vue": "^7.18.0",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.6.0",
|
||||||
"docx-preview": "^0.3.7",
|
|
||||||
"docx2pdf-converter": "^2.1.1",
|
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"jspdf": "^4.2.1",
|
|
||||||
"katex": "^0.16.9",
|
"katex": "^0.16.9",
|
||||||
"markdown-it": "^13.0.0",
|
"markdown-it": "^13.0.0",
|
||||||
"markdown-it-math": "^3.0.2",
|
"markdown-it-math": "^3.0.2",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"tui-color-picker": "^2.2.8",
|
|
||||||
"tui-image-editor": "^3.15.3",
|
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/language-server": "^3.2.6",
|
|
||||||
"vite": "^7.2.4"
|
"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
|
|
||||||
Binary file not shown.
-133
@@ -1,133 +0,0 @@
|
|||||||
# Src 前端指引
|
|
||||||
|
|
||||||
本文件适用于 src/ 下的前端代码。进入 src/plugins/ 后,以子目录 AGENTS.md 为准。
|
|
||||||
|
|
||||||
## 前端职责
|
|
||||||
|
|
||||||
- 启动 Vue 应用并挂载路由和 Pinia。
|
|
||||||
- 提供编辑器界面、上传导出、OCR 触发、TTS 菜单、设置面板和多语言 UI。
|
|
||||||
- 把编辑器状态、设置和网络请求接到后端接口。
|
|
||||||
|
|
||||||
## 先看哪里
|
|
||||||
|
|
||||||
- 应用入口:main.js
|
|
||||||
- 根组件:App.vue
|
|
||||||
- 路由:router/index.js
|
|
||||||
- 编辑页:views/EditorView.vue
|
|
||||||
- 文档页:views/DocsView.vue
|
|
||||||
- 编辑器主组件:components/MilkdownEditor.vue
|
|
||||||
- 文件预览:components/FileContent.vue、components/OfficePreview.vue
|
|
||||||
- 设置面板:components/SettingsPanel.vue
|
|
||||||
- TTS 组件:components/TTSMenu.vue、components/TTSPlayer.vue
|
|
||||||
- 插件层:plugins/
|
|
||||||
- 设置状态:stores/settings.js
|
|
||||||
- 请求层:utils/api.js
|
|
||||||
- 环境配置:utils/config.js
|
|
||||||
- 文件转换:utils/convert.js
|
|
||||||
- OCR 缓存:utils/ocrCache.js
|
|
||||||
- 文档块工具:utils/docBlock.js
|
|
||||||
|
|
||||||
## 路由事实
|
|
||||||
|
|
||||||
- / -> EditorView
|
|
||||||
- /docs -> DocsView
|
|
||||||
|
|
||||||
## 核心行为
|
|
||||||
|
|
||||||
### 编辑器主控
|
|
||||||
|
|
||||||
- MilkdownEditor.vue 是前端最重要的控制点。
|
|
||||||
- 它负责:
|
|
||||||
- 创建 Crepe 编辑器
|
|
||||||
- 注册 copilot、docBlock、mermaid 插件
|
|
||||||
- 上传图片和文档
|
|
||||||
- 触发 OCR
|
|
||||||
- 导入导出 Markdown
|
|
||||||
- 导出 DOCX 和 PDF
|
|
||||||
- AI 开关
|
|
||||||
- 32 KB 大小限制
|
|
||||||
- TTS 菜单和播放器
|
|
||||||
- 把 Markdown 更新回父组件
|
|
||||||
|
|
||||||
### 设置状态
|
|
||||||
|
|
||||||
- settings.js 负责持久化用户设置到 localStorage。
|
|
||||||
- 当前重要字段包括:
|
|
||||||
- theme
|
|
||||||
- modelThinking
|
|
||||||
- debounceMs
|
|
||||||
- privacyMode
|
|
||||||
- language
|
|
||||||
- currency
|
|
||||||
- backgroundType
|
|
||||||
- backgroundImage
|
|
||||||
- backgroundOpacity
|
|
||||||
- ttsInstruct
|
|
||||||
- initialMarkdown 也是由 store 推导出来的,编辑器初始化会消费它。
|
|
||||||
|
|
||||||
### 请求层
|
|
||||||
|
|
||||||
- utils/config.js 负责从 VITE_* 环境变量拼接接口地址。
|
|
||||||
- utils/api.js 负责补全请求、取消补全、TTS 请求和状态请求。
|
|
||||||
- fetchSuggestion 会:
|
|
||||||
- 生成 request_id
|
|
||||||
- 绑定 AbortSignal
|
|
||||||
- 触发中止时的 cancel 请求
|
|
||||||
- 读取 settings store 中的 thinking、privacy、language、currency、timezone 信息
|
|
||||||
- 向后端发 POST 并读取 JSON
|
|
||||||
|
|
||||||
## 当前真实约定
|
|
||||||
|
|
||||||
- 这个目录不是纯 JS,也不是纯 TS,而是 Vue SFC + JS + TS 混合。
|
|
||||||
- 组件、视图文件多为 PascalCase。
|
|
||||||
- 工具和配置模块多为小写 .js。
|
|
||||||
- 不要根据旧文档把前端文件统一改成另一种命名风格。
|
|
||||||
- 代码风格存在历史混合,优先跟随当前文件周围风格,不要做无关格式化。
|
|
||||||
|
|
||||||
## 容易踩坑的点
|
|
||||||
|
|
||||||
- 文档超过 32 KB 时,AI 补全会被禁用。
|
|
||||||
- 在文档块、Mermaid、LaTeX 等特定上下文中,部分 AI 行为和上传行为会被禁用或改道。
|
|
||||||
- OCR 文本和文档块摘录会被注入补全上下文,但这些内容不应直接作为用户可见输出回写到文档。
|
|
||||||
- 前端存在 /v1/export/pdf 调用,但调试前先确认后端是否真的实现了这个端点。
|
|
||||||
- 当前前端多处仍保留占位 API Key 或默认值;不要把这种写法继续扩散到新代码。
|
|
||||||
- 需要多语言 UI 时,优先走现有 i18n 结构,而不是在组件里新增硬编码文案。
|
|
||||||
|
|
||||||
## 调试路径
|
|
||||||
|
|
||||||
- 补全问题:
|
|
||||||
components/MilkdownEditor.vue
|
|
||||||
-> plugins/copilotPlugin.ts
|
|
||||||
-> utils/api.js
|
|
||||||
-> backend
|
|
||||||
|
|
||||||
- OCR 问题:
|
|
||||||
components/MilkdownEditor.vue
|
|
||||||
-> OCR 请求
|
|
||||||
-> backend/main.py
|
|
||||||
|
|
||||||
- 文档导入转换问题:
|
|
||||||
components/MilkdownEditor.vue
|
|
||||||
-> utils/convert.js
|
|
||||||
-> backend/main.py
|
|
||||||
|
|
||||||
- TTS 问题:
|
|
||||||
components/TTSMenu.vue / components/TTSPlayer.vue / components/MilkdownEditor.vue
|
|
||||||
-> utils/api.js
|
|
||||||
-> backend/tts_asr.py
|
|
||||||
|
|
||||||
## 改动建议
|
|
||||||
|
|
||||||
- 编辑器相关问题优先从 MilkdownEditor.vue 入手,不要先到处搜。
|
|
||||||
- 插件逻辑问题优先看 plugins/,尤其是 copilotPlugin.ts。
|
|
||||||
- 状态问题优先看 stores/settings.js。
|
|
||||||
- 网络问题优先看 utils/config.js 和 utils/api.js。
|
|
||||||
- 改前端时尽量避免同时改样式、文案、结构和网络逻辑,多做局部可验证修改。
|
|
||||||
|
|
||||||
## 不要做的事
|
|
||||||
|
|
||||||
- 不要修改 milkdown-docs/。
|
|
||||||
- 不要在组件里继续新增硬编码密钥。
|
|
||||||
- 不要绕开现有 store 直接分散持久化设置。
|
|
||||||
- 不要把隐藏上下文数据直接渲染进用户文档。
|
|
||||||
- 不要因为某个文件风格不统一就顺手改整个 src/。
|
|
||||||
+22
-4
@@ -1,10 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||||
import { useSettingsStore } from './stores/settings'
|
import { useSettingsStore } from './stores/settings'
|
||||||
import SettingsPanel from './components/SettingsPanel.vue'
|
import SettingsPanel from './components/SettingsPanel.vue'
|
||||||
|
|
||||||
|
const MilkdownEditor = defineAsyncComponent(() => import('./components/MilkdownEditor.vue'))
|
||||||
|
|
||||||
|
const markdown = ref('')
|
||||||
|
const emit = defineEmits(['update:markdown'])
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
|
function onChange(markdownValue) {
|
||||||
|
markdown.value = markdownValue
|
||||||
|
emit('update:markdown', markdownValue)
|
||||||
|
}
|
||||||
|
|
||||||
const appStyle = computed(() => {
|
const appStyle = computed(() => {
|
||||||
const style = {}
|
const style = {}
|
||||||
if (settings.backgroundType === 'warm') {
|
if (settings.backgroundType === 'warm') {
|
||||||
@@ -42,8 +51,10 @@ const backgroundStyle = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell" :style="appStyle">
|
<div class="app-shell" :style="appStyle">
|
||||||
<div v-if="settings.backgroundType === 'image'" class="app-bg-layer" :style="backgroundStyle"></div>
|
<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 />
|
<SettingsPanel />
|
||||||
</div>
|
</div>
|
||||||
@@ -53,10 +64,17 @@ const backgroundStyle = computed(() => {
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
background: var(--app-bg);
|
background: var(--app-bg);
|
||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
transition: background 0.3s, color 0.3s;
|
transition: background 0.3s, color 0.3s;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, 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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindGlobalListeners() {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
document.addEventListener('keydown', handleKeydown)
|
|
||||||
}
|
|
||||||
|
|
||||||
function unbindGlobalListeners() {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.visible, async (val) => {
|
|
||||||
if (val) {
|
|
||||||
bindGlobalListeners()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
unbindGlobalListeners()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
unbindGlobalListeners()
|
|
||||||
})
|
|
||||||
</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>
|
|
||||||
+167
-267
@@ -1,350 +1,250 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="doc-card" :class="{ 'is-collapsed': collapsedState }">
|
<div class="doc-block-crepe" :class="{ collapsed: isCollapsed }">
|
||||||
<header class="doc-card__header">
|
<div class="doc-header">
|
||||||
<div class="doc-card__badge">{{ typeLabel }}</div>
|
<div class="doc-icon">
|
||||||
<div class="doc-card__meta">
|
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<div class="doc-card__name">{{ docName }}</div>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
<div class="doc-card__time">{{ displayTime }}</div>
|
<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>
|
||||||
<div class="doc-card__actions">
|
<div class="doc-name">{{ docName }}</div>
|
||||||
<button type="button" class="doc-card__btn" :title="collapsedState ? '展开文件' : '折叠文件'" @click="toggleCollapse">
|
<div class="doc-actions">
|
||||||
<svg v-if="collapsedState" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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"/>
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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"/>
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
|
||||||
<div v-show="!collapsedState" class="doc-card__body">
|
|
||||||
<div ref="editorRoot" class="doc-card__editor"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="doc-editor" v-show="!isCollapsed">
|
||||||
|
<div ref="editorRoot" class="inner-crepe"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { replaceAll } from '@milkdown/kit/utils'
|
|
||||||
import { Crepe } from '@milkdown/crepe'
|
import { Crepe } from '@milkdown/crepe'
|
||||||
import { editorViewCtx } from '@milkdown/kit/core'
|
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
import { copilotPlugin, copilotConfigCtx, setCopilotEnabled } from '../plugins/copilotPlugin'
|
||||||
import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView } from '../plugins/hiddenTextPlugin'
|
|
||||||
import { fetchSuggestion } from '../utils/api.js'
|
import { fetchSuggestion } from '../utils/api.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
docType: { type: String, default: 'txt' },
|
docType: { type: String, default: 'text' },
|
||||||
docName: { type: String, default: 'document.txt' },
|
docName: { type: String, default: 'document.txt' },
|
||||||
uploadTime: { type: String, default: '' },
|
uploadTime: { type: String, default: '' },
|
||||||
content: { type: String, default: '' },
|
initialContent: { type: String, default: '' }
|
||||||
collapsed: { type: Boolean, default: false },
|
|
||||||
resolveSuggestionRequest: { type: Function, default: null },
|
|
||||||
onUpdateContent: { type: Function, default: null },
|
|
||||||
onUpdateCollapsed: { type: Function, default: null },
|
|
||||||
onDelete: { type: Function, default: null },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:content', 'delete'])
|
||||||
|
|
||||||
const editorRoot = ref(null)
|
const editorRoot = ref(null)
|
||||||
const collapsedState = ref(Boolean(props.collapsed))
|
const isCollapsed = ref(false)
|
||||||
const currentContent = ref(props.content || '')
|
|
||||||
let crepe = null
|
let crepe = null
|
||||||
let syncTimer = null
|
let internalChangeTimer = 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 })
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
const toggleCollapse = () => {
|
||||||
collapsedState.value = !collapsedState.value
|
isCollapsed.value = !isCollapsed.value
|
||||||
props.onUpdateCollapsed?.(collapsedState.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 = () => {
|
const syncContent = () => {
|
||||||
if (!crepe) return
|
if (!crepe) return
|
||||||
if (syncTimer) clearTimeout(syncTimer)
|
if (internalChangeTimer) clearTimeout(internalChangeTimer)
|
||||||
syncTimer = setTimeout(async () => {
|
internalChangeTimer = setTimeout(async () => {
|
||||||
if (!crepe || syncingExternal) return
|
|
||||||
const markdown = await crepe.getMarkdown()
|
const markdown = await crepe.getMarkdown()
|
||||||
currentContent.value = markdown
|
emit('update:content', markdown)
|
||||||
props.onUpdateContent?.(markdown)
|
|
||||||
}, 120)
|
}, 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 () => {
|
onMounted(async () => {
|
||||||
if (!editorRoot.value) return
|
if (!editorRoot.value) return
|
||||||
crepe = new Crepe({
|
crepe = new Crepe({
|
||||||
root: editorRoot.value,
|
root: editorRoot.value,
|
||||||
defaultValue: props.content || '',
|
defaultValue: props.initialContent || '',
|
||||||
features: {
|
features: {
|
||||||
[Crepe.Feature.Latex]: true,
|
[Crepe.Feature.Latex]: true,
|
||||||
[Crepe.Feature.ImageBlock]: true,
|
[Crepe.Feature.ImageBlock]: true,
|
||||||
[Crepe.Feature.Table]: true,
|
[Crepe.Feature.Table]: true,
|
||||||
[Crepe.Feature.ListCheck]: true,
|
[Crepe.Feature.ListCheck]: true,
|
||||||
},
|
},
|
||||||
config: {
|
config: { showLineNumber: false }
|
||||||
showLineNumber: false,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
crepe.editor.config((ctx) => {
|
crepe.editor.config(ctx => {
|
||||||
ctx.set(copilotConfigCtx.key, {
|
ctx.set(copilotConfigCtx.key, {
|
||||||
fetchSuggestion: async (prefix, suffix, languageId, signal) => {
|
fetchSuggestion,
|
||||||
const payload = props.resolveSuggestionRequest
|
debounceMs: 1000
|
||||||
? await props.resolveSuggestionRequest({ prefix, suffix, languageId })
|
|
||||||
: { prefix, suffix, languageId, blocked: false }
|
|
||||||
if (payload?.blocked) return ''
|
|
||||||
return fetchSuggestion(payload?.prefix ?? prefix, payload?.suffix ?? suffix, payload?.languageId ?? languageId, signal)
|
|
||||||
},
|
|
||||||
debounceMs: 900,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
crepe.editor.use(copilotConfigCtx)
|
|
||||||
crepe.editor.use(copilotGhostMark)
|
|
||||||
crepe.editor.use(copilotPlugin)
|
crepe.editor.use(copilotPlugin)
|
||||||
crepe.editor.use(hiddenTextRemark)
|
|
||||||
crepe.editor.use(hiddenTextNode)
|
|
||||||
crepe.editor.use(hiddenTextView)
|
|
||||||
crepe.editor.use(hiddenTextInputPlugin)
|
|
||||||
await crepe.create()
|
await crepe.create()
|
||||||
|
|
||||||
crepe.on((listener) => {
|
crepe.on(listener => {
|
||||||
listener.updated(() => {
|
listener.updated(() => {
|
||||||
syncContent()
|
syncContent()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
crepe.editor.action((ctx) => {
|
crepe.editor.action(ctx => {
|
||||||
const view = ctx.get(editorViewCtx)
|
const view = ctx.get(editorViewCtx)
|
||||||
setCopilotEnabled(view, true)
|
setCopilotEnabled(view, true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
watch(() => props.initialContent, (newVal) => {
|
||||||
if (syncTimer) {
|
if (crepe && newVal !== undefined) {
|
||||||
clearTimeout(syncTimer)
|
crepe.editor.action(ctx => {
|
||||||
syncTimer = null
|
|
||||||
}
|
|
||||||
if (crepe) {
|
|
||||||
crepe.editor.action((ctx) => {
|
|
||||||
const view = ctx.get(editorViewCtx)
|
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.destroy()
|
||||||
crepe = null
|
crepe = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getContent: () => crepe ? crepe.getMarkdown() : Promise.resolve(''),
|
||||||
|
getEditor: () => crepe
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.doc-card {
|
.doc-block-crepe {
|
||||||
width: 100%;
|
margin: 12px 0;
|
||||||
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);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.5);
|
overflow: hidden;
|
||||||
color: #64748b;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
color: var(--crepe-color-primary);
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .doc-card__btn {
|
.doc-name {
|
||||||
background: rgba(34, 40, 52, 0.5);
|
flex: 1;
|
||||||
border-color: rgba(96, 165, 250, 0.15);
|
font-size: 14px;
|
||||||
color: #aeb6c5;
|
font-weight: 500;
|
||||||
}
|
color: var(--crepe-color-on-surface);
|
||||||
|
|
||||||
.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);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .doc-card__editor {
|
.doc-actions {
|
||||||
background: rgba(26, 30, 39, 0.8);
|
display: flex;
|
||||||
border-color: rgba(96, 165, 250, 0.12);
|
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;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-card__editor :deep(.milkdown__main),
|
.inner-crepe :deep(.ProseMirror) {
|
||||||
.doc-card__editor :deep(.milkdown__editor) {
|
min-height: 80px;
|
||||||
margin: 0 !important;
|
padding: 8px !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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<div class="doc-block" :class="{ collapsed: isCollapsed }">
|
||||||
|
<!-- 深色条:文件头 -->
|
||||||
|
<div class="doc-header">
|
||||||
|
<!-- 最左边:文件类型icon -->
|
||||||
|
<div class="doc-icon">
|
||||||
|
<!-- PDF 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>
|
||||||
|
<!-- Word icon -->
|
||||||
|
<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>
|
||||||
|
<!-- PPT icon -->
|
||||||
|
<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>
|
||||||
|
<!-- TXT icon -->
|
||||||
|
<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-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 浅色块:文档内容(非折叠状态显示) -->
|
||||||
|
<div class="doc-content" v-show="!isCollapsed">
|
||||||
|
<pre>{{ content }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
docType: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
docName: {
|
||||||
|
type: String,
|
||||||
|
default: 'document.txt'
|
||||||
|
},
|
||||||
|
uploadTime: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCollapsed = ref(false)
|
||||||
|
|
||||||
|
const toggleCollapse = () => {
|
||||||
|
isCollapsed.value = !isCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadDoc = () => {
|
||||||
|
const blob = new Blob([props.content], { 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)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.doc-block {
|
||||||
|
margin: 12px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
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: 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: var(--crepe-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件名 */
|
||||||
|
.doc-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--crepe-color-on-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
|
.doc-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-content {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--crepe-color-surface-low);
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-content pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--crepe-color-on-surface);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,976 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, defineAsyncComponent, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import MarkdownIt from 'markdown-it'
|
|
||||||
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
|
|
||||||
import { hiddenTextMarkdownItPlugin } from '../utils/hiddenText.js'
|
|
||||||
|
|
||||||
const AsyncImageEditorComponent = defineAsyncComponent(() => import('./ImageEditorComponent.vue'))
|
|
||||||
const AsyncOfficePreview = defineAsyncComponent(() => import('./OfficePreview.vue'))
|
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogv', 'ogg', 'mov', 'm4v'])
|
|
||||||
|
|
||||||
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 },
|
|
||||||
updateFile: { type: Function, default: null },
|
|
||||||
showSidebarToggle: { type: Boolean, default: false }
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['navigate', 'toggle-sidebar'])
|
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
|
||||||
html: false,
|
|
||||||
breaks: true,
|
|
||||||
linkify: true
|
|
||||||
})
|
|
||||||
|
|
||||||
md.use(hiddenTextMarkdownItPlugin)
|
|
||||||
|
|
||||||
const basePreviewUrl = ref('')
|
|
||||||
const imageEditorRef = ref(null)
|
|
||||||
const imageEditorError = ref('')
|
|
||||||
const isEditingImage = ref(false)
|
|
||||||
const isSavingImage = ref(false)
|
|
||||||
const videoPreviewError = 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 isVideo = computed(() => {
|
|
||||||
const mime = String(props.node?.mimeType || '').toLowerCase()
|
|
||||||
return mime.startsWith('video/') || VIDEO_EXTENSIONS.has(fileExt.value)
|
|
||||||
})
|
|
||||||
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 isOffice = computed(() => {
|
|
||||||
if (!props.node || props.node.type !== 'file') return false
|
|
||||||
if (!props.node.name) return false
|
|
||||||
return isOfficeFile({ name: props.node.name, type: props.node.mimeType })
|
|
||||||
})
|
|
||||||
const officeFormat = computed(() => {
|
|
||||||
if (!isOffice.value) return null
|
|
||||||
if (!props.node?.name) return null
|
|
||||||
return getOfficeFormat({ name: props.node.name, type: props.node.mimeType })
|
|
||||||
})
|
|
||||||
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} 行` : '二进制文件'
|
|
||||||
})
|
|
||||||
const objectUrl = computed(() => basePreviewUrl.value)
|
|
||||||
const canEditImage = computed(() => isImage.value && typeof props.updateFile === 'function')
|
|
||||||
|
|
||||||
function revokeUrl(url = '') {
|
|
||||||
if (!url) return
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearBasePreview() {
|
|
||||||
revokeUrl(basePreviewUrl.value)
|
|
||||||
basePreviewUrl.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignBasePreviewUrl(url = '') {
|
|
||||||
clearBasePreview()
|
|
||||||
basePreviewUrl.value = url
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[fileBlob, () => props.node?.id],
|
|
||||||
([blob]) => {
|
|
||||||
clearBasePreview()
|
|
||||||
videoPreviewError.value = ''
|
|
||||||
|
|
||||||
if (!(blob instanceof Blob)) return
|
|
||||||
|
|
||||||
assignBasePreviewUrl(URL.createObjectURL(blob))
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.node?.id,
|
|
||||||
() => {
|
|
||||||
isEditingImage.value = false
|
|
||||||
isSavingImage.value = false
|
|
||||||
imageEditorError.value = ''
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearBasePreview()
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSidebar() {
|
|
||||||
emit('toggle-sidebar')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageExportFormat() {
|
|
||||||
if (fileExt.value === 'jpg' || fileExt.value === 'jpeg') return 'jpeg'
|
|
||||||
if (fileExt.value === 'webp') return 'webp'
|
|
||||||
return 'png'
|
|
||||||
}
|
|
||||||
|
|
||||||
function startImageEditing() {
|
|
||||||
if (!canEditImage.value || !objectUrl.value || isSavingImage.value) return
|
|
||||||
imageEditorError.value = ''
|
|
||||||
isEditingImage.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelImageEditing() {
|
|
||||||
if (isSavingImage.value) return
|
|
||||||
imageEditorError.value = ''
|
|
||||||
isEditingImage.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageEditorError(message) {
|
|
||||||
imageEditorError.value = String(message || '图片编辑器加载失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveImageEdits() {
|
|
||||||
if (!props.node?.id || typeof props.updateFile !== 'function' || !imageEditorRef.value) return
|
|
||||||
|
|
||||||
isSavingImage.value = true
|
|
||||||
imageEditorError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const editedBlob = await imageEditorRef.value.exportImageBlob({
|
|
||||||
format: getImageExportFormat(),
|
|
||||||
quality: 0.92
|
|
||||||
})
|
|
||||||
|
|
||||||
const updated = await props.updateFile(props.node.id, editedBlob, {
|
|
||||||
mimeType: editedBlob.type || props.node?.mimeType || '',
|
|
||||||
storageKind: 'blob',
|
|
||||||
previewText: '',
|
|
||||||
isTruncatedPreview: false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (updated === false) {
|
|
||||||
throw new Error('保存图片失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
isEditingImage.value = false
|
|
||||||
} catch (error) {
|
|
||||||
imageEditorError.value = error instanceof Error && error.message
|
|
||||||
? error.message
|
|
||||||
: '保存图片失败'
|
|
||||||
} finally {
|
|
||||||
isSavingImage.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadBlob(blob, fileName) {
|
|
||||||
if (!(blob instanceof Blob)) return
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const anchor = document.createElement('a')
|
|
||||||
anchor.href = url
|
|
||||||
anchor.download = fileName
|
|
||||||
document.body.appendChild(anchor)
|
|
||||||
anchor.click()
|
|
||||||
anchor.remove()
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleVideoError() {
|
|
||||||
videoPreviewError.value = '当前浏览器无法直接播放这个视频格式,请下载后使用本地播放器打开。'
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
downloadBlob(blob, props.node?.name || 'download')
|
|
||||||
}
|
|
||||||
</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-header-row">
|
|
||||||
<button
|
|
||||||
v-if="showSidebarToggle"
|
|
||||||
class="sidebar-toggle-btn"
|
|
||||||
type="button"
|
|
||||||
title="切换左侧栏"
|
|
||||||
@click="toggleSidebar"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><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="file-header-main">
|
|
||||||
<div class="file-path">
|
|
||||||
<button type="button" class="breadcrumb-link" @click="navigateTo(null)">workspace</button>
|
|
||||||
<template v-for="(item, index) in breadcrumb" :key="item.id || item.name">
|
|
||||||
<span class="path-sep">/</span>
|
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="file-header-meta">
|
|
||||||
<span class="file-meta">{{ fileSizeLabel }}</span>
|
|
||||||
<span class="file-meta">{{ locLabel }}</span>
|
|
||||||
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</span>
|
|
||||||
<span v-else-if="isVideo && videoPreviewError" class="truncated-pill">视频预览不可用</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="file-actions">
|
|
||||||
<button
|
|
||||||
v-if="canEditImage && !isEditingImage"
|
|
||||||
class="action-btn"
|
|
||||||
type="button"
|
|
||||||
@click="startImageEditing"
|
|
||||||
:disabled="!objectUrl"
|
|
||||||
>
|
|
||||||
编辑图片
|
|
||||||
</button>
|
|
||||||
<template v-else-if="canEditImage && isEditingImage">
|
|
||||||
<button class="action-btn" type="button" @click="cancelImageEditing" :disabled="isSavingImage">取消</button>
|
|
||||||
<button class="action-btn primary-btn" type="button" @click="saveImageEdits" :disabled="isSavingImage">{{ isSavingImage ? '保存中' : '保存编辑' }}</button>
|
|
||||||
</template>
|
|
||||||
<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" :class="{ 'image-editor-active': isEditingImage }">
|
|
||||||
<div v-if="imageEditorError" class="image-inline-error">{{ imageEditorError }}</div>
|
|
||||||
<AsyncImageEditorComponent
|
|
||||||
v-if="isEditingImage"
|
|
||||||
ref="imageEditorRef"
|
|
||||||
:image-url="objectUrl"
|
|
||||||
:image-name="node.name"
|
|
||||||
@error="handleImageEditorError"
|
|
||||||
/>
|
|
||||||
<img :src="objectUrl" :alt="node.name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="isVideo" class="content-preview">
|
|
||||||
<div class="preview-surface video-surface">
|
|
||||||
<div v-if="videoPreviewError" class="video-state-card video-state-error">
|
|
||||||
<h3>视频预览暂时不可用</h3>
|
|
||||||
<p>{{ videoPreviewError }}</p>
|
|
||||||
<p>原始文件仍保存在浏览器本地,你可以继续下载后使用本地播放器打开。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<video
|
|
||||||
v-else-if="objectUrl"
|
|
||||||
class="video-player"
|
|
||||||
controls
|
|
||||||
playsinline
|
|
||||||
:src="objectUrl"
|
|
||||||
@error="handleVideoError"
|
|
||||||
></video>
|
|
||||||
</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-if="isOffice && fileBlob && officeFormat" class="content-preview">
|
|
||||||
<AsyncOfficePreview
|
|
||||||
:key="`${node?.id || node?.name || 'office'}:${officeFormat}:${fileBlob?.size || 0}`"
|
|
||||||
:fileBlob="fileBlob"
|
|
||||||
:fileName="node.name"
|
|
||||||
:format="officeFormat"
|
|
||||||
/>
|
|
||||||
</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-header-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 12px 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-toggle-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 1px solid var(--github-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--github-bg);
|
|
||||||
color: var(--github-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-toggle-btn:hover {
|
|
||||||
background: var(--github-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-header-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-path {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-header-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-surface.image-editor-active {
|
|
||||||
display: block;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-surface.image-editor-active img {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-inline-error {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid color-mix(in srgb, #cf222e 35%, var(--github-border) 65%);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: color-mix(in srgb, #cf222e 8%, var(--github-bg) 92%);
|
|
||||||
color: #cf222e;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-surface {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 20px;
|
|
||||||
min-height: 72vh;
|
|
||||||
padding: 24px;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top, rgba(9, 105, 218, 0.08), transparent 38%),
|
|
||||||
linear-gradient(180deg, rgba(13, 17, 23, 0.04), rgba(13, 17, 23, 0.08));
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-player {
|
|
||||||
align-self: center;
|
|
||||||
width: min(1100px, 100%);
|
|
||||||
max-height: 78vh;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #000;
|
|
||||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-btn {
|
|
||||||
border-color: #0969da;
|
|
||||||
background: #0969da;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-btn:hover:enabled {
|
|
||||||
background: #0859ba;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-state-card {
|
|
||||||
width: min(520px, 100%);
|
|
||||||
padding: 24px;
|
|
||||||
border: 1px solid var(--github-border);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: color-mix(in srgb, var(--github-bg) 92%, white 8%);
|
|
||||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-state-card h3 {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-state-card p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--github-text-secondary);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-state-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 12px;
|
|
||||||
color: var(--github-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-state-error {
|
|
||||||
border-color: color-mix(in srgb, #cf222e 40%, var(--github-border) 60%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-video::before { content: '▶'; font-size: 11px; font-weight: 700; color: #0969da; }
|
|
||||||
.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-header-row,
|
|
||||||
.directory-card-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-header-main,
|
|
||||||
.file-actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-actions {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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,651 +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>文档模式</h2>
|
|
||||||
</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="M2.5 12.5a1 1 0 011-1h9a1 1 0 110 2h-9a1 1 0 01-1-1z"/><path d="M8 2l5 5H9.5v4.5h-3V7H3l5-5z"/></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: 4px;
|
|
||||||
padding: 2px;
|
|
||||||
border: 1px solid var(--github-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--github-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-icon-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 4px;
|
|
||||||
margin-left: 4px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node:hover .node-actions,
|
|
||||||
.tree-node.selected .node-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-icon-btn,
|
|
||||||
.action-btn,
|
|
||||||
.chevron {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--github-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron {
|
|
||||||
margin-left: -2px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-icon-btn svg,
|
|
||||||
.action-btn svg,
|
|
||||||
.chevron svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-icon-btn:hover,
|
|
||||||
.action-btn:hover,
|
|
||||||
.tree-node:hover .chevron,
|
|
||||||
.tree-node.selected .chevron {
|
|
||||||
background: rgba(9, 105, 218, 0.08);
|
|
||||||
color: var(--github-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-icon-btn:focus-visible,
|
|
||||||
.action-btn:focus-visible,
|
|
||||||
.chevron:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-content::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-content::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(99, 110, 123, 0.28);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span class="hidden-text-chip" :class="{ 'is-expanded': expanded }">
|
|
||||||
<button
|
|
||||||
v-if="!expanded"
|
|
||||||
type="button"
|
|
||||||
class="hidden-text-chip__trigger"
|
|
||||||
:title="`点击展开:${rawText}`"
|
|
||||||
@click.stop="expandText"
|
|
||||||
>
|
|
||||||
<span class="hidden-text-chip__visible">{{ label }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span v-else class="hidden-text-chip__expanded">
|
|
||||||
<span class="hidden-text-chip__syntax">
|
|
||||||
<span class="hidden-text-chip__punct">(</span>
|
|
||||||
<input
|
|
||||||
ref="displayInputRef"
|
|
||||||
v-model="displayedValue"
|
|
||||||
type="text"
|
|
||||||
class="hidden-text-chip__input hidden-text-chip__input--visible"
|
|
||||||
placeholder="显示的文本"
|
|
||||||
@input="syncTexts"
|
|
||||||
>
|
|
||||||
<span class="hidden-text-chip__punct">)</span>
|
|
||||||
<span class="hidden-text-chip__punct">{</span>
|
|
||||||
<input
|
|
||||||
ref="hiddenInputRef"
|
|
||||||
v-model="hiddenValue"
|
|
||||||
type="text"
|
|
||||||
class="hidden-text-chip__input hidden-text-chip__input--hidden"
|
|
||||||
placeholder="隐藏的文本"
|
|
||||||
@input="syncTexts"
|
|
||||||
>
|
|
||||||
<span class="hidden-text-chip__punct">}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="hidden-text-chip__collapse"
|
|
||||||
title="折叠"
|
|
||||||
@click.stop="collapseText"
|
|
||||||
>
|
|
||||||
<span class="hidden-text-chip__triangle">◀</span>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
|
||||||
import { serializeHiddenTextSyntax } from '../utils/hiddenText.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
displayed: { type: String, default: '' },
|
|
||||||
hidden: { type: String, default: '' },
|
|
||||||
expanded: { type: Boolean, default: false },
|
|
||||||
updateTexts: { type: Function, default: null },
|
|
||||||
updateExpanded: { type: Function, default: null },
|
|
||||||
})
|
|
||||||
|
|
||||||
const rawText = computed(() => serializeHiddenTextSyntax(props.displayed, props.hidden))
|
|
||||||
const label = computed(() => (props.displayed || '未命名文本'))
|
|
||||||
|
|
||||||
const displayInputRef = ref(null)
|
|
||||||
const hiddenInputRef = ref(null)
|
|
||||||
const displayedValue = ref(props.displayed || '')
|
|
||||||
const hiddenValue = ref(props.hidden || '')
|
|
||||||
|
|
||||||
const syncTexts = () => {
|
|
||||||
props.updateTexts?.({
|
|
||||||
displayed: displayedValue.value,
|
|
||||||
hidden: hiddenValue.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const expandText = async () => {
|
|
||||||
if (props.expanded) return
|
|
||||||
props.updateExpanded?.(true)
|
|
||||||
await nextTick()
|
|
||||||
displayInputRef.value?.focus?.()
|
|
||||||
displayInputRef.value?.select?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const collapseText = () => {
|
|
||||||
props.updateExpanded?.(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.displayed,
|
|
||||||
(nextValue) => {
|
|
||||||
if (nextValue !== displayedValue.value) {
|
|
||||||
displayedValue.value = nextValue || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.hidden,
|
|
||||||
(nextValue) => {
|
|
||||||
if (nextValue !== hiddenValue.value) {
|
|
||||||
hiddenValue.value = nextValue || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.hidden-text-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
vertical-align: baseline;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip.is-expanded .hidden-text-chip__trigger {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__visible {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 1.8em;
|
|
||||||
padding: 0.08em 0.52em;
|
|
||||||
border-radius: 0.45em;
|
|
||||||
background: rgba(148, 163, 184, 0.26);
|
|
||||||
color: inherit;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='dark'] .hidden-text-chip__visible {
|
|
||||||
background: rgba(100, 116, 139, 0.32);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__expanded {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.15rem;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__syntax {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.08rem;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__punct {
|
|
||||||
color: inherit;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__input {
|
|
||||||
min-width: 4rem;
|
|
||||||
padding: 0.08rem 0.25rem;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__input--visible {
|
|
||||||
min-width: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__input--hidden {
|
|
||||||
min-width: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='dark'] .hidden-text-chip__input {
|
|
||||||
background: rgba(15, 23, 42, 0.92);
|
|
||||||
border-color: rgba(100, 116, 139, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--focus-ring);
|
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__collapse {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: none;
|
|
||||||
width: 1.2rem;
|
|
||||||
height: 1.2rem;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(148, 163, 184, 0.18);
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__collapse:hover {
|
|
||||||
background: rgba(148, 163, 184, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-chip__triangle {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
line-height: 1;
|
|
||||||
transform: translateX(0.05rem);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
import 'tui-color-picker/dist/tui-color-picker.css'
|
|
||||||
import 'tui-image-editor/dist/tui-image-editor.css'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
imageUrl: { type: String, required: true },
|
|
||||||
imageName: { type: String, default: 'image' }
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['error'])
|
|
||||||
|
|
||||||
const editorHost = ref(null)
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const isReady = ref(false)
|
|
||||||
|
|
||||||
const editorMenus = ['crop', 'flip', 'rotate', 'draw', 'shape', 'icon', 'text', 'filter']
|
|
||||||
const editorTheme = {
|
|
||||||
'common.bi.image': 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
|
|
||||||
'common.backgroundColor': '#08111f',
|
|
||||||
'common.border': '0px',
|
|
||||||
'menu.normalLabel.color': '#9fb0c8',
|
|
||||||
'menu.activeLabel.color': '#f8fafc',
|
|
||||||
'submenu.backgroundColor': 'rgba(8, 17, 31, 0.95)',
|
|
||||||
'submenu.partition.color': 'rgba(159, 176, 200, 0.2)',
|
|
||||||
'submenu.normalLabel.color': '#9fb0c8',
|
|
||||||
'submenu.activeLabel.color': '#f8fafc',
|
|
||||||
'checkbox.backgroundColor': '#f8fafc',
|
|
||||||
'range.pointer.color': '#f8fafc',
|
|
||||||
'range.bar.color': 'rgba(159, 176, 200, 0.3)',
|
|
||||||
'range.subbar.color': '#38bdf8',
|
|
||||||
'range.value.color': '#f8fafc',
|
|
||||||
'range.value.backgroundColor': 'rgba(15, 23, 42, 0.88)',
|
|
||||||
'range.value.border': '1px solid rgba(159, 176, 200, 0.22)',
|
|
||||||
'range.title.color': '#cbd5e1',
|
|
||||||
'colorpicker.button.border': '1px solid rgba(159, 176, 200, 0.2)',
|
|
||||||
'colorpicker.title.color': '#e2e8f0'
|
|
||||||
}
|
|
||||||
|
|
||||||
let editorInstance = null
|
|
||||||
let loadRequestId = 0
|
|
||||||
|
|
||||||
function getErrorMessage(error, fallback) {
|
|
||||||
return error instanceof Error && error.message ? error.message : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function dataUrlToBlob(dataUrl) {
|
|
||||||
const [header, body = ''] = String(dataUrl || '').split(',')
|
|
||||||
const mimeMatch = header.match(/data:([^;]+);base64/)
|
|
||||||
const mimeType = mimeMatch ? mimeMatch[1] : 'image/png'
|
|
||||||
const binary = atob(body)
|
|
||||||
const bytes = new Uint8Array(binary.length)
|
|
||||||
|
|
||||||
for (let index = 0; index < binary.length; index += 1) {
|
|
||||||
bytes[index] = binary.charCodeAt(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Blob([bytes], { type: mimeType })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getImageEditorConstructor() {
|
|
||||||
const module = await import('tui-image-editor')
|
|
||||||
return module.default || module
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeEditor() {
|
|
||||||
if (!editorHost.value || !props.imageUrl || editorInstance) return
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ImageEditor = await getImageEditorConstructor()
|
|
||||||
editorInstance = new ImageEditor(editorHost.value, {
|
|
||||||
includeUI: {
|
|
||||||
loadImage: {
|
|
||||||
path: props.imageUrl,
|
|
||||||
name: props.imageName || 'image'
|
|
||||||
},
|
|
||||||
menu: editorMenus,
|
|
||||||
initMenu: 'draw',
|
|
||||||
menuBarPosition: 'bottom',
|
|
||||||
uiSize: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%'
|
|
||||||
},
|
|
||||||
theme: editorTheme,
|
|
||||||
usageStatistics: false
|
|
||||||
},
|
|
||||||
cssMaxWidth: 1280,
|
|
||||||
cssMaxHeight: 960,
|
|
||||||
selectionStyle: {
|
|
||||||
cornerSize: 16,
|
|
||||||
rotatingPointOffset: 56
|
|
||||||
},
|
|
||||||
usageStatistics: false
|
|
||||||
})
|
|
||||||
|
|
||||||
isReady.value = true
|
|
||||||
} catch (error) {
|
|
||||||
emit('error', getErrorMessage(error, '图片编辑器加载失败'))
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reloadImageFromProps() {
|
|
||||||
if (!editorInstance || !props.imageUrl) return
|
|
||||||
|
|
||||||
const currentRequestId = ++loadRequestId
|
|
||||||
isLoading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await editorInstance.loadImageFromURL(props.imageUrl, props.imageName || 'image')
|
|
||||||
editorInstance.clearUndoStack()
|
|
||||||
editorInstance.clearRedoStack()
|
|
||||||
} catch (error) {
|
|
||||||
if (currentRequestId === loadRequestId) {
|
|
||||||
emit('error', getErrorMessage(error, '重新载入图片失败'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (currentRequestId === loadRequestId) {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportImageBlob(options = {}) {
|
|
||||||
if (!editorInstance) {
|
|
||||||
throw new Error('图片编辑器尚未就绪')
|
|
||||||
}
|
|
||||||
|
|
||||||
editorInstance.discardSelection()
|
|
||||||
const dataUrl = editorInstance.toDataURL({
|
|
||||||
format: options.format || 'png',
|
|
||||||
quality: typeof options.quality === 'number' ? options.quality : 0.92
|
|
||||||
})
|
|
||||||
|
|
||||||
return dataUrlToBlob(dataUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyEditor() {
|
|
||||||
loadRequestId += 1
|
|
||||||
isReady.value = false
|
|
||||||
|
|
||||||
if (!editorInstance) return
|
|
||||||
|
|
||||||
editorInstance.destroy()
|
|
||||||
editorInstance = null
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.imageUrl,
|
|
||||||
(nextUrl, previousUrl) => {
|
|
||||||
if (!editorInstance || !nextUrl || nextUrl === previousUrl) return
|
|
||||||
reloadImageFromProps()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initializeEditor()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
destroyEditor()
|
|
||||||
})
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
exportImageBlob,
|
|
||||||
reloadImageFromProps,
|
|
||||||
isReady
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="image-editor-shell">
|
|
||||||
<div v-if="isLoading && !isReady" class="editor-placeholder">
|
|
||||||
<div class="editor-placeholder-card">
|
|
||||||
<strong>正在装载图片编辑器</strong>
|
|
||||||
<p>首次打开会初始化 TUI Image Editor,时间会略长一些。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref="editorHost"
|
|
||||||
class="image-editor-host tui-image-editor-container bottom"
|
|
||||||
:class="{ 'is-initializing': isLoading && !isReady }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.image-editor-shell {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 640px;
|
|
||||||
height: min(78vh, 920px);
|
|
||||||
border: 1px solid var(--github-border);
|
|
||||||
border-radius: 18px;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top, rgba(56, 189, 248, 0.14), transparent 32%),
|
|
||||||
linear-gradient(180deg, #08111f, #0f172a 58%, #121a2c);
|
|
||||||
box-shadow: 0 28px 60px rgba(15, 23, 42, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-host.tui-image-editor-container {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-host.is-initializing {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-placeholder {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-placeholder-card {
|
|
||||||
width: min(420px, 100%);
|
|
||||||
padding: 22px 24px;
|
|
||||||
border: 1px solid rgba(159, 176, 200, 0.18);
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(8, 17, 31, 0.82);
|
|
||||||
color: #f8fafc;
|
|
||||||
text-align: center;
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-placeholder-card strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-placeholder-card p {
|
|
||||||
margin: 10px 0 0;
|
|
||||||
color: rgba(226, 232, 240, 0.84);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-container) {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-header) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-main-container) {
|
|
||||||
top: 0;
|
|
||||||
bottom: 78px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-main) {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-wrap) {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-controls) {
|
|
||||||
height: 78px;
|
|
||||||
border-top: 1px solid rgba(159, 176, 200, 0.12);
|
|
||||||
background: rgba(8, 17, 31, 0.9);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-menu > .tui-image-editor-item) {
|
|
||||||
margin: 0 6px;
|
|
||||||
padding: 10px 10px 4px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-menu > .tui-image-editor-item.active) {
|
|
||||||
background: rgba(248, 250, 252, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-submenu) {
|
|
||||||
height: 166px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-submenu > div) {
|
|
||||||
padding-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-canvas-container) {
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.image-editor-shell {
|
|
||||||
min-height: 560px;
|
|
||||||
height: 72vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-main-container) {
|
|
||||||
bottom: 88px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-controls) {
|
|
||||||
height: 88px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-editor-shell :deep(.tui-image-editor-menu) {
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -7,7 +7,6 @@ import { computed } from 'vue'
|
|||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import katex from 'katex'
|
import katex from 'katex'
|
||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
import { hiddenTextMarkdownItPlugin } from '../utils/hiddenText.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
content: {
|
content: {
|
||||||
@@ -22,8 +21,6 @@ const md = new MarkdownIt({
|
|||||||
typographer: true
|
typographer: true
|
||||||
})
|
})
|
||||||
|
|
||||||
md.use(hiddenTextMarkdownItPlugin)
|
|
||||||
|
|
||||||
// 预处理 markdown,转换 $...$ 为 <span class="math-inline">...</span>
|
// 预处理 markdown,转换 $...$ 为 <span class="math-inline">...</span>
|
||||||
const preprocessLatex = (text) => {
|
const preprocessLatex = (text) => {
|
||||||
// 处理 $$...$$ 块级公式
|
// 处理 $$...$$ 块级公式
|
||||||
|
|||||||
+609
-1526
File diff suppressed because it is too large
Load Diff
@@ -1,245 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
fileBlob: { type: Blob, required: true },
|
|
||||||
fileName: { type: String, required: true },
|
|
||||||
format: { type: String, required: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const docxContainer = ref(null)
|
|
||||||
const isRendering = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
let renderToken = 0
|
|
||||||
|
|
||||||
const isDocx = computed(() => props.format === 'docx')
|
|
||||||
const previewTitle = computed(() => {
|
|
||||||
if (props.format === 'docx') return 'Word 文档预览'
|
|
||||||
if (props.format === 'xlsx') return 'Excel 文件'
|
|
||||||
if (props.format === 'pptx') return 'PowerPoint 文件'
|
|
||||||
return 'Office 文件'
|
|
||||||
})
|
|
||||||
|
|
||||||
function clearDocxPreview() {
|
|
||||||
if (docxContainer.value) {
|
|
||||||
docxContainer.value.replaceChildren()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadCurrentFile() {
|
|
||||||
if (!(props.fileBlob instanceof Blob)) return
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(props.fileBlob)
|
|
||||||
const anchor = document.createElement('a')
|
|
||||||
anchor.href = url
|
|
||||||
anchor.download = props.fileName || 'document'
|
|
||||||
document.body.appendChild(anchor)
|
|
||||||
anchor.click()
|
|
||||||
anchor.remove()
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderPreview() {
|
|
||||||
const currentToken = ++renderToken
|
|
||||||
errorMessage.value = ''
|
|
||||||
isRendering.value = isDocx.value
|
|
||||||
clearDocxPreview()
|
|
||||||
|
|
||||||
if (!isDocx.value) {
|
|
||||||
isRendering.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { renderAsync } = await import('docx-preview')
|
|
||||||
if (currentToken !== renderToken || !docxContainer.value) return
|
|
||||||
|
|
||||||
await renderAsync(props.fileBlob, docxContainer.value, undefined, {
|
|
||||||
className: 'docx-preview-shell',
|
|
||||||
ignoreWidth: false,
|
|
||||||
ignoreHeight: false,
|
|
||||||
inWrapper: false,
|
|
||||||
breakPages: false,
|
|
||||||
useBase64URL: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (currentToken !== renderToken) {
|
|
||||||
clearDocxPreview()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (currentToken !== renderToken) return
|
|
||||||
errorMessage.value = error instanceof Error && error.message
|
|
||||||
? error.message
|
|
||||||
: '文档预览失败,请下载后查看原文件。'
|
|
||||||
clearDocxPreview()
|
|
||||||
} finally {
|
|
||||||
if (currentToken === renderToken) {
|
|
||||||
isRendering.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.fileBlob, props.fileName, props.format],
|
|
||||||
() => {
|
|
||||||
void renderPreview()
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
renderToken += 1
|
|
||||||
clearDocxPreview()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="office-preview-card">
|
|
||||||
<header class="office-preview-header">
|
|
||||||
<div>
|
|
||||||
<h3>{{ previewTitle }}</h3>
|
|
||||||
<p>{{ fileName }}</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="office-preview-download" @click="downloadCurrentFile">下载原文件</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="office-preview-body">
|
|
||||||
<div v-if="isRendering" class="office-preview-state">
|
|
||||||
<h4>正在加载预览</h4>
|
|
||||||
<p>仅在当前文件需要时才按需加载预览模块,避免初始页面加载过重。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="errorMessage" class="office-preview-state office-preview-state-error">
|
|
||||||
<h4>预览失败</h4>
|
|
||||||
<p>{{ errorMessage }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="isDocx" ref="docxContainer" class="docx-preview-host"></div>
|
|
||||||
|
|
||||||
<div v-else class="office-preview-state">
|
|
||||||
<h4>当前格式不再使用内置重型预览引擎</h4>
|
|
||||||
<p>为了降低移动端包体积和运行负担,Excel 与 PowerPoint 文件改为下载后使用本地应用查看。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.office-preview-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100%;
|
|
||||||
background: var(--github-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid var(--github-border);
|
|
||||||
background: rgba(248, 250, 252, 0.92);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='dark'] .office-preview-header {
|
|
||||||
background: rgba(13, 17, 23, 0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-header p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: var(--github-text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-download {
|
|
||||||
flex: none;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 1px solid var(--github-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--github-bg);
|
|
||||||
color: var(--app-text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-download:hover {
|
|
||||||
background: var(--github-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-state {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
place-content: center;
|
|
||||||
min-height: 320px;
|
|
||||||
padding: 24px;
|
|
||||||
border: 1px dashed var(--github-border);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: var(--github-hover);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-state h4,
|
|
||||||
.office-preview-state p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-state p {
|
|
||||||
color: var(--github-text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-state-error {
|
|
||||||
border-style: solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docx-preview-host {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docx-preview-host :deep(.docx-preview-shell) {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docx-preview-host :deep(.docx-wrapper) {
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docx-preview-host :deep(section.docx) {
|
|
||||||
width: min(100%, 960px) !important;
|
|
||||||
margin: 0 auto 20px !important;
|
|
||||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.office-preview-header {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-download {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-preview-body {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="pro-block-shell" :class="[`is-${stage}`, { 'is-busy': isBusy }]">
|
|
||||||
<button
|
|
||||||
v-if="isIdle"
|
|
||||||
type="button"
|
|
||||||
class="pro-block-capsule"
|
|
||||||
:disabled="isBusy"
|
|
||||||
:title="props.title"
|
|
||||||
@mousedown.stop.prevent
|
|
||||||
@click.stop="handleActivate"
|
|
||||||
>
|
|
||||||
<span class="pro-block-badge">PRO</span>
|
|
||||||
<span class="pro-block-label">模式思考</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-else class="pro-block-panel">
|
|
||||||
<div class="pro-block-header">
|
|
||||||
<div class="pro-block-heading">
|
|
||||||
<span class="pro-block-badge">PRO</span>
|
|
||||||
<div class="pro-block-heading-text">
|
|
||||||
<h3>{{ props.title }}</h3>
|
|
||||||
<p>{{ statusText }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isBusy" class="pro-block-spinner" aria-hidden="true"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pro-block-body">
|
|
||||||
<div v-if="!displayContent" class="pro-block-placeholder">
|
|
||||||
<strong>{{ isBusy ? '正在思考' : '等待结果' }}</strong>
|
|
||||||
<span>{{ isBusy ? '流式结果会在这里展开。' : '点击重做重新发起一次深度思考。' }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="pro-block-preview">
|
|
||||||
<MarkdownPreview :content="displayContent" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pro-block-actions">
|
|
||||||
<button type="button" class="pro-text-btn" @mousedown.stop.prevent @click.stop="handleDiscard">放弃</button>
|
|
||||||
<button
|
|
||||||
v-if="!isBusy"
|
|
||||||
type="button"
|
|
||||||
class="pro-secondary-btn"
|
|
||||||
:disabled="!canRedo"
|
|
||||||
@mousedown.stop.prevent
|
|
||||||
@click.stop="handleRedo"
|
|
||||||
>
|
|
||||||
重做
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="!isBusy"
|
|
||||||
type="button"
|
|
||||||
class="pro-primary-btn"
|
|
||||||
:disabled="!canAccept"
|
|
||||||
@mousedown.stop.prevent
|
|
||||||
@click.stop="handleAccept"
|
|
||||||
>
|
|
||||||
接受
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import MarkdownPreview from './MarkdownPreview.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
stage: { type: String, default: 'idle' },
|
|
||||||
previewContent: { type: String, default: '' },
|
|
||||||
savedContent: { type: String, default: '' },
|
|
||||||
isBusy: { type: Boolean, default: false },
|
|
||||||
title: { type: String, default: 'PRO模式思考' },
|
|
||||||
activateAction: { type: Function, default: null },
|
|
||||||
discardAction: { type: Function, default: null },
|
|
||||||
redoAction: { type: Function, default: null },
|
|
||||||
acceptAction: { type: Function, default: null },
|
|
||||||
})
|
|
||||||
|
|
||||||
const isIdle = computed(() => props.stage === 'idle' && !props.previewContent && !props.savedContent)
|
|
||||||
const displayContent = computed(() => props.previewContent || props.savedContent || '')
|
|
||||||
const canAccept = computed(() => !props.isBusy && Boolean(displayContent.value))
|
|
||||||
const canRedo = computed(() => !props.isBusy)
|
|
||||||
const statusText = computed(() => {
|
|
||||||
if (props.stage === 'thinking') return '正在思考更强的答案'
|
|
||||||
if (props.stage === 'streaming') return '结果正在逐步生成'
|
|
||||||
return '接受会写入正文,放弃会回到胶囊态。'
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleActivate = () => {
|
|
||||||
props.activateAction?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDiscard = () => {
|
|
||||||
props.discardAction?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRedo = () => {
|
|
||||||
props.redoAction?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAccept = () => {
|
|
||||||
props.acceptAction?.()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.pro-block-shell {
|
|
||||||
width: 100%;
|
|
||||||
margin: 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-capsule {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border: 1px solid #1d4ed8;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 16px 38px rgba(37, 99, 235, 0.18);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-capsule:hover:not(:disabled) {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 22px 44px rgba(29, 78, 216, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-capsule:disabled {
|
|
||||||
opacity: 0.72;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-capsule .pro-block-badge {
|
|
||||||
background: rgba(239, 246, 255, 0.18);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-capsule .pro-block-label {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-panel {
|
|
||||||
border: 1px solid rgba(37, 99, 235, 0.22);
|
|
||||||
border-radius: 24px;
|
|
||||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98) 0%, rgba(219, 234, 254, 0.88) 100%);
|
|
||||||
box-shadow: 0 22px 48px rgba(37, 99, 235, 0.14);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 20px 22px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-heading-text h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-heading-text p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 52px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(29, 78, 216, 0.14);
|
|
||||||
color: #1d4ed8;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-label {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-spinner {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid rgba(29, 78, 216, 0.18);
|
|
||||||
border-top-color: #1d4ed8;
|
|
||||||
animation: pro-spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-body {
|
|
||||||
padding: 0 18px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-placeholder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 18px 20px 22px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(255, 255, 255, 0.62);
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-placeholder strong {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-placeholder span {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-preview {
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-preview :deep(.preview-container) {
|
|
||||||
height: auto;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 18px 22px;
|
|
||||||
overflow: visible;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 0 18px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-text-btn,
|
|
||||||
.pro-secondary-btn,
|
|
||||||
.pro-primary-btn {
|
|
||||||
min-width: 88px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.18s ease, opacity 0.18s ease, box-shadow 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-text-btn {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-secondary-btn {
|
|
||||||
border: 1px solid rgba(37, 99, 235, 0.22);
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
color: #1e3a8a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-primary-btn {
|
|
||||||
border: 1px solid #1d4ed8;
|
|
||||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
|
||||||
color: #eff6ff;
|
|
||||||
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-text-btn:hover,
|
|
||||||
.pro-secondary-btn:hover,
|
|
||||||
.pro-primary-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-text-btn:disabled,
|
|
||||||
.pro-secondary-btn:disabled,
|
|
||||||
.pro-primary-btn:disabled {
|
|
||||||
opacity: 0.58;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.pro-block-node-view) {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.pro-block-node-view.ProseMirror-selectednode) {
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.pro-block-node-view.ProseMirror-selectednode)::after {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pro-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.pro-block-header {
|
|
||||||
padding: 18px 16px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-body {
|
|
||||||
padding: 0 14px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pro-block-actions {
|
|
||||||
padding: 0 14px 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
import packageJson from '../../package.json'
|
import packageJson from '../../package.json'
|
||||||
|
|
||||||
const store = useSettingsStore()
|
const store = useSettingsStore()
|
||||||
const { setTheme } = useTheme()
|
const { setTheme } = useTheme()
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const VERSION = packageJson.version || '0.0.0'
|
const VERSION = packageJson.version || '0.0.0'
|
||||||
|
|
||||||
@@ -132,11 +129,6 @@ const handleImageUpload = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t = (key) => store.t[key]
|
const t = (key) => store.t[key]
|
||||||
|
|
||||||
const switchView = (view) => {
|
|
||||||
router.push(view === 'editor' ? '/' : '/docs')
|
|
||||||
closePanel()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -202,53 +194,6 @@ const switchView = (view) => {
|
|||||||
v-model.number="store.backgroundOpacity"
|
v-model.number="store.backgroundOpacity"
|
||||||
class="range-slider"
|
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>
|
|
||||||
|
|
||||||
<section class="settings-section">
|
|
||||||
<h3>{{ t('proMode') || 'PRO 模式' }}</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>{{ t('proModel') }}</label>
|
|
||||||
<input
|
|
||||||
v-model="store.proModel"
|
|
||||||
type="text"
|
|
||||||
class="select-input"
|
|
||||||
:placeholder="t('proModelPlaceholder')"
|
|
||||||
/>
|
|
||||||
<p class="help-text">{{ t('proModelDesc') }} {{ t('proModelEmptyHint') || '留空则使用后端默认 PRO 模型。' }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -321,27 +266,12 @@ const switchView = (view) => {
|
|||||||
<option value="AUD">AUD ($)</option>
|
<option value="AUD">AUD ($)</option>
|
||||||
<option value="CAD">CAD ($)</option>
|
<option value="CAD">CAD ($)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- TTS Settings -->
|
<!-- About -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>{{ t('ttsSettings') || '语音设置' }}</h3>
|
<h3>{{ t('about') }}</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>
|
|
||||||
<div class="about-card">
|
<div class="about-card">
|
||||||
<h4>llm-in-text</h4>
|
<h4>llm-in-text</h4>
|
||||||
<p>A smart Markdown editor with local LLM intelligence.</p>
|
<p>A smart Markdown editor with local LLM intelligence.</p>
|
||||||
@@ -389,7 +319,6 @@ const switchView = (view) => {
|
|||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border-right: 1px solid var(--panel-border);
|
border-right: 1px solid var(--panel-border);
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
box-shadow: var(--panel-shadow);
|
box-shadow: var(--panel-shadow);
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
@@ -420,11 +349,8 @@ const switchView = (view) => {
|
|||||||
/* Mobile Fullscreen */
|
/* Mobile Fullscreen */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-radius: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +384,6 @@ const switchView = (view) => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
@@ -637,37 +562,5 @@ const switchView = (view) => {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
opacity: 0.7;
|
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>
|
</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,464 +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" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M1 4v6h6"/>
|
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
|
||||||
<text x="12" y="15" font-size="8" font-weight="600" fill="currentColor" stroke="none" text-anchor="middle">5</text>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="tts-player__btn tts-player__btn--primary" @click="togglePlay" :title="isPlaying ? '暂停' : '播放'">
|
|
||||||
<Transition name="icon-fade" mode="out-in">
|
|
||||||
<svg v-if="!isPlaying" key="play" 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 key="pause" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
|
||||||
<rect x="6" y="4" width="4" height="16" rx="1"/>
|
|
||||||
<rect x="14" y="4" width="4" height="16" rx="1"/>
|
|
||||||
</svg>
|
|
||||||
</Transition>
|
|
||||||
</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" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M23 4v6h-6"/>
|
|
||||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
|
||||||
<text x="12" y="15" font-size="8" font-weight="600" fill="currentColor" stroke="none" text-anchor="middle">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" @play="onPlay" @pause="onPause"></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()
|
|
||||||
isPlaying.value = false
|
|
||||||
} else {
|
|
||||||
audio.play().then(() => {
|
|
||||||
isPlaying.value = true
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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().then(() => {
|
|
||||||
isPlaying.value = true
|
|
||||||
}).catch(() => {
|
|
||||||
isPlaying.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onEnded() {
|
|
||||||
isPlaying.value = false
|
|
||||||
currentTime.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听音频实际播放状态(用户可能通过其他方式暂停)
|
|
||||||
function onPlay() {
|
|
||||||
isPlaying.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPause() {
|
|
||||||
isPlaying.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = false
|
|
||||||
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: 16px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 99999;
|
|
||||||
max-width: 720px;
|
|
||||||
width: calc(100% - 32px);
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--panel-bg);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid var(--panel-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15), 0 2px 8px 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);
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-player__btn--primary:hover {
|
|
||||||
transform: scale(1.08);
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-player__btn--primary:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-player-slide-enter-from,
|
|
||||||
.tts-player-slide-leave-to {
|
|
||||||
transform: translateX(-50%) translateY(24px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-fade-enter-active,
|
|
||||||
.icon-fade-leave-active {
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-fade-enter-from,
|
|
||||||
.icon-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.7);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,343 +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>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.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 {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 4px;
|
|
||||||
margin-left: 4px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node:hover .node-actions,
|
|
||||||
.tree-node.selected .node-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn,
|
|
||||||
.chevron {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--github-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron {
|
|
||||||
margin-left: -2px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn svg,
|
|
||||||
.chevron svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover,
|
|
||||||
.tree-node:hover .chevron,
|
|
||||||
.tree-node.selected .chevron {
|
|
||||||
background: rgba(9, 105, 218, 0.08);
|
|
||||||
color: var(--github-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:focus-visible,
|
|
||||||
.chevron:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.18);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="upload-block-shell">
|
|
||||||
<input
|
|
||||||
ref="inputRef"
|
|
||||||
class="upload-block-input"
|
|
||||||
type="file"
|
|
||||||
:accept="activeAccept"
|
|
||||||
@change="handleFileChange"
|
|
||||||
>
|
|
||||||
|
|
||||||
<div class="upload-block-card" :class="{ 'is-uploading': isUploading }" @click="openFileDialog()">
|
|
||||||
<div class="upload-block-cross" aria-hidden="true">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="upload-block-footer" @click.stop>
|
|
||||||
<span class="upload-block-label">{{ isUploading ? '处理中...' : '上传' }}</span>
|
|
||||||
<label class="upload-block-select-wrap" for="upload-block-format-select">
|
|
||||||
<select
|
|
||||||
id="upload-block-format-select"
|
|
||||||
v-model="activeFilter"
|
|
||||||
class="upload-block-select"
|
|
||||||
:disabled="isUploading"
|
|
||||||
@change="handleFilterChange"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<option value="">全部</option>
|
|
||||||
<option
|
|
||||||
v-for="option in props.menuOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<svg class="upload-block-select-arrow" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M7 10l5 6 5-6z" />
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { getUploadBlockAcceptForType } from '../utils/uploadBlock.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
allowedTypes: { type: Array, default: () => [] },
|
|
||||||
accept: { type: String, default: '' },
|
|
||||||
menuOptions: { type: Array, default: () => [] },
|
|
||||||
onUploadRequest: { type: Function, default: null },
|
|
||||||
})
|
|
||||||
|
|
||||||
const inputRef = ref(null)
|
|
||||||
const isUploading = ref(false)
|
|
||||||
const activeFilter = ref('')
|
|
||||||
const activeAccept = computed(() => {
|
|
||||||
if (activeFilter.value) {
|
|
||||||
return getUploadBlockAcceptForType(activeFilter.value) || props.accept
|
|
||||||
}
|
|
||||||
return props.accept
|
|
||||||
})
|
|
||||||
|
|
||||||
const openFileDialog = (filter = '') => {
|
|
||||||
if (isUploading.value) return
|
|
||||||
activeFilter.value = filter
|
|
||||||
inputRef.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFilterChange = (event) => {
|
|
||||||
const nextValue = event?.target?.value || ''
|
|
||||||
activeFilter.value = nextValue
|
|
||||||
if (nextValue) {
|
|
||||||
openFileDialog(nextValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = async (event) => {
|
|
||||||
const input = event.target
|
|
||||||
const file = input?.files?.[0]
|
|
||||||
if (!file || !props.onUploadRequest) {
|
|
||||||
if (input) input.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isUploading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await props.onUploadRequest(file)
|
|
||||||
} finally {
|
|
||||||
isUploading.value = false
|
|
||||||
activeFilter.value = ''
|
|
||||||
if (input) input.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.accept,
|
|
||||||
() => {
|
|
||||||
activeFilter.value = ''
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.upload-block-shell {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
width: 100%;
|
|
||||||
margin: 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-card {
|
|
||||||
position: relative;
|
|
||||||
width: 10%;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
min-width: 108px;
|
|
||||||
border-radius: 22px;
|
|
||||||
border: 2px solid #2563eb;
|
|
||||||
background: linear-gradient(180deg, rgba(219, 234, 254, 0.72) 0%, rgba(191, 219, 254, 0.95) 100%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 16px 38px rgba(37, 99, 235, 0.16);
|
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 20px 42px rgba(37, 99, 235, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-card.is-uploading {
|
|
||||||
cursor: wait;
|
|
||||||
opacity: 0.78;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-label {
|
|
||||||
color: #111827;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-select-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-cross {
|
|
||||||
position: relative;
|
|
||||||
width: 54%;
|
|
||||||
height: 54%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-cross span {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #2563eb;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-cross span:last-child {
|
|
||||||
width: 4px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-select {
|
|
||||||
appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 18px 0 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-select:disabled {
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-block-select-arrow {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: #111827;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.upload-block-node-view) {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.upload-block-node-view.ProseMirror-selectednode) {
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.upload-block-node-view.ProseMirror-selectednode .upload-block-card) {
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: 0 16px 38px rgba(37, 99, 235, 0.16) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.upload-block-node-view.ProseMirror-selectednode)::after {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
|
||||||
.upload-block-card {
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,764 +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 request
|
|
||||||
try {
|
|
||||||
request = handler(store)
|
|
||||||
} catch (error) {
|
|
||||||
reject(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (request && typeof request.onsuccess === 'function') {
|
|
||||||
request.onsuccess = () => resolve(request.result)
|
|
||||||
request.onerror = () => reject(request.error || new Error('本地数据库操作失败'))
|
|
||||||
} else {
|
|
||||||
transaction.oncomplete = () => resolve(request)
|
|
||||||
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) {
|
|
||||||
const textExtensions = [
|
|
||||||
'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', 'swift', 'kt', 'rb', 'php', 'pl', 'r', 'scala',
|
|
||||||
'gradle', 'properties', 'env', 'gitignore', 'dockerfile'
|
|
||||||
]
|
|
||||||
return textExtensions.includes(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBinaryExtension(ext) {
|
|
||||||
const binaryExtensions = [
|
|
||||||
'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'obj', 'o', 'a',
|
|
||||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
|
|
||||||
'pdf', 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
|
|
||||||
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'svg',
|
|
||||||
'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv',
|
|
||||||
'ttf', 'otf', 'woff', 'woff2', 'eot',
|
|
||||||
'class', 'pyc', 'pyo', 'jar', 'war', 'ear',
|
|
||||||
'db', 'sqlite', 'mdb', 'accdb',
|
|
||||||
'pem', 'key', 'crt', 'cer', 'p12', 'pfx', 'jks',
|
|
||||||
'msg', 'eml', 'pst', 'ost',
|
|
||||||
'dwg', 'dxf', 'step', 'stl', 'obj', 'fbx', '3ds', 'blend'
|
|
||||||
]
|
|
||||||
return binaryExtensions.includes(ext.toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferMimeType(name, fallback = '') {
|
|
||||||
const ext = getExtension(name)
|
|
||||||
const map = {
|
|
||||||
avi: 'video/x-msvideo',
|
|
||||||
md: 'text/markdown',
|
|
||||||
markdown: 'text/markdown',
|
|
||||||
mkv: 'video/x-matroska',
|
|
||||||
mov: 'video/quicktime',
|
|
||||||
mp4: 'video/mp4',
|
|
||||||
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',
|
|
||||||
flv: 'video/x-flv',
|
|
||||||
m4v: 'video/x-m4v',
|
|
||||||
png: 'image/png',
|
|
||||||
gif: 'image/gif',
|
|
||||||
webp: 'image/webp',
|
|
||||||
svg: 'image/svg+xml',
|
|
||||||
pdf: 'application/pdf',
|
|
||||||
ogg: 'video/ogg',
|
|
||||||
ogv: 'video/ogg',
|
|
||||||
webm: 'video/webm',
|
|
||||||
wmv: 'video/x-ms-wmv'
|
|
||||||
}
|
|
||||||
return fallback || map[ext] || 'application/octet-stream'
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTextFile(record) {
|
|
||||||
const ext = getExtension(record?.name)
|
|
||||||
// 二进制文件不提供预览
|
|
||||||
if (isBinaryExtension(ext)) return false
|
|
||||||
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 (isBinaryExtension(ext)) {
|
|
||||||
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 nextRecords = await withStore('readonly', (store) => store.getAll())
|
|
||||||
if (!Array.isArray(nextRecords) || 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 updateFile(id, nextValue, options = {}) {
|
|
||||||
const file = records.value.find((item) => item.id === id && item.type === 'file')
|
|
||||||
if (!file) return false
|
|
||||||
|
|
||||||
const nextName = options.name || file.name
|
|
||||||
const isBlobValue = nextValue instanceof Blob
|
|
||||||
const nextContent = isBlobValue ? (options.content ?? '') : String(options.content ?? nextValue ?? '')
|
|
||||||
const nextSize = typeof options.size === 'number'
|
|
||||||
? options.size
|
|
||||||
: isBlobValue
|
|
||||||
? nextValue.size
|
|
||||||
: new Blob([nextContent]).size
|
|
||||||
|
|
||||||
if (nextSize > MAX_FILE_SIZE) {
|
|
||||||
error.value = '单个文件不能超过 1GB'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
file.name = nextName
|
|
||||||
file.updatedAt = Date.now()
|
|
||||||
file.mimeType = inferMimeType(nextName, options.mimeType || (isBlobValue ? nextValue.type : file.mimeType))
|
|
||||||
file.size = nextSize
|
|
||||||
file.storageKind = options.storageKind || (isBlobValue ? 'blob' : 'text')
|
|
||||||
file.content = nextContent
|
|
||||||
file.previewText = options.previewText ?? (file.storageKind === 'text' ? nextContent : '')
|
|
||||||
file.isTruncatedPreview = Boolean(options.isTruncatedPreview)
|
|
||||||
file.blob = isBlobValue ? nextValue : null
|
|
||||||
records.value = [...records.value]
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
persistRecord(file).catch(() => {
|
|
||||||
error.value = '保存文件失败,可能是浏览器存储空间不足'
|
|
||||||
})
|
|
||||||
touchParent(file.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 = {
|
|
||||||
avi: 'video',
|
|
||||||
md: 'markdown',
|
|
||||||
markdown: 'markdown',
|
|
||||||
mkv: 'video',
|
|
||||||
mov: 'video',
|
|
||||||
mp4: 'video',
|
|
||||||
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',
|
|
||||||
flv: 'video',
|
|
||||||
m4v: 'video',
|
|
||||||
webp: 'image',
|
|
||||||
svg: 'image',
|
|
||||||
ogg: 'video',
|
|
||||||
ogv: 'video',
|
|
||||||
pdf: 'pdf',
|
|
||||||
doc: 'word',
|
|
||||||
docx: 'word',
|
|
||||||
ppt: 'ppt',
|
|
||||||
pptx: 'ppt',
|
|
||||||
webm: 'video',
|
|
||||||
wmv: 'video',
|
|
||||||
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,
|
|
||||||
updateFile,
|
|
||||||
createFolder,
|
|
||||||
rename,
|
|
||||||
remove,
|
|
||||||
select,
|
|
||||||
toggleFolder,
|
|
||||||
copy,
|
|
||||||
cut,
|
|
||||||
paste,
|
|
||||||
canPaste,
|
|
||||||
clearClipboard,
|
|
||||||
getSelectedNode,
|
|
||||||
getBreadcrumbPath,
|
|
||||||
showContextMenu,
|
|
||||||
hideContextMenu,
|
|
||||||
getFileIcon,
|
|
||||||
getExtension,
|
|
||||||
getFileBlob,
|
|
||||||
isTextFile,
|
|
||||||
uploadFiles,
|
|
||||||
MAX_FILE_SIZE,
|
|
||||||
MAX_NODES
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-2
@@ -1,6 +1,5 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
@@ -9,5 +8,12 @@ import '@milkdown/crepe/theme/frame.css'
|
|||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||||
|
// Service worker registration failed, silently ignore
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# Plugins 插件层指引
|
|
||||||
|
|
||||||
本文件适用于 src/plugins/ 下的插件代码。
|
|
||||||
|
|
||||||
## 插件层职责
|
|
||||||
|
|
||||||
- 承接 Milkdown/ProseMirror 与业务逻辑之间的粘合层。
|
|
||||||
- 负责 AI 补全 ghost text、文档块节点、Mermaid 预览等编辑器级行为。
|
|
||||||
- 这里的代码状态性很强,误改后通常会出现“看起来还能编译,但交互细节坏掉”的问题。
|
|
||||||
|
|
||||||
## 文件分工
|
|
||||||
|
|
||||||
- copilotPlugin.ts:AI 补全主逻辑,包含 ghost text、请求调度、取消、语言识别、隐藏上下文注入和大小限制。
|
|
||||||
- docBlockPlugin.ts:文档块节点、remark 适配和视图逻辑。
|
|
||||||
- mermaidPlugin.ts:Mermaid 代码块预览和渲染。
|
|
||||||
- index.ts:导出入口。
|
|
||||||
- types.ts:公共类型。
|
|
||||||
|
|
||||||
## copilotPlugin 的真实职责
|
|
||||||
|
|
||||||
- 维护 ghost suggestion 的 plugin state。
|
|
||||||
- 用 mark + decoration 表示 ghost text,而不是简单的纯字符串缓存。
|
|
||||||
- 为每个 EditorView 维护 runtime:
|
|
||||||
- enabled
|
|
||||||
- debounceTimer
|
|
||||||
- abortController
|
|
||||||
- ctx
|
|
||||||
- requestSeq
|
|
||||||
- docVersion
|
|
||||||
- 推断当前光标语言,处理 fenced code、latex、mermaid 等上下文。
|
|
||||||
- 提取 OCR 缓存和文档块内容,拼到隐藏上下文里。
|
|
||||||
- 控制请求失效和中止,避免旧请求把新文档状态覆盖掉。
|
|
||||||
- 在结果插入后恢复合理的光标位置和选择状态。
|
|
||||||
|
|
||||||
## 先看哪些函数
|
|
||||||
|
|
||||||
- getCursorLanguageId:判断当前上下文语言
|
|
||||||
- extractDocBlocksFromMarkdown:从文档块生成隐藏上下文
|
|
||||||
- doFetchSuggestion:真正发起补全请求
|
|
||||||
- clearRuntimeRequests:中止定时器和请求
|
|
||||||
- insertGhostText / insertPlainText:把建议插回编辑器
|
|
||||||
|
|
||||||
## 编辑规则
|
|
||||||
|
|
||||||
- 不要把隐藏 OCR 上下文或文档块上下文直接变成最终可见文本,除非需求明确要求。
|
|
||||||
- 不要破坏 requestSeq 和 AbortController 这套旧请求失效机制。
|
|
||||||
- 不要随意改 ghost text 的 mark/decorations 表示方式;这会影响显示、接受、拒绝和同步。
|
|
||||||
- parserCtx 和 serializerCtx 是和 Milkdown 集成的关键上下文,改解析插入逻辑时要一起考虑。
|
|
||||||
- 插件行为并不完全自洽,很多配置和启停逻辑是在 components/MilkdownEditor.vue 里接上的;不要只在插件内部脑补整体行为。
|
|
||||||
|
|
||||||
## 容易踩坑的点
|
|
||||||
|
|
||||||
- 大小限制既有插件常量,也有编辑器 UI 层联动。
|
|
||||||
- 图片节点类型不止一种名字,代码已经兼容 image、image-block、imageBlock。
|
|
||||||
- 上下文语言识别会影响 Prompt 行为,改错以后通常不是“报错”,而是补全质量或补全边界变差。
|
|
||||||
- Mermaid、LaTeX、文档块这几类上下文都和“是否允许补全、是否允许上传”相关。
|
|
||||||
- 这是状态机式代码;局部改动后要优先验证交互,而不是只看类型通过。
|
|
||||||
|
|
||||||
## 调试顺序
|
|
||||||
|
|
||||||
- 如果是“为什么没有发请求”,先查 enabled、size limit、cursor context、debounce 和 abort。
|
|
||||||
- 如果是“为什么结果插错位置”,先查 ghost range、mapping、selection 恢复。
|
|
||||||
- 如果是“为什么上下文不对”,先查 OCR 缓存、文档块摘录和语言识别。
|
|
||||||
- 如果是“为什么改了插件没生效”,再回看 MilkdownEditor.vue 里的 use/config/watch 是否也要一起改。
|
|
||||||
|
|
||||||
## 改动建议
|
|
||||||
|
|
||||||
- 先做最小改动。
|
|
||||||
- 先验证单个交互问题,不要一口气同时改 ghost 插入、语言识别和请求时机。
|
|
||||||
- 当改动影响请求 payload 时,再考虑是否需要同步后端 Prompt 测试。
|
|
||||||
- 只有在确认插件本身是控制点时,才在这里改;很多行为实际上是在上层组件里决定的。
|
|
||||||
@@ -4,7 +4,6 @@ import { parserCtx, serializerCtx } from '@milkdown/kit/core'
|
|||||||
import { Node as ProseNode, Slice } from '@milkdown/prose/model'
|
import { Node as ProseNode, Slice } from '@milkdown/prose/model'
|
||||||
import type { Ctx } from '@milkdown/kit/core'
|
import type { Ctx } from '@milkdown/kit/core'
|
||||||
import { Decoration, DecorationSet, type EditorView } from '@milkdown/prose/view'
|
import { Decoration, DecorationSet, type EditorView } from '@milkdown/prose/view'
|
||||||
import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js'
|
|
||||||
import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCache'
|
import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCache'
|
||||||
|
|
||||||
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
|
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
|
||||||
@@ -12,8 +11,6 @@ const DEBOUNCE_MS = 1000
|
|||||||
const SIZE_LIMIT = OCR_SIZE_LIMIT
|
const SIZE_LIMIT = OCR_SIZE_LIMIT
|
||||||
const DOC_SIZE_LIMIT = 32 * 1024 // 文档块32KB限制
|
const DOC_SIZE_LIMIT = 32 * 1024 // 文档块32KB限制
|
||||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
||||||
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
|
|
||||||
const FALLBACK_LEAF_TEXT = '\n'
|
|
||||||
|
|
||||||
interface CopilotState {
|
interface CopilotState {
|
||||||
from: number
|
from: number
|
||||||
@@ -184,6 +181,10 @@ function normalizeSuggestionText(raw: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!text.includes('\n') && text.includes('\\n')) {
|
||||||
|
text = text.replace(/\\n/g, '\n')
|
||||||
|
}
|
||||||
|
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +307,7 @@ function serializeRangeToMarkdown(
|
|||||||
const slice = doc.slice(from, to)
|
const slice = doc.slice(from, to)
|
||||||
if (slice.content.size <= 0) return ''
|
if (slice.content.size <= 0) return ''
|
||||||
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
|
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
|
||||||
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, '\n', '\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
|
function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
|
||||||
@@ -327,7 +328,32 @@ function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (lines.length === 0) return ''
|
if (lines.length === 0) return ''
|
||||||
return lines.join('\n')
|
return `\n\n${lines.join('\n')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从markdown中提取文档块内容用于AI补全上下文
|
||||||
|
function extractDocBlocksFromMarkdown(markdown: string): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
// 使用正则表达式匹配文档块
|
||||||
|
// <doc_type="pdf" doc_name="xxx" upload_time="xxx">content</doc_end>
|
||||||
|
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>/g
|
||||||
|
|
||||||
|
let match
|
||||||
|
while ((match = docBlockRegex.exec(markdown)) !== null) {
|
||||||
|
const docType = match[1]
|
||||||
|
const docName = match[2]
|
||||||
|
const content = match[4].trim()
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
// 将文档内容格式化为上下文,限制长度
|
||||||
|
const truncatedContent = content.length > 500 ? content.substring(0, 500) + '...' : content
|
||||||
|
lines.push(`<doc_type="${docType}" doc_name="${docName}">\n${truncatedContent}\n</doc_end>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0) return ''
|
||||||
|
return `\n\n-- 已上传文档内容 --\n${lines.join('\n\n')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function doFetchSuggestion(
|
function doFetchSuggestion(
|
||||||
@@ -387,26 +413,26 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
|
|||||||
try {
|
try {
|
||||||
prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, serializer)
|
prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, serializer)
|
||||||
if (!prefixMarkdown) {
|
if (!prefixMarkdown) {
|
||||||
prefixMarkdown = doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
prefixMarkdown = doc.textBetween(0, pos, '\n', '\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
suffixMarkdown = serializeRangeToMarkdown(doc, pos, doc.content.size, schema, serializer)
|
suffixMarkdown = serializeRangeToMarkdown(doc, pos, doc.content.size, schema, serializer)
|
||||||
if (!suffixMarkdown) {
|
if (!suffixMarkdown) {
|
||||||
suffixMarkdown = doc.textBetween(pos, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
prefixMarkdown = doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
prefixMarkdown = doc.textBetween(0, pos, '\n', '\n')
|
||||||
suffixMarkdown = doc.textBetween(pos, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建上下文:OCR内容 + 上传文档内容
|
// 构建上下文:OCR内容 + 上传文档内容
|
||||||
const ocrContext = buildOcrContextForRequest(doc, pos)
|
const ocrContext = buildOcrContextForRequest(doc, pos)
|
||||||
|
|
||||||
// 从markdown中提取文档块内容用于AI补全上下文
|
// 从markdown中提取文档块内容用于AI补全上下文
|
||||||
const docContext = extractDocBlockContextFromMarkdown(prefixMarkdown + suffixMarkdown, 500)
|
const docContext = extractDocBlocksFromMarkdown(prefixMarkdown + suffixMarkdown)
|
||||||
|
|
||||||
// 组合所有上下文到prefix前面
|
// 组合所有上下文到prefix前面
|
||||||
const fullPrefixWithContext = [ocrContext, docContext, prefixMarkdown].filter(Boolean).join('\n\n')
|
const fullPrefixWithContext = `${ocrContext}${docContext}\n\n${prefixMarkdown}`
|
||||||
|
|
||||||
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
|
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
|
||||||
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length
|
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length
|
||||||
@@ -717,12 +743,7 @@ export function interruptCopilot(view: EditorView): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
|
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
|
||||||
let size = view.state.doc.content.size
|
const 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return { size, overLimit: size > SIZE_LIMIT }
|
return { size, overLimit: size > SIZE_LIMIT }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,344 +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'
|
|
||||||
|
|
||||||
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
|
|
||||||
const FALLBACK_LEAF_TEXT = '\n'
|
|
||||||
const CONTEXT_SEPARATOR = '\n\n'
|
|
||||||
const MAX_SUFFIX_RATIO = 0.35
|
|
||||||
|
|
||||||
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, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJoinedLength(parts: string[]) {
|
|
||||||
let total = 0
|
|
||||||
let hasContent = false
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!part) continue
|
|
||||||
if (hasContent) total += CONTEXT_SEPARATOR.length
|
|
||||||
total += part.length
|
|
||||||
hasContent = true
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeTail(text: string, limit: number) {
|
|
||||||
if (!text || limit <= 0) return ''
|
|
||||||
if (text.length <= limit) return text
|
|
||||||
return text.slice(-limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeHead(text: string, limit: number) {
|
|
||||||
if (!text || limit <= 0) return ''
|
|
||||||
if (text.length <= limit) return text
|
|
||||||
return text.slice(0, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitPrefixSections(parts: string[], limit: number) {
|
|
||||||
if (limit <= 0) return ''
|
|
||||||
|
|
||||||
const fitted: string[] = []
|
|
||||||
let remaining = limit
|
|
||||||
|
|
||||||
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
||||||
const part = parts[index]
|
|
||||||
if (!part || remaining <= 0) continue
|
|
||||||
|
|
||||||
const separatorCost = fitted.length > 0 ? CONTEXT_SEPARATOR.length : 0
|
|
||||||
if (remaining <= separatorCost) break
|
|
||||||
|
|
||||||
const nextPart = takeTail(part, remaining - separatorCost)
|
|
||||||
if (!nextPart) continue
|
|
||||||
|
|
||||||
fitted.unshift(nextPart)
|
|
||||||
remaining -= nextPart.length + separatorCost
|
|
||||||
}
|
|
||||||
|
|
||||||
return fitted.join(CONTEXT_SEPARATOR)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitSuffixSections(parts: string[], limit: number) {
|
|
||||||
if (limit <= 0) return ''
|
|
||||||
|
|
||||||
const fitted: string[] = []
|
|
||||||
let remaining = limit
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!part || remaining <= 0) continue
|
|
||||||
|
|
||||||
const separatorCost = fitted.length > 0 ? CONTEXT_SEPARATOR.length : 0
|
|
||||||
if (remaining <= separatorCost) break
|
|
||||||
|
|
||||||
const nextPart = takeHead(part, remaining - separatorCost)
|
|
||||||
if (!nextPart) continue
|
|
||||||
|
|
||||||
fitted.push(nextPart)
|
|
||||||
remaining -= nextPart.length + separatorCost
|
|
||||||
}
|
|
||||||
|
|
||||||
return fitted.join(CONTEXT_SEPARATOR)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 prefixParts = [docContext, before, payload.prefix].filter(Boolean)
|
|
||||||
const suffixParts = [payload.suffix, after].filter(Boolean)
|
|
||||||
const mergedPrefix = prefixParts.join(CONTEXT_SEPARATOR)
|
|
||||||
const mergedSuffix = suffixParts.join(CONTEXT_SEPARATOR)
|
|
||||||
|
|
||||||
if (mergedPrefix.length + mergedSuffix.length > DOC_CONTEXT_LIMIT) {
|
|
||||||
const prefixCapacity = getJoinedLength(prefixParts)
|
|
||||||
const suffixCapacity = getJoinedLength(suffixParts)
|
|
||||||
const maxSuffixBudget = Math.min(suffixCapacity, Math.floor(DOC_CONTEXT_LIMIT * MAX_SUFFIX_RATIO))
|
|
||||||
|
|
||||||
let suffixBudget = maxSuffixBudget
|
|
||||||
let prefixBudget = DOC_CONTEXT_LIMIT - suffixBudget
|
|
||||||
|
|
||||||
if (prefixCapacity < prefixBudget) {
|
|
||||||
const transferable = prefixBudget - prefixCapacity
|
|
||||||
suffixBudget = Math.min(suffixCapacity, suffixBudget + transferable)
|
|
||||||
prefixBudget = DOC_CONTEXT_LIMIT - suffixBudget
|
|
||||||
} else if (suffixCapacity < suffixBudget) {
|
|
||||||
const transferable = suffixBudget - suffixCapacity
|
|
||||||
prefixBudget = Math.min(prefixCapacity, prefixBudget + transferable)
|
|
||||||
suffixBudget = DOC_CONTEXT_LIMIT - prefixBudget
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
prefix: fitPrefixSections(prefixParts, prefixBudget),
|
|
||||||
suffix: fitSuffixSections(suffixParts, suffixBudget),
|
|
||||||
languageId: payload.languageId,
|
|
||||||
blocked: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,246 +0,0 @@
|
|||||||
import { createApp, h, reactive } from 'vue'
|
|
||||||
import { Plugin, PluginKey } from '@milkdown/prose/state'
|
|
||||||
import { $node, $prose, $remark, $view } from '@milkdown/kit/utils'
|
|
||||||
import type { Node as ProseNode } from '@milkdown/prose/model'
|
|
||||||
import type { EditorView, NodeView } from '@milkdown/prose/view'
|
|
||||||
import HiddenTextCrepe from '../components/HiddenTextCrepe.vue'
|
|
||||||
import { HIDDEN_TEXT_NODE_TYPE, parseHiddenTextAt, serializeHiddenTextSyntax, splitTextWithHiddenSyntax } from '../utils/hiddenText.js'
|
|
||||||
|
|
||||||
const HIDDEN_TEXT_INPUT_PLUGIN_KEY = new PluginKey('milkdown-hidden-text-input')
|
|
||||||
const HIDDEN_TEXT_INPUT_META = 'hidden-text-input-meta'
|
|
||||||
|
|
||||||
function transformHiddenTextChildren(node: any) {
|
|
||||||
if (!node || !Array.isArray(node.children)) return
|
|
||||||
|
|
||||||
const nextChildren: any[] = []
|
|
||||||
|
|
||||||
for (const child of node.children) {
|
|
||||||
if (child?.type === 'text' && typeof child.value === 'string') {
|
|
||||||
const segments = splitTextWithHiddenSyntax(child.value)
|
|
||||||
if (segments) {
|
|
||||||
nextChildren.push(...segments)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transformHiddenTextChildren(child)
|
|
||||||
nextChildren.push(child)
|
|
||||||
}
|
|
||||||
|
|
||||||
node.children = nextChildren
|
|
||||||
}
|
|
||||||
|
|
||||||
function findHiddenTextReplacements(doc: ProseNode) {
|
|
||||||
const replacements: Array<{ from: number; to: number; displayed: string; hidden: string; marks: ProseNode['marks'] }> = []
|
|
||||||
|
|
||||||
doc.descendants((node, pos, parent) => {
|
|
||||||
if (!node.isText || !node.text) return true
|
|
||||||
if (parent?.type.spec.code) return true
|
|
||||||
if (node.marks.some((mark) => mark.type.spec.code)) return true
|
|
||||||
|
|
||||||
let index = 0
|
|
||||||
while (index < node.text.length) {
|
|
||||||
const match = parseHiddenTextAt(node.text, index)
|
|
||||||
if (!match) {
|
|
||||||
index += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
replacements.push({
|
|
||||||
from: pos + match.start,
|
|
||||||
to: pos + match.end,
|
|
||||||
displayed: match.displayed,
|
|
||||||
hidden: match.hidden,
|
|
||||||
marks: node.marks,
|
|
||||||
})
|
|
||||||
|
|
||||||
index = match.end
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return replacements
|
|
||||||
}
|
|
||||||
|
|
||||||
class HiddenTextNodeView implements NodeView {
|
|
||||||
node: ProseNode
|
|
||||||
view: EditorView
|
|
||||||
getPos: (() => number) | boolean
|
|
||||||
dom: HTMLElement
|
|
||||||
app: ReturnType<typeof createApp> | null = null
|
|
||||||
props: Record<string, any>
|
|
||||||
|
|
||||||
constructor(node: ProseNode, view: EditorView, getPos: (() => number) | boolean) {
|
|
||||||
this.node = node
|
|
||||||
this.view = view
|
|
||||||
this.getPos = getPos
|
|
||||||
this.dom = document.createElement('span')
|
|
||||||
this.dom.className = 'hidden-text-node-view'
|
|
||||||
this.dom.setAttribute('contenteditable', 'false')
|
|
||||||
this.props = reactive({
|
|
||||||
displayed: node.attrs.displayed,
|
|
||||||
hidden: node.attrs.hidden,
|
|
||||||
expanded: node.attrs.expanded,
|
|
||||||
updateTexts: ({ displayed, hidden }: { displayed: string; hidden: string }) => {
|
|
||||||
this.updateAttrs({ displayed, hidden })
|
|
||||||
},
|
|
||||||
updateExpanded: (expanded: boolean) => this.updateAttrs({ expanded }),
|
|
||||||
})
|
|
||||||
this.mount()
|
|
||||||
}
|
|
||||||
|
|
||||||
mount() {
|
|
||||||
this.app = createApp({
|
|
||||||
render: () => h(HiddenTextCrepe, this.props),
|
|
||||||
})
|
|
||||||
this.app.mount(this.dom)
|
|
||||||
}
|
|
||||||
|
|
||||||
getPosValue() {
|
|
||||||
if (typeof this.getPos === 'function') {
|
|
||||||
try {
|
|
||||||
const pos = this.getPos()
|
|
||||||
if (typeof pos === 'number') return pos
|
|
||||||
} catch {
|
|
||||||
// Fall back to DOM-based resolution below.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pos = this.view.posAtDOM(this.dom, 0)
|
|
||||||
return typeof pos === 'number' ? pos : undefined
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAttrs(patch: Record<string, any>) {
|
|
||||||
const pos = this.getPosValue()
|
|
||||||
if (pos === undefined) return
|
|
||||||
const nextAttrs = { ...this.node.attrs, ...patch }
|
|
||||||
const nextNode = this.node.type.create(nextAttrs, undefined, this.node.marks)
|
|
||||||
this.view.dispatch(this.view.state.tr.replaceWith(pos, pos + this.node.nodeSize, nextNode))
|
|
||||||
}
|
|
||||||
|
|
||||||
update(node: ProseNode) {
|
|
||||||
if (node.type !== this.node.type) return false
|
|
||||||
this.node = node
|
|
||||||
this.props.displayed = node.attrs.displayed
|
|
||||||
this.props.hidden = node.attrs.hidden
|
|
||||||
this.props.expanded = node.attrs.expanded
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hiddenTextRemark = $remark('hiddenTextRemark', () => () => {
|
|
||||||
return (tree: any) => {
|
|
||||||
transformHiddenTextChildren(tree)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const hiddenTextNode = $node(HIDDEN_TEXT_NODE_TYPE, () => ({
|
|
||||||
group: 'inline',
|
|
||||||
inline: true,
|
|
||||||
atom: true,
|
|
||||||
selectable: true,
|
|
||||||
draggable: false,
|
|
||||||
attrs: {
|
|
||||||
displayed: { default: '' },
|
|
||||||
hidden: { default: '' },
|
|
||||||
expanded: { default: false },
|
|
||||||
},
|
|
||||||
parseDOM: [
|
|
||||||
{
|
|
||||||
tag: 'span[data-hidden-text="true"]',
|
|
||||||
getAttrs: (dom) => ({
|
|
||||||
displayed: (dom as HTMLElement).getAttribute('data-hidden-display') || '',
|
|
||||||
hidden: (dom as HTMLElement).getAttribute('data-hidden-value') || '',
|
|
||||||
expanded: ((dom as HTMLElement).getAttribute('data-hidden-expanded') || '') === 'true',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
toDOM: (node) => [
|
|
||||||
'span',
|
|
||||||
{
|
|
||||||
'data-hidden-text': 'true',
|
|
||||||
'data-hidden-display': node.attrs.displayed,
|
|
||||||
'data-hidden-value': node.attrs.hidden,
|
|
||||||
'data-hidden-expanded': String(Boolean(node.attrs.expanded)),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
parseMarkdown: {
|
|
||||||
match: (node) => node.type === HIDDEN_TEXT_NODE_TYPE,
|
|
||||||
runner: (state, node, type) => {
|
|
||||||
state.addNode(type, {
|
|
||||||
displayed: String(node.displayed || ''),
|
|
||||||
hidden: String(node.hidden || ''),
|
|
||||||
expanded: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toMarkdown: {
|
|
||||||
match: (node) => node.type.name === HIDDEN_TEXT_NODE_TYPE,
|
|
||||||
runner: (state, node) => {
|
|
||||||
state.addNode('text', undefined, serializeHiddenTextSyntax(node.attrs.displayed, node.attrs.hidden))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
leafText: (node) => serializeHiddenTextSyntax(node.attrs.displayed, node.attrs.hidden),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const hiddenTextView = $view(hiddenTextNode, () => {
|
|
||||||
return (node, view, getPos) => new HiddenTextNodeView(node, view, getPos)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const hiddenTextInputPlugin = $prose(() => {
|
|
||||||
return new Plugin({
|
|
||||||
key: HIDDEN_TEXT_INPUT_PLUGIN_KEY,
|
|
||||||
appendTransaction: (transactions, _oldState, newState) => {
|
|
||||||
if (!transactions.some((transaction) => transaction.docChanged)) return null
|
|
||||||
if (transactions.some((transaction) => transaction.getMeta(HIDDEN_TEXT_INPUT_META))) return null
|
|
||||||
|
|
||||||
const hiddenTextType = newState.schema.nodes[HIDDEN_TEXT_NODE_TYPE]
|
|
||||||
if (!hiddenTextType) return null
|
|
||||||
|
|
||||||
const replacements = findHiddenTextReplacements(newState.doc)
|
|
||||||
if (replacements.length === 0) return null
|
|
||||||
|
|
||||||
let tr = newState.tr
|
|
||||||
|
|
||||||
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
|
||||||
const replacement = replacements[index]
|
|
||||||
tr = tr.replaceWith(
|
|
||||||
replacement.from,
|
|
||||||
replacement.to,
|
|
||||||
hiddenTextType.create(
|
|
||||||
{
|
|
||||||
displayed: replacement.displayed,
|
|
||||||
hidden: replacement.hidden,
|
|
||||||
expanded: false,
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
replacement.marks
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tr.docChanged) return null
|
|
||||||
tr.setMeta(HIDDEN_TEXT_INPUT_META, true)
|
|
||||||
return tr
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||||
import mermaid from 'mermaid'
|
import mermaid from 'mermaid'
|
||||||
|
|
||||||
let mermaidReadyTheme = ''
|
let mermaidReadyTheme = ''
|
||||||
@@ -24,37 +24,14 @@ function ensureMermaid() {
|
|||||||
const theme = getMermaidTheme()
|
const theme = getMermaidTheme()
|
||||||
if (mermaidReadyTheme === theme) return
|
if (mermaidReadyTheme === theme) return
|
||||||
|
|
||||||
const dark = theme === 'dark'
|
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
theme: dark ? 'dark' : 'base',
|
theme: theme || (dark ? 'dark' : 'default'),
|
||||||
securityLevel: 'loose',
|
securityLevel: 'loose',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
flowchart: { htmlLabels: false },
|
flowchart: {
|
||||||
themeVariables: dark ? {
|
htmlLabels: false,
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
mermaidReadyTheme = theme
|
mermaidReadyTheme = theme
|
||||||
@@ -92,26 +69,17 @@ function makeMermaidFilename() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMermaidPreviewMarkup(code: string, token: number) {
|
function buildMermaidPreviewMarkup(code: string, token: number) {
|
||||||
// 剥离首尾的 ```mermaid 或 ``` 标识符,防止其被误认为图表节点
|
const encoded = encodeMermaidCode(code)
|
||||||
const cleanCode = code
|
|
||||||
.replace(/^```[a-z]*\s*\n?/i, '')
|
|
||||||
.replace(/\n?```\s*$/i, '')
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
const encoded = encodeMermaidCode(cleanCode)
|
|
||||||
const filename = makeMermaidFilename()
|
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 `
|
return `
|
||||||
<div class="mermaid-block" data-mermaid-code="${encoded}" data-mermaid-token="${token}">
|
<div class="mermaid-block" data-mermaid-code="${encoded}" data-mermaid-token="${token}">
|
||||||
<div class="mermaid-controls">
|
<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" data-mermaid-action="zoom" disabled>Zoom</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="download" data-mermaid-filename="${filename}" disabled>Download PNG</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mermaid-inner">
|
<div class="mermaid-inner">
|
||||||
<div class="mermaid-loading"><span></span><span></span><span></span></div>
|
<div class="mermaid-loading">...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`.trim()
|
</div>`.trim()
|
||||||
}
|
}
|
||||||
@@ -134,6 +102,7 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'download') {
|
if (action === 'download') {
|
||||||
|
node.textContent = 'Download PNG'
|
||||||
if (payload) {
|
if (payload) {
|
||||||
node.removeAttribute('disabled')
|
node.removeAttribute('disabled')
|
||||||
node.setAttribute('data-mermaid-url', payload.downloadUrl || payload.previewUrl)
|
node.setAttribute('data-mermaid-url', payload.downloadUrl || payload.previewUrl)
|
||||||
@@ -161,7 +130,6 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSvgSize(svg: string) {
|
function getSvgSize(svg: string) {
|
||||||
// 优先读取 viewBox (取第三、四个值作为宽高)
|
|
||||||
const viewBox = svg.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
|
const viewBox = svg.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
|
||||||
if (viewBox) {
|
if (viewBox) {
|
||||||
const width = Number(viewBox[1])
|
const width = Number(viewBox[1])
|
||||||
@@ -171,16 +139,14 @@ function getSvgSize(svg: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 次选读取数字像素属性
|
const widthAttr = svg.match(/width\s*=\s*["']([-\d.]+)(px)?["']/i)
|
||||||
const widthAttr = svg.match(/\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/i)
|
const heightAttr = svg.match(/height\s*=\s*["']([-\d.]+)(px)?["']/i)
|
||||||
const heightAttr = svg.match(/\bheight\s*=\s*["']([\d.]+)(?:px)?["']/i)
|
const width = widthAttr ? Number(widthAttr[1]) : 960
|
||||||
const attrW = widthAttr ? Number(widthAttr[1]) : 0
|
const height = heightAttr ? Number(heightAttr[1]) : 540
|
||||||
const attrH = heightAttr ? Number(heightAttr[1]) : 0
|
return {
|
||||||
if (attrW > 0 && attrH > 0) {
|
width: Number.isFinite(width) && width > 0 ? width : 960,
|
||||||
return { width: attrW, height: attrH }
|
height: Number.isFinite(height) && height > 0 ? height : 540,
|
||||||
}
|
}
|
||||||
|
|
||||||
return { width: 960, height: 540 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function svgToDataUrl(svg: string) {
|
function svgToDataUrl(svg: string) {
|
||||||
@@ -193,38 +159,6 @@ function stripExternalSvgResources(svg: string) {
|
|||||||
.replace(/url\(\s*["']?https?:\/\/[^"')]*["']?\s*\)/gi, 'none')
|
.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) {
|
function getRasterDpr(width: number, height: number) {
|
||||||
const rawDpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
|
const rawDpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
|
||||||
const baseDpr = Math.min(3, Math.max(1, rawDpr))
|
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> {
|
async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
|
||||||
const paddedSvg = padSvgViewBox(svg)
|
const fallback = getSvgSize(svg)
|
||||||
const fallback = getSvgSize(paddedSvg)
|
|
||||||
const sourceWidth = fallback.width
|
const sourceWidth = fallback.width
|
||||||
const sourceHeight = fallback.height
|
const sourceHeight = fallback.height
|
||||||
|
|
||||||
@@ -275,8 +208,8 @@ async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
|
|||||||
width = Math.max(1, Math.round(width * scale))
|
width = Math.max(1, Math.round(width * scale))
|
||||||
height = Math.max(1, Math.round(height * scale))
|
height = Math.max(1, Math.round(height * scale))
|
||||||
|
|
||||||
const normalizedSvg = stripExternalSvgResources(paddedSvg)
|
const normalizedSvg = stripExternalSvgResources(svg)
|
||||||
const candidates = normalizedSvg === paddedSvg ? [paddedSvg] : [paddedSvg, normalizedSvg]
|
const candidates = normalizedSvg === svg ? [svg] : [svg, normalizedSvg]
|
||||||
let lastError: unknown = null
|
let lastError: unknown = null
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
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 encodedCode = block.getAttribute('data-mermaid-code') || ''
|
||||||
const code = decodeMermaidCode(encodedCode).trim() || 'graph TD\nA-->B'
|
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)
|
setMermaidActionsState(block, null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -373,8 +306,8 @@ async function renderMermaidBlock(block: HTMLElement, token: number): Promise<vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleMermaidRender(token: number) {
|
function scheduleMermaidRender(token: number) {
|
||||||
|
const maxAttempts = 24
|
||||||
const targetSelector = `.mermaid-block[data-mermaid-token="${token}"]`
|
const targetSelector = `.mermaid-block[data-mermaid-token="${token}"]`
|
||||||
const retryDelays = [24, 64, 160, 320, 640]
|
|
||||||
|
|
||||||
const run = (attempt: number) => {
|
const run = (attempt: number) => {
|
||||||
const block = document.querySelector(targetSelector)
|
const block = document.querySelector(targetSelector)
|
||||||
@@ -382,9 +315,13 @@ function scheduleMermaidRender(token: number) {
|
|||||||
void renderMermaidBlock(block, token)
|
void renderMermaidBlock(block, token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const nextDelay = retryDelays[attempt]
|
if (attempt >= maxAttempts) return
|
||||||
if (nextDelay === undefined) return
|
|
||||||
window.setTimeout(() => run(attempt + 1), nextDelay)
|
if (typeof window.requestAnimationFrame === 'function') {
|
||||||
|
window.requestAnimationFrame(() => run(attempt + 1))
|
||||||
|
} else {
|
||||||
|
window.setTimeout(() => run(attempt + 1), 16)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run(0)
|
run(0)
|
||||||
|
|||||||
@@ -1,665 +0,0 @@
|
|||||||
import { createApp, h, reactive } from 'vue'
|
|
||||||
import { parserCtx, serializerCtx } from '@milkdown/kit/core'
|
|
||||||
import { $ctx, $node, $prose, $remark, $view } from '@milkdown/kit/utils'
|
|
||||||
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
|
|
||||||
import { type Node as ProseNode, Slice, type Schema } from '@milkdown/prose/model'
|
|
||||||
import { Decoration, DecorationSet, type EditorView, type NodeView } from '@milkdown/prose/view'
|
|
||||||
import ProBlockCrepe from '../components/ProBlockCrepe.vue'
|
|
||||||
import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js'
|
|
||||||
import { extractTextFromOCR, getOcrCache } from '../utils/ocrCache'
|
|
||||||
import { PRO_BLOCK_NODE_TYPE, PRO_DISPLAY_LABEL, PRO_TRIGGER_TEXT, parseProBlockSyntax, serializeProBlockSyntax } from '../utils/proBlock.js'
|
|
||||||
|
|
||||||
const PRO_BLOCK_INPUT_PLUGIN_KEY = new PluginKey('milkdown-pro-block-input')
|
|
||||||
const PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY = new PluginKey<DecorationSet>('milkdown-pro-block-highlight')
|
|
||||||
const PRO_BLOCK_INPUT_META = 'pro-block-input-meta'
|
|
||||||
const PRO_CONTEXT_LIMIT = 32 * 1024
|
|
||||||
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
|
|
||||||
const FALLBACK_LEAF_TEXT = '\n'
|
|
||||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
|
||||||
|
|
||||||
interface ProBlockConfig {
|
|
||||||
fetchSuggestionStream: (payload: {
|
|
||||||
prefix: string
|
|
||||||
suffix: string
|
|
||||||
languageId: string
|
|
||||||
signal?: AbortSignal
|
|
||||||
model?: string
|
|
||||||
temperature?: number
|
|
||||||
onChunk?: (chunk: string) => void
|
|
||||||
}) => Promise<string>
|
|
||||||
getProModel: () => string
|
|
||||||
showError: (message: string) => void
|
|
||||||
isThinkingActive: () => boolean
|
|
||||||
setThinkingActive: (value: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProRequestPayload {
|
|
||||||
prefix: string
|
|
||||||
suffix: string
|
|
||||||
languageId: string
|
|
||||||
blocked: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeRangeToMarkdown(
|
|
||||||
doc: ProseNode,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
schema: Schema,
|
|
||||||
serializer: (content: ProseNode) => string
|
|
||||||
): string {
|
|
||||||
if (from >= to) return ''
|
|
||||||
const fallback = doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
|
||||||
if (typeof serializer !== 'function') return fallback
|
|
||||||
|
|
||||||
const slice = doc.slice(from, to)
|
|
||||||
if (slice.content.size <= 0) return ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
|
|
||||||
return sliceDoc ? serializer(sliceDoc) : fallback
|
|
||||||
} catch {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformProBlockChildren(node: any) {
|
|
||||||
if (!node || !Array.isArray(node.children)) return
|
|
||||||
|
|
||||||
node.children = node.children.map((child: any) => {
|
|
||||||
const replacement = parseProParagraphNode(child)
|
|
||||||
if (replacement) return replacement
|
|
||||||
transformProBlockChildren(child)
|
|
||||||
return child
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProParagraphNode(node: any) {
|
|
||||||
if (!node || node.type !== 'paragraph' || !Array.isArray(node.children)) return null
|
|
||||||
if (node.children.length !== 1) return null
|
|
||||||
|
|
||||||
const textNode = node.children[0]
|
|
||||||
if (!textNode || textNode.type !== 'text' || typeof textNode.value !== 'string') return null
|
|
||||||
|
|
||||||
const parsed = parseProBlockSyntax(textNode.value)
|
|
||||||
if (!parsed) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: PRO_BLOCK_NODE_TYPE,
|
|
||||||
content: parsed.content,
|
|
||||||
autoStart: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findProBlockReplacements(doc: ProseNode) {
|
|
||||||
const replacements: Array<{ from: number; to: number; content: string; autoStart: boolean }> = []
|
|
||||||
|
|
||||||
doc.descendants((node, pos) => {
|
|
||||||
if (node.type.name !== 'paragraph' || node.childCount !== 1) return true
|
|
||||||
|
|
||||||
const firstChild = node.firstChild
|
|
||||||
if (!firstChild?.isText) return true
|
|
||||||
|
|
||||||
const parsed = parseProBlockSyntax(node.textContent)
|
|
||||||
if (!parsed) return true
|
|
||||||
|
|
||||||
replacements.push({
|
|
||||||
from: pos,
|
|
||||||
to: pos + node.nodeSize,
|
|
||||||
content: parsed.content,
|
|
||||||
autoStart: parsed.autoStart,
|
|
||||||
})
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
return replacements
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOcrContext(doc: ProseNode) {
|
|
||||||
const lines: string[] = []
|
|
||||||
|
|
||||||
doc.descendants((node) => {
|
|
||||||
if (!IMAGE_NODE_TYPES.has(node.type.name)) return true
|
|
||||||
const src = typeof node.attrs?.src === 'string' ? node.attrs.src : ''
|
|
||||||
if (!src) return true
|
|
||||||
const ocrText = getOcrCache(src)
|
|
||||||
if (!ocrText) return true
|
|
||||||
|
|
||||||
const preview = extractTextFromOCR(ocrText, 120)
|
|
||||||
if (!preview) return true
|
|
||||||
|
|
||||||
const label = typeof node.attrs?.alt === 'string' && node.attrs.alt.trim() ? node.attrs.alt.trim() : 'image'
|
|
||||||
lines.push(` <OCR:${preview}>`)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHighlightDecorations(doc: ProseNode, from: number, to: number) {
|
|
||||||
if (to <= from) return DecorationSet.empty
|
|
||||||
|
|
||||||
const decorations = [
|
|
||||||
Decoration.inline(from, to, { class: 'pro-block-accepted-highlight' }),
|
|
||||||
]
|
|
||||||
|
|
||||||
doc.nodesBetween(from, to, (node, pos) => {
|
|
||||||
if (!node.isBlock) return true
|
|
||||||
const nodeFrom = pos
|
|
||||||
const nodeTo = pos + node.nodeSize
|
|
||||||
if (nodeFrom >= from && nodeTo <= to) {
|
|
||||||
decorations.push(Decoration.node(nodeFrom, nodeTo, { class: 'pro-block-accepted-block' }))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return DecorationSet.create(doc, decorations)
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceWithParsedMarkdownSlice(tr: any, from: number, to: number, parsedDoc: ProseNode) {
|
|
||||||
if (!parsedDoc || parsedDoc.content.size <= 0) return null
|
|
||||||
|
|
||||||
const parsedSlice = Slice.maxOpen(parsedDoc.content)
|
|
||||||
if (!parsedSlice || parsedSlice.size <= 0) return null
|
|
||||||
|
|
||||||
tr.replaceRange(from, to, parsedSlice)
|
|
||||||
const startPos = tr.mapping.map(from, -1)
|
|
||||||
const endPos = tr.mapping.map(to, 1)
|
|
||||||
if (endPos <= startPos) return null
|
|
||||||
return { from: startPos, to: endPos }
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertProBlockNode(view: EditorView, autoStart: boolean) {
|
|
||||||
const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE]
|
|
||||||
if (!proBlockType) return false
|
|
||||||
|
|
||||||
const { from, to } = view.state.selection
|
|
||||||
const blockNode = proBlockType.create({ content: '', autoStart })
|
|
||||||
const tr = view.state.tr.replaceRangeWith(from, to, blockNode)
|
|
||||||
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
|
|
||||||
view.dispatch(tr.scrollIntoView())
|
|
||||||
view.focus()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceParagraphWithProBlock(
|
|
||||||
view: EditorView,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
content: string,
|
|
||||||
autoStart: boolean
|
|
||||||
) {
|
|
||||||
const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE]
|
|
||||||
if (!proBlockType) return false
|
|
||||||
|
|
||||||
const blockNode = proBlockType.create({
|
|
||||||
content,
|
|
||||||
autoStart: autoStart && content === '',
|
|
||||||
})
|
|
||||||
const tr = view.state.tr.replaceWith(from, to, blockNode)
|
|
||||||
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
|
|
||||||
tr.setMeta(PRO_BLOCK_INPUT_META, true)
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
|
|
||||||
view.dispatch(tr.scrollIntoView())
|
|
||||||
view.focus()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryHandleProTriggerTextInput(view: EditorView, insertedText: string) {
|
|
||||||
if (!insertedText || !insertedText.includes(']')) return false
|
|
||||||
|
|
||||||
const { state } = view
|
|
||||||
const { $from, from, to } = state.selection
|
|
||||||
const paragraph = $from.parent
|
|
||||||
if (!paragraph || paragraph.type.name !== 'paragraph') return false
|
|
||||||
if (!$from.sameParent(state.selection.$to)) return false
|
|
||||||
|
|
||||||
const paragraphDepth = $from.depth
|
|
||||||
const paragraphStart = $from.start(paragraphDepth)
|
|
||||||
const startOffset = from - paragraphStart
|
|
||||||
const endOffset = to - paragraphStart
|
|
||||||
const nextText = `${paragraph.textContent.slice(0, startOffset)}${insertedText}${paragraph.textContent.slice(endOffset)}`
|
|
||||||
const parsed = parseProBlockSyntax(nextText)
|
|
||||||
if (!parsed?.autoStart || parsed.content) return false
|
|
||||||
|
|
||||||
const blockFrom = $from.before(paragraphDepth)
|
|
||||||
const blockTo = blockFrom + paragraph.nodeSize
|
|
||||||
return replaceParagraphWithProBlock(view, blockFrom, blockTo, parsed.content, parsed.autoStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAbortError(error: unknown) {
|
|
||||||
return Boolean(error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const proBlockConfigCtx = $ctx<ProBlockConfig, 'proBlockConfig'>({
|
|
||||||
fetchSuggestionStream: async () => '',
|
|
||||||
getProModel: () => '',
|
|
||||||
showError: () => {},
|
|
||||||
isThinkingActive: () => false,
|
|
||||||
setThinkingActive: () => {},
|
|
||||||
}, 'proBlockConfig')
|
|
||||||
|
|
||||||
class ProBlockNodeView implements NodeView {
|
|
||||||
node: ProseNode
|
|
||||||
view: EditorView
|
|
||||||
getPos: (() => number) | boolean
|
|
||||||
dom: HTMLElement
|
|
||||||
app: ReturnType<typeof createApp> | null = null
|
|
||||||
props: Record<string, any>
|
|
||||||
parser: (markdown: string) => Promise<ProseNode>
|
|
||||||
serializer: (content: ProseNode) => string
|
|
||||||
config: ProBlockConfig
|
|
||||||
abortController: AbortController | null = null
|
|
||||||
requestSeq = 0
|
|
||||||
redoCount = 0
|
|
||||||
destroyed = false
|
|
||||||
highlightTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
node: ProseNode,
|
|
||||||
view: EditorView,
|
|
||||||
getPos: (() => number) | boolean,
|
|
||||||
parser: (markdown: string) => Promise<ProseNode>,
|
|
||||||
serializer: (content: ProseNode) => string,
|
|
||||||
config: ProBlockConfig
|
|
||||||
) {
|
|
||||||
this.node = node
|
|
||||||
this.view = view
|
|
||||||
this.getPos = getPos
|
|
||||||
this.parser = parser
|
|
||||||
this.serializer = serializer
|
|
||||||
this.config = config
|
|
||||||
this.dom = document.createElement('div')
|
|
||||||
this.dom.className = 'pro-block-node-view'
|
|
||||||
this.props = reactive({
|
|
||||||
stage: node.attrs.content ? 'done' : 'idle',
|
|
||||||
previewContent: '',
|
|
||||||
savedContent: node.attrs.content || '',
|
|
||||||
isBusy: false,
|
|
||||||
title: PRO_DISPLAY_LABEL,
|
|
||||||
activateAction: () => {
|
|
||||||
void this.startThinking(false)
|
|
||||||
},
|
|
||||||
discardAction: () => {
|
|
||||||
this.discardResult()
|
|
||||||
},
|
|
||||||
redoAction: () => {
|
|
||||||
void this.startThinking(true)
|
|
||||||
},
|
|
||||||
acceptAction: () => {
|
|
||||||
void this.acceptResult()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.mount()
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (!this.destroyed) {
|
|
||||||
this.consumeAutoStart()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
mount() {
|
|
||||||
this.app = createApp({
|
|
||||||
render: () => h(ProBlockCrepe, { ...this.props }),
|
|
||||||
})
|
|
||||||
this.app.mount(this.dom)
|
|
||||||
}
|
|
||||||
|
|
||||||
getPosValue() {
|
|
||||||
if (typeof this.getPos === 'function') {
|
|
||||||
try {
|
|
||||||
const pos = this.getPos()
|
|
||||||
if (typeof pos === 'number') return pos
|
|
||||||
} catch {
|
|
||||||
// Ignore and fall back to DOM lookup.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pos = this.view.posAtDOM(this.dom, 0)
|
|
||||||
return typeof pos === 'number' ? pos : undefined
|
|
||||||
} catch {
|
|
||||||
return 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
setStage(stage: 'idle' | 'thinking' | 'streaming' | 'done', previewContent = '') {
|
|
||||||
this.props.stage = stage
|
|
||||||
this.props.previewContent = previewContent
|
|
||||||
this.props.isBusy = stage === 'thinking' || stage === 'streaming'
|
|
||||||
}
|
|
||||||
|
|
||||||
buildRequestPayload(): ProRequestPayload {
|
|
||||||
const pos = this.getPosValue()
|
|
||||||
if (pos === undefined) {
|
|
||||||
return {
|
|
||||||
prefix: '',
|
|
||||||
suffix: '',
|
|
||||||
languageId: 'markdown',
|
|
||||||
blocked: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = this.view.state.doc
|
|
||||||
const schema = this.view.state.schema
|
|
||||||
const prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer)
|
|
||||||
|| doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
|
||||||
const suffixStart = Math.min(pos + this.node.nodeSize, doc.content.size)
|
|
||||||
const suffixMarkdown = serializeRangeToMarkdown(doc, suffixStart, doc.content.size, schema, this.serializer)
|
|
||||||
|| doc.textBetween(suffixStart, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
|
|
||||||
|
|
||||||
const ocrContext = buildOcrContext(doc)
|
|
||||||
const docContext = extractDocBlockContextFromMarkdown(`${prefixMarkdown}\n\n${suffixMarkdown}`, 1600)
|
|
||||||
const fullPrefix = [ocrContext, docContext, prefixMarkdown].filter(Boolean).join('\n\n')
|
|
||||||
|
|
||||||
return {
|
|
||||||
prefix: fullPrefix,
|
|
||||||
suffix: suffixMarkdown,
|
|
||||||
languageId: 'markdown',
|
|
||||||
blocked: fullPrefix.length + suffixMarkdown.length > PRO_CONTEXT_LIMIT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
consumeAutoStart() {
|
|
||||||
if (!this.node.attrs.autoStart) return
|
|
||||||
this.updateAttrs({ autoStart: false })
|
|
||||||
void this.startThinking(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
stopThinkingLock() {
|
|
||||||
this.config.setThinkingActive(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
abortActive(reason = 'abort') {
|
|
||||||
if (!this.abortController) return
|
|
||||||
this.abortController.abort(reason)
|
|
||||||
this.abortController = null
|
|
||||||
this.stopThinkingLock()
|
|
||||||
}
|
|
||||||
|
|
||||||
discardResult() {
|
|
||||||
this.abortActive('discard')
|
|
||||||
this.redoCount = 0
|
|
||||||
this.props.savedContent = ''
|
|
||||||
this.setStage('idle', '')
|
|
||||||
this.updateAttrs({ content: '', autoStart: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
async startThinking(isRedo: boolean) {
|
|
||||||
if (this.props.isBusy) return
|
|
||||||
const previousContent = this.node.attrs.content || ''
|
|
||||||
let requestSeq = this.requestSeq
|
|
||||||
let requestStarted = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (this.config.isThinkingActive()) {
|
|
||||||
this.config.showError('当前已有一个 PRO 思考正在进行,请先等待它完成。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = this.buildRequestPayload()
|
|
||||||
if (payload.blocked) {
|
|
||||||
this.config.showError('PRO 模式上下文超过 32KB,无法继续思考。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
requestSeq = this.requestSeq + 1
|
|
||||||
this.requestSeq = requestSeq
|
|
||||||
this.abortController = new AbortController()
|
|
||||||
this.config.setThinkingActive(true)
|
|
||||||
requestStarted = true
|
|
||||||
this.setStage('thinking', '')
|
|
||||||
|
|
||||||
const temperature = Math.min(1.2, 0.7 + (isRedo ? (this.redoCount + 1) * 0.2 : 0))
|
|
||||||
|
|
||||||
const result = await this.config.fetchSuggestionStream({
|
|
||||||
...payload,
|
|
||||||
signal: this.abortController.signal,
|
|
||||||
model: this.config.getProModel(),
|
|
||||||
temperature,
|
|
||||||
onChunk: (chunk) => {
|
|
||||||
if (this.destroyed || this.requestSeq !== requestSeq) return
|
|
||||||
const nextContent = `${this.props.previewContent || ''}${chunk}`
|
|
||||||
this.setStage('streaming', nextContent)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (this.destroyed || this.requestSeq !== requestSeq) return
|
|
||||||
|
|
||||||
const content = String(result || this.props.previewContent || '').trim()
|
|
||||||
this.redoCount = isRedo ? this.redoCount + 1 : 0
|
|
||||||
this.props.savedContent = content
|
|
||||||
this.setStage(content ? 'done' : 'idle', '')
|
|
||||||
this.updateAttrs({ content, autoStart: false })
|
|
||||||
} catch (error) {
|
|
||||||
if (!isAbortError(error)) {
|
|
||||||
this.config.showError(error instanceof Error ? error.message : 'PRO 模式思考失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.savedContent = previousContent
|
|
||||||
this.setStage(previousContent ? 'done' : 'idle', '')
|
|
||||||
} finally {
|
|
||||||
if (requestStarted && this.requestSeq === requestSeq) {
|
|
||||||
this.abortController = null
|
|
||||||
this.stopThinkingLock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async acceptResult() {
|
|
||||||
if (this.props.isBusy) return
|
|
||||||
|
|
||||||
const source = String(this.props.savedContent || this.node.attrs.content || '').trim()
|
|
||||||
if (!source) return
|
|
||||||
|
|
||||||
const pos = this.getPosValue()
|
|
||||||
if (pos === undefined) return
|
|
||||||
|
|
||||||
const from = pos
|
|
||||||
const to = pos + this.node.nodeSize
|
|
||||||
const tr = this.view.state.tr
|
|
||||||
let insertedRange: { from: number; to: number } | null = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsedDoc = await this.parser(source)
|
|
||||||
insertedRange = replaceWithParsedMarkdownSlice(tr, from, to, parsedDoc)
|
|
||||||
} catch {
|
|
||||||
insertedRange = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!insertedRange) {
|
|
||||||
tr.insertText(source, from, to)
|
|
||||||
insertedRange = { from, to: from + source.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
const endPos = Math.min(insertedRange.to, tr.doc.content.size)
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(endPos), 1))
|
|
||||||
tr.setMeta(PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY, { set: insertedRange })
|
|
||||||
this.view.dispatch(tr.scrollIntoView())
|
|
||||||
this.view.focus()
|
|
||||||
|
|
||||||
if (this.highlightTimer) {
|
|
||||||
clearTimeout(this.highlightTimer)
|
|
||||||
}
|
|
||||||
this.highlightTimer = setTimeout(() => {
|
|
||||||
if (this.destroyed) return
|
|
||||||
this.view.dispatch(this.view.state.tr.setMeta(PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY, { clear: true }))
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
update(node: ProseNode) {
|
|
||||||
if (node.type !== this.node.type) return false
|
|
||||||
this.node = node
|
|
||||||
|
|
||||||
if (!this.props.isBusy) {
|
|
||||||
this.props.savedContent = node.attrs.content || ''
|
|
||||||
this.props.previewContent = ''
|
|
||||||
this.props.stage = node.attrs.content ? 'done' : 'idle'
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
stopEvent(event: Event) {
|
|
||||||
const target = event.target as Node | null
|
|
||||||
return Boolean(target && this.dom.contains(target))
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreMutation() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.destroyed = true
|
|
||||||
this.abortActive('destroy')
|
|
||||||
if (this.highlightTimer) {
|
|
||||||
clearTimeout(this.highlightTimer)
|
|
||||||
this.highlightTimer = null
|
|
||||||
}
|
|
||||||
this.app?.unmount()
|
|
||||||
this.app = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const proBlockRemark = $remark('proBlockRemark', () => () => {
|
|
||||||
return (tree: any) => {
|
|
||||||
transformProBlockChildren(tree)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
|
|
||||||
group: 'block',
|
|
||||||
atom: true,
|
|
||||||
isolating: true,
|
|
||||||
selectable: true,
|
|
||||||
draggable: false,
|
|
||||||
marks: '',
|
|
||||||
attrs: {
|
|
||||||
content: { default: '' },
|
|
||||||
autoStart: { default: false },
|
|
||||||
},
|
|
||||||
parseDOM: [
|
|
||||||
{
|
|
||||||
tag: 'div[data-pro-block="true"]',
|
|
||||||
getAttrs: (dom) => ({
|
|
||||||
content: (dom as HTMLElement).getAttribute('data-pro-content') || '',
|
|
||||||
autoStart: ((dom as HTMLElement).getAttribute('data-pro-autostart') || '') === 'true',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
toDOM: (node) => [
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
'data-pro-block': 'true',
|
|
||||||
'data-pro-content': node.attrs.content || '',
|
|
||||||
'data-pro-autostart': String(Boolean(node.attrs.autoStart)),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
parseMarkdown: {
|
|
||||||
match: (node) => node.type === PRO_BLOCK_NODE_TYPE,
|
|
||||||
runner: (state, node, type) => {
|
|
||||||
state.addNode(type, {
|
|
||||||
content: String(node.content || ''),
|
|
||||||
autoStart: false,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toMarkdown: {
|
|
||||||
match: (node) => node.type.name === PRO_BLOCK_NODE_TYPE,
|
|
||||||
runner: (state, node) => {
|
|
||||||
state.addNode('paragraph', [{
|
|
||||||
type: 'text',
|
|
||||||
value: serializeProBlockSyntax(node.attrs.content),
|
|
||||||
}])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
leafText: (node) => serializeProBlockSyntax(node.attrs.content),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const proBlockView = $view(proBlockNode, (ctx) => {
|
|
||||||
const parser = ctx.get(parserCtx)
|
|
||||||
const serializer = ctx.get(serializerCtx)
|
|
||||||
const config = ctx.get(proBlockConfigCtx.key)
|
|
||||||
return (node, view, getPos) => new ProBlockNodeView(node, view, getPos, parser, serializer, config)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const proBlockInputPlugin = $prose(() => {
|
|
||||||
return new Plugin({
|
|
||||||
key: PRO_BLOCK_INPUT_PLUGIN_KEY,
|
|
||||||
props: {
|
|
||||||
handleTextInput: (view, _from, _to, text) => {
|
|
||||||
return tryHandleProTriggerTextInput(view, text)
|
|
||||||
},
|
|
||||||
handleKeyDown: (view, event) => {
|
|
||||||
const pressed = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'p'
|
|
||||||
if (!pressed) return false
|
|
||||||
event.preventDefault()
|
|
||||||
return insertProBlockNode(view, false)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appendTransaction: (transactions, _oldState, newState) => {
|
|
||||||
if (!transactions.some((transaction) => transaction.docChanged)) return null
|
|
||||||
if (transactions.some((transaction) => transaction.getMeta(PRO_BLOCK_INPUT_META))) return null
|
|
||||||
|
|
||||||
const proBlockType = newState.schema.nodes[PRO_BLOCK_NODE_TYPE]
|
|
||||||
if (!proBlockType) return null
|
|
||||||
|
|
||||||
const replacements = findProBlockReplacements(newState.doc)
|
|
||||||
if (replacements.length === 0) return null
|
|
||||||
|
|
||||||
let tr = newState.tr
|
|
||||||
|
|
||||||
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
|
||||||
const replacement = replacements[index]
|
|
||||||
tr = tr.replaceWith(
|
|
||||||
replacement.from,
|
|
||||||
replacement.to,
|
|
||||||
proBlockType.create({
|
|
||||||
content: replacement.content,
|
|
||||||
autoStart: replacement.autoStart && replacement.content === '',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tr.docChanged) return null
|
|
||||||
tr.setMeta(PRO_BLOCK_INPUT_META, true)
|
|
||||||
return tr
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const proBlockHighlightPlugin = $prose(() => {
|
|
||||||
return new Plugin<DecorationSet>({
|
|
||||||
key: PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY,
|
|
||||||
state: {
|
|
||||||
init: () => DecorationSet.empty,
|
|
||||||
apply: (tr, value) => {
|
|
||||||
const meta = tr.getMeta(PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY)
|
|
||||||
if (meta?.clear) {
|
|
||||||
return DecorationSet.empty
|
|
||||||
}
|
|
||||||
if (meta?.set) {
|
|
||||||
return createHighlightDecorations(tr.doc, meta.set.from, meta.set.to)
|
|
||||||
}
|
|
||||||
return value.map(tr.mapping, tr.doc)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations: (state) => PRO_BLOCK_HIGHLIGHT_PLUGIN_KEY.getState(state),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export function insertProBlockAtSelection(view: EditorView, autoStart = false) {
|
|
||||||
return insertProBlockNode(view, autoStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { PRO_TRIGGER_TEXT }
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import { createApp, h, reactive } from 'vue'
|
|
||||||
import { Plugin, PluginKey } from '@milkdown/prose/state'
|
|
||||||
import { $ctx, $node, $prose, $remark, $view } from '@milkdown/kit/utils'
|
|
||||||
import type { Node as ProseNode } from '@milkdown/prose/model'
|
|
||||||
import type { EditorView, NodeView } from '@milkdown/prose/view'
|
|
||||||
import UploadBlockCrepe from '../components/UploadBlockCrepe.vue'
|
|
||||||
import {
|
|
||||||
DEFAULT_UPLOAD_BLOCK_TYPES,
|
|
||||||
UPLOAD_BLOCK_NODE_TYPE,
|
|
||||||
getUploadBlockAccept,
|
|
||||||
getUploadBlockMenuOptions,
|
|
||||||
normalizeUploadBlockTypes,
|
|
||||||
parseUploadBlockSyntax,
|
|
||||||
serializeUploadBlockSyntax,
|
|
||||||
} from '../utils/uploadBlock.js'
|
|
||||||
|
|
||||||
const UPLOAD_BLOCK_INPUT_PLUGIN_KEY = new PluginKey('milkdown-upload-block-input')
|
|
||||||
const UPLOAD_BLOCK_INPUT_META = 'upload-block-input-meta'
|
|
||||||
|
|
||||||
interface UploadBlockConfig {
|
|
||||||
requestUpload: (payload: { file: File; allowedTypes: string[]; from: number; to: number }) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const uploadBlockConfigCtx = $ctx<UploadBlockConfig, 'uploadBlockConfig'>({
|
|
||||||
requestUpload: async () => {},
|
|
||||||
}, 'uploadBlockConfig')
|
|
||||||
|
|
||||||
function transformUploadBlockChildren(node: any) {
|
|
||||||
if (!node || !Array.isArray(node.children)) return
|
|
||||||
|
|
||||||
node.children = node.children.map((child: any) => {
|
|
||||||
const replacement = parseUploadParagraphNode(child)
|
|
||||||
if (replacement) return replacement
|
|
||||||
transformUploadBlockChildren(child)
|
|
||||||
return child
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUploadParagraphNode(node: any) {
|
|
||||||
if (!node || node.type !== 'paragraph' || !Array.isArray(node.children)) return null
|
|
||||||
if (node.children.length !== 1) return null
|
|
||||||
|
|
||||||
const textNode = node.children[0]
|
|
||||||
if (!textNode || textNode.type !== 'text' || typeof textNode.value !== 'string') return null
|
|
||||||
|
|
||||||
const parsed = parseUploadBlockSyntax(textNode.value)
|
|
||||||
if (!parsed) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: UPLOAD_BLOCK_NODE_TYPE,
|
|
||||||
allowedTypes: parsed.allowedTypes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findUploadBlockReplacements(doc: ProseNode) {
|
|
||||||
const replacements: Array<{ from: number; to: number; allowedTypes: string[] }> = []
|
|
||||||
|
|
||||||
doc.descendants((node, pos) => {
|
|
||||||
if (node.type.name !== 'paragraph' || node.childCount !== 1) return true
|
|
||||||
|
|
||||||
const firstChild = node.firstChild
|
|
||||||
if (!firstChild?.isText) return true
|
|
||||||
|
|
||||||
const parsed = parseUploadBlockSyntax(node.textContent)
|
|
||||||
if (!parsed) return true
|
|
||||||
|
|
||||||
replacements.push({
|
|
||||||
from: pos,
|
|
||||||
to: pos + node.nodeSize,
|
|
||||||
allowedTypes: parsed.allowedTypes,
|
|
||||||
})
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
return replacements
|
|
||||||
}
|
|
||||||
|
|
||||||
class UploadBlockNodeView implements NodeView {
|
|
||||||
node: ProseNode
|
|
||||||
view: EditorView
|
|
||||||
getPos: (() => number) | boolean
|
|
||||||
dom: HTMLElement
|
|
||||||
app: ReturnType<typeof createApp> | null = null
|
|
||||||
props: Record<string, any>
|
|
||||||
requestUpload: UploadBlockConfig['requestUpload']
|
|
||||||
|
|
||||||
constructor(node: ProseNode, view: EditorView, getPos: (() => number) | boolean, requestUpload: UploadBlockConfig['requestUpload']) {
|
|
||||||
this.node = node
|
|
||||||
this.view = view
|
|
||||||
this.getPos = getPos
|
|
||||||
this.requestUpload = requestUpload
|
|
||||||
this.dom = document.createElement('div')
|
|
||||||
this.dom.className = 'upload-block-node-view'
|
|
||||||
|
|
||||||
const allowedTypes = normalizeUploadBlockTypes(node.attrs.allowedTypes)
|
|
||||||
|
|
||||||
this.props = reactive({
|
|
||||||
allowedTypes,
|
|
||||||
accept: getUploadBlockAccept(allowedTypes),
|
|
||||||
menuOptions: getUploadBlockMenuOptions(allowedTypes),
|
|
||||||
onUploadRequest: (file: File) => this.handleUploadRequest(file),
|
|
||||||
})
|
|
||||||
|
|
||||||
this.mount()
|
|
||||||
}
|
|
||||||
|
|
||||||
mount() {
|
|
||||||
this.app = createApp({
|
|
||||||
render: () => h(UploadBlockCrepe, this.props),
|
|
||||||
})
|
|
||||||
this.app.mount(this.dom)
|
|
||||||
}
|
|
||||||
|
|
||||||
getPosValue() {
|
|
||||||
if (typeof this.getPos === 'function') {
|
|
||||||
try {
|
|
||||||
const pos = this.getPos()
|
|
||||||
if (typeof pos === 'number') return pos
|
|
||||||
} catch {
|
|
||||||
// Ignore and fall back to DOM lookup.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pos = this.view.posAtDOM(this.dom, 0)
|
|
||||||
return typeof pos === 'number' ? pos : undefined
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleUploadRequest(file: File) {
|
|
||||||
const pos = this.getPosValue()
|
|
||||||
if (pos === undefined || !file) return
|
|
||||||
|
|
||||||
await this.requestUpload({
|
|
||||||
file,
|
|
||||||
allowedTypes: normalizeUploadBlockTypes(this.node.attrs.allowedTypes),
|
|
||||||
from: pos,
|
|
||||||
to: pos + this.node.nodeSize,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
update(node: ProseNode) {
|
|
||||||
if (node.type !== this.node.type) return false
|
|
||||||
this.node = node
|
|
||||||
|
|
||||||
const allowedTypes = normalizeUploadBlockTypes(node.attrs.allowedTypes)
|
|
||||||
this.props.allowedTypes = allowedTypes
|
|
||||||
this.props.accept = getUploadBlockAccept(allowedTypes)
|
|
||||||
this.props.menuOptions = getUploadBlockMenuOptions(allowedTypes)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const uploadBlockRemark = $remark('uploadBlockRemark', () => () => {
|
|
||||||
return (tree: any) => {
|
|
||||||
transformUploadBlockChildren(tree)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const uploadBlockNode = $node(UPLOAD_BLOCK_NODE_TYPE, () => ({
|
|
||||||
group: 'block',
|
|
||||||
atom: true,
|
|
||||||
isolating: true,
|
|
||||||
selectable: true,
|
|
||||||
draggable: false,
|
|
||||||
marks: '',
|
|
||||||
attrs: {
|
|
||||||
allowedTypes: { default: [...DEFAULT_UPLOAD_BLOCK_TYPES] },
|
|
||||||
},
|
|
||||||
parseDOM: [
|
|
||||||
{
|
|
||||||
tag: 'div[data-upload-block="true"]',
|
|
||||||
getAttrs: (dom) => ({
|
|
||||||
allowedTypes: normalizeUploadBlockTypes(
|
|
||||||
String((dom as HTMLElement).getAttribute('data-upload-types') || '')
|
|
||||||
.split(',')
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
toDOM: (node) => [
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
'data-upload-block': 'true',
|
|
||||||
'data-upload-types': normalizeUploadBlockTypes(node.attrs.allowedTypes).join(','),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
parseMarkdown: {
|
|
||||||
match: (node) => node.type === UPLOAD_BLOCK_NODE_TYPE,
|
|
||||||
runner: (state, node, type) => {
|
|
||||||
state.addNode(type, {
|
|
||||||
allowedTypes: normalizeUploadBlockTypes(node.allowedTypes),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toMarkdown: {
|
|
||||||
match: (node) => node.type.name === UPLOAD_BLOCK_NODE_TYPE,
|
|
||||||
runner: (state, node) => {
|
|
||||||
state.addNode('paragraph', [{
|
|
||||||
type: 'text',
|
|
||||||
value: serializeUploadBlockSyntax(node.attrs.allowedTypes),
|
|
||||||
}])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
leafText: (node) => serializeUploadBlockSyntax(node.attrs.allowedTypes),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const uploadBlockView = $view(uploadBlockNode, (ctx) => {
|
|
||||||
const requestUpload = ctx.get(uploadBlockConfigCtx.key).requestUpload
|
|
||||||
return (node, view, getPos) => new UploadBlockNodeView(node, view, getPos, requestUpload)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const uploadBlockInputPlugin = $prose(() => {
|
|
||||||
return new Plugin({
|
|
||||||
key: UPLOAD_BLOCK_INPUT_PLUGIN_KEY,
|
|
||||||
appendTransaction: (transactions, _oldState, newState) => {
|
|
||||||
if (!transactions.some((transaction) => transaction.docChanged)) return null
|
|
||||||
if (transactions.some((transaction) => transaction.getMeta(UPLOAD_BLOCK_INPUT_META))) return null
|
|
||||||
|
|
||||||
const uploadBlockType = newState.schema.nodes[UPLOAD_BLOCK_NODE_TYPE]
|
|
||||||
if (!uploadBlockType) return null
|
|
||||||
|
|
||||||
const replacements = findUploadBlockReplacements(newState.doc)
|
|
||||||
if (replacements.length === 0) return null
|
|
||||||
|
|
||||||
let tr = newState.tr
|
|
||||||
|
|
||||||
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
|
||||||
const replacement = replacements[index]
|
|
||||||
tr = tr.replaceWith(
|
|
||||||
replacement.from,
|
|
||||||
replacement.to,
|
|
||||||
uploadBlockType.create({
|
|
||||||
allowedTypes: normalizeUploadBlockTypes(replacement.allowedTypes),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tr.docChanged) return null
|
|
||||||
tr.setMeta(UPLOAD_BLOCK_INPUT_META, true)
|
|
||||||
return tr
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
+2
-11
@@ -3,17 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Editor',
|
name: 'Home',
|
||||||
component: () => import('../views/EditorView.vue')
|
component: () => import('../App.vue')
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/docs',
|
|
||||||
name: 'Docs',
|
|
||||||
component: () => import('../views/DocsView.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/:pathMatch(.*)*',
|
|
||||||
redirect: '/'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* Office 文件类型检测工具
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const OfficeFormat = {
|
|
||||||
DOCX: 'docx',
|
|
||||||
XLSX: 'xlsx',
|
|
||||||
PPTX: 'pptx'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 支持的 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 default {
|
|
||||||
OfficeFormat,
|
|
||||||
isOfficeFile,
|
|
||||||
getOfficeFormat,
|
|
||||||
SUPPORTED_EXTENSIONS,
|
|
||||||
MIME_TYPES
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
export default pinia
|
||||||
+1
-18
@@ -11,7 +11,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
// 2. Model Behavior
|
// 2. Model Behavior
|
||||||
const modelThinking = ref('low') // 'low' | 'medium' | 'high'
|
const modelThinking = ref('low') // 'low' | 'medium' | 'high'
|
||||||
const debounceMs = ref(1000) // 1000 - 5000
|
const debounceMs = ref(1000) // 1000 - 5000
|
||||||
const proModel = ref('')
|
|
||||||
|
|
||||||
// 3. Privacy
|
// 3. Privacy
|
||||||
const privacyMode = ref(true)
|
const privacyMode = ref(true)
|
||||||
@@ -24,8 +23,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
|
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
|
||||||
const backgroundImage = ref('')
|
const backgroundImage = ref('')
|
||||||
const backgroundOpacity = ref(0.2) // 0.05 - 0.50
|
const backgroundOpacity = ref(0.2) // 0.05 - 0.50
|
||||||
// TTS Voice
|
|
||||||
const ttsInstruct = ref('')
|
|
||||||
|
|
||||||
// --- Getters ---
|
// --- Getters ---
|
||||||
const uiLanguage = computed(() => {
|
const uiLanguage = computed(() => {
|
||||||
@@ -45,10 +42,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
// We will let the backend handle 'auto' currency if needed, or stick to auto label.
|
// We will let the backend handle 'auto' currency if needed, or stick to auto label.
|
||||||
|
|
||||||
const t = computed(() => {
|
const t = computed(() => {
|
||||||
return {
|
return translations[uiLanguage.value] || translations['en']
|
||||||
...translations['en'],
|
|
||||||
...(translations[uiLanguage.value] || {}),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const initialMarkdown = computed(() => {
|
const initialMarkdown = computed(() => {
|
||||||
@@ -67,7 +61,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
if (data.theme) theme.value = data.theme
|
if (data.theme) theme.value = data.theme
|
||||||
if (data.modelThinking) modelThinking.value = data.modelThinking
|
if (data.modelThinking) modelThinking.value = data.modelThinking
|
||||||
if (data.debounceMs) debounceMs.value = data.debounceMs
|
if (data.debounceMs) debounceMs.value = data.debounceMs
|
||||||
if (typeof data.proModel === 'string') proModel.value = data.proModel
|
|
||||||
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
|
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
|
||||||
if (data.language) language.value = data.language
|
if (data.language) language.value = data.language
|
||||||
if (data.currency) currency.value = data.currency
|
if (data.currency) currency.value = data.currency
|
||||||
@@ -77,7 +70,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
}
|
}
|
||||||
if (data.backgroundImage) backgroundImage.value = data.backgroundImage
|
if (data.backgroundImage) backgroundImage.value = data.backgroundImage
|
||||||
if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
|
if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
|
||||||
if (typeof data.ttsInstruct === 'string') ttsInstruct.value = data.ttsInstruct
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Failed to load settings, use defaults
|
// Failed to load settings, use defaults
|
||||||
@@ -91,14 +83,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
theme: theme.value,
|
theme: theme.value,
|
||||||
modelThinking: modelThinking.value,
|
modelThinking: modelThinking.value,
|
||||||
debounceMs: debounceMs.value,
|
debounceMs: debounceMs.value,
|
||||||
proModel: proModel.value,
|
|
||||||
privacyMode: privacyMode.value,
|
privacyMode: privacyMode.value,
|
||||||
language: language.value,
|
language: language.value,
|
||||||
currency: currency.value,
|
currency: currency.value,
|
||||||
backgroundType: backgroundType.value,
|
backgroundType: backgroundType.value,
|
||||||
backgroundImage: backgroundImage.value,
|
backgroundImage: backgroundImage.value,
|
||||||
backgroundOpacity: backgroundOpacity.value,
|
backgroundOpacity: backgroundOpacity.value,
|
||||||
ttsInstruct: ttsInstruct.value,
|
|
||||||
}
|
}
|
||||||
localStorage.setItem('llm-in-text-settings', JSON.stringify(data))
|
localStorage.setItem('llm-in-text-settings', JSON.stringify(data))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -111,14 +101,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
theme.value = 'system'
|
theme.value = 'system'
|
||||||
modelThinking.value = 'low'
|
modelThinking.value = 'low'
|
||||||
debounceMs.value = 1000
|
debounceMs.value = 1000
|
||||||
proModel.value = ''
|
|
||||||
privacyMode.value = false
|
privacyMode.value = false
|
||||||
language.value = 'auto'
|
language.value = 'auto'
|
||||||
currency.value = 'auto'
|
currency.value = 'auto'
|
||||||
backgroundType.value = 'default'
|
backgroundType.value = 'default'
|
||||||
backgroundImage.value = ''
|
backgroundImage.value = ''
|
||||||
backgroundOpacity.value = 0.2
|
backgroundOpacity.value = 0.2
|
||||||
ttsInstruct.value = ''
|
|
||||||
saveSettings()
|
saveSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,14 +116,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
theme,
|
theme,
|
||||||
modelThinking,
|
modelThinking,
|
||||||
debounceMs,
|
debounceMs,
|
||||||
proModel,
|
|
||||||
privacyMode,
|
privacyMode,
|
||||||
language,
|
language,
|
||||||
currency,
|
currency,
|
||||||
backgroundType,
|
backgroundType,
|
||||||
backgroundImage,
|
backgroundImage,
|
||||||
backgroundOpacity,
|
backgroundOpacity,
|
||||||
ttsInstruct,
|
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
saveSettings()
|
saveSettings()
|
||||||
@@ -149,15 +135,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
theme,
|
theme,
|
||||||
modelThinking,
|
modelThinking,
|
||||||
debounceMs,
|
debounceMs,
|
||||||
proModel,
|
|
||||||
privacyMode,
|
privacyMode,
|
||||||
language,
|
language,
|
||||||
currency,
|
currency,
|
||||||
backgroundType,
|
backgroundType,
|
||||||
backgroundImage,
|
backgroundImage,
|
||||||
backgroundOpacity,
|
backgroundOpacity,
|
||||||
ttsInstruct,
|
|
||||||
detectedTimezone,
|
|
||||||
uiLanguage,
|
uiLanguage,
|
||||||
t,
|
t,
|
||||||
initialMarkdown,
|
initialMarkdown,
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'llm-in-text-templates'
|
|
||||||
const STORAGE_VERSION = 1
|
|
||||||
|
|
||||||
const PRESET_TEMPLATES = [
|
|
||||||
{
|
|
||||||
id: 'preset-meeting-notes',
|
|
||||||
name: '会议纪要',
|
|
||||||
content: `# 会议纪要
|
|
||||||
|
|
||||||
- 日期:
|
|
||||||
- 时间:
|
|
||||||
- 地点:
|
|
||||||
- 参会人:
|
|
||||||
|
|
||||||
## 议题
|
|
||||||
|
|
||||||
## 讨论记录
|
|
||||||
|
|
||||||
## 决议
|
|
||||||
|
|
||||||
## 待办事项
|
|
||||||
- [ ] `,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'preset-article-outline',
|
|
||||||
name: '文章大纲',
|
|
||||||
content: `# 标题
|
|
||||||
|
|
||||||
> 一句话摘要
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
## 结构
|
|
||||||
|
|
||||||
## 关键点
|
|
||||||
|
|
||||||
## 结论`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'preset-project-plan',
|
|
||||||
name: '项目计划',
|
|
||||||
content: `# 项目计划
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
## 范围
|
|
||||||
|
|
||||||
## 里程碑
|
|
||||||
|
|
||||||
## 风险
|
|
||||||
|
|
||||||
## 负责人`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'preset-research-notes',
|
|
||||||
name: '研究笔记',
|
|
||||||
content: `# 研究笔记
|
|
||||||
|
|
||||||
## 问题定义
|
|
||||||
|
|
||||||
## 资料来源
|
|
||||||
|
|
||||||
## 观察
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
## 下一步`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const normalizeTemplateName = (value) => String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
||||||
|
|
||||||
const normalizeTemplateContent = (value) => String(value ?? '').replace(/\r\n?/g, '\n').replace(/\s+$/, '')
|
|
||||||
|
|
||||||
const makeTemplateId = () => {
|
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
||||||
return `custom-${crypto.randomUUID()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeStoredTemplate = (template) => {
|
|
||||||
if (!template || typeof template !== 'object') return null
|
|
||||||
|
|
||||||
const name = normalizeTemplateName(template.name)
|
|
||||||
const content = normalizeTemplateContent(template.content)
|
|
||||||
if (!name || !content.trim()) return null
|
|
||||||
|
|
||||||
const createdAt = typeof template.createdAt === 'string' && template.createdAt ? template.createdAt : new Date().toISOString()
|
|
||||||
const updatedAt = typeof template.updatedAt === 'string' && template.updatedAt ? template.updatedAt : createdAt
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: typeof template.id === 'string' && template.id ? template.id : makeTemplateId(),
|
|
||||||
name,
|
|
||||||
content,
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTemplatesStore = defineStore('templates', () => {
|
|
||||||
const customTemplates = ref([])
|
|
||||||
let isHydrated = false
|
|
||||||
|
|
||||||
const loadTemplates = () => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!stored) return
|
|
||||||
|
|
||||||
const parsed = JSON.parse(stored)
|
|
||||||
const rawTemplates = Array.isArray(parsed)
|
|
||||||
? parsed
|
|
||||||
: Array.isArray(parsed?.customTemplates)
|
|
||||||
? parsed.customTemplates
|
|
||||||
: Array.isArray(parsed?.data)
|
|
||||||
? parsed.data
|
|
||||||
: []
|
|
||||||
|
|
||||||
customTemplates.value = rawTemplates
|
|
||||||
.map(normalizeStoredTemplate)
|
|
||||||
.filter(Boolean)
|
|
||||||
} catch {
|
|
||||||
customTemplates.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveTemplates = () => {
|
|
||||||
if (!isHydrated) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
version: STORAGE_VERSION,
|
|
||||||
customTemplates: customTemplates.value,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// Ignore persistence failures in browser storage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCustomTemplateNameTaken = (name, ignoreId = '') => {
|
|
||||||
const normalizedName = normalizeTemplateName(name)
|
|
||||||
if (!normalizedName) return false
|
|
||||||
|
|
||||||
return customTemplates.value.some((template) => {
|
|
||||||
if (ignoreId && template.id === ignoreId) return false
|
|
||||||
return template.name.toLowerCase() === normalizedName.toLowerCase()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildUniqueCustomTemplateName = (baseName, ignoreId = '') => {
|
|
||||||
const normalizedBaseName = normalizeTemplateName(baseName) || 'Untitled Template'
|
|
||||||
let candidate = normalizedBaseName
|
|
||||||
let suffix = 2
|
|
||||||
|
|
||||||
while (isCustomTemplateNameTaken(candidate, ignoreId)) {
|
|
||||||
candidate = `${normalizedBaseName} ${suffix}`
|
|
||||||
suffix += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
const createCustomTemplate = ({ name, content }) => {
|
|
||||||
const nextName = normalizeTemplateName(name)
|
|
||||||
const nextContent = normalizeTemplateContent(content)
|
|
||||||
|
|
||||||
if (!nextName || !nextContent.trim()) {
|
|
||||||
return { ok: false, error: 'invalid' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCustomTemplateNameTaken(nextName)) {
|
|
||||||
return { ok: false, error: 'duplicate' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const template = {
|
|
||||||
id: makeTemplateId(),
|
|
||||||
name: nextName,
|
|
||||||
content: nextContent,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
customTemplates.value = [...customTemplates.value, template]
|
|
||||||
return { ok: true, template }
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCustomTemplate = (id, { name, content }) => {
|
|
||||||
const index = customTemplates.value.findIndex((template) => template.id === id)
|
|
||||||
if (index === -1) {
|
|
||||||
return { ok: false, error: 'not_found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextName = normalizeTemplateName(name)
|
|
||||||
const nextContent = normalizeTemplateContent(content)
|
|
||||||
|
|
||||||
if (!nextName || !nextContent.trim()) {
|
|
||||||
return { ok: false, error: 'invalid' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCustomTemplateNameTaken(nextName, id)) {
|
|
||||||
return { ok: false, error: 'duplicate' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const nextTemplates = [...customTemplates.value]
|
|
||||||
nextTemplates[index] = {
|
|
||||||
...nextTemplates[index],
|
|
||||||
name: nextName,
|
|
||||||
content: nextContent,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
customTemplates.value = nextTemplates
|
|
||||||
return { ok: true, template: nextTemplates[index] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteCustomTemplate = (id) => {
|
|
||||||
const nextTemplates = customTemplates.value.filter((template) => template.id !== id)
|
|
||||||
if (nextTemplates.length === customTemplates.value.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
customTemplates.value = nextTemplates
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTemplateById = (id) => {
|
|
||||||
if (!id) return null
|
|
||||||
|
|
||||||
return PRESET_TEMPLATES.find((template) => template.id === id)
|
|
||||||
|| customTemplates.value.find((template) => template.id === id)
|
|
||||||
|| null
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(customTemplates, saveTemplates, { deep: true })
|
|
||||||
|
|
||||||
loadTemplates()
|
|
||||||
isHydrated = true
|
|
||||||
saveTemplates()
|
|
||||||
|
|
||||||
return {
|
|
||||||
presetTemplates: PRESET_TEMPLATES,
|
|
||||||
customTemplates,
|
|
||||||
createCustomTemplate,
|
|
||||||
updateCustomTemplate,
|
|
||||||
deleteCustomTemplate,
|
|
||||||
getTemplateById,
|
|
||||||
buildUniqueCustomTemplateName,
|
|
||||||
isCustomTemplateNameTaken,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+75
-278
@@ -37,21 +37,16 @@
|
|||||||
--toggle-moon: #475569;
|
--toggle-moon: #475569;
|
||||||
--ghost-text: #7d8796;
|
--ghost-text: #7d8796;
|
||||||
--ghost-code-bg: rgba(15, 23, 42, 0.06);
|
--ghost-code-bg: rgba(15, 23, 42, 0.06);
|
||||||
--code-inline-bg: rgba(15, 23, 42, 0.06);
|
--mermaid-max-height: 420px;
|
||||||
--code-block-bg: #f8fafc;
|
|
||||||
--code-block-border: rgba(148, 163, 184, 0.28);
|
|
||||||
--code-text: #0f172a;
|
|
||||||
--mermaid-max-height: 480px;
|
|
||||||
--mermaid-mobile-max-height: 320px;
|
--mermaid-mobile-max-height: 320px;
|
||||||
--mermaid-glass-bg: rgba(255, 255, 255, 0.55);
|
--mermaid-action-bg: linear-gradient(180deg, #ffffff 0%, #f4f7fc 100%);
|
||||||
--mermaid-glass-border: rgba(180, 200, 230, 0.5);
|
--mermaid-action-hover-bg: linear-gradient(180deg, #ffffff 0%, #e9f2ff 100%);
|
||||||
--mermaid-glass-border-hover: rgba(59, 130, 246, 0.45);
|
--mermaid-action-fg: #1f2937;
|
||||||
--mermaid-glass-shadow: 0 4px 24px rgba(15, 23, 42, 0.08), 0 1px 4px rgba(15, 23, 42, 0.05);
|
--mermaid-action-border: #cfd8e6;
|
||||||
--mermaid-glass-shadow-hover: 0 8px 32px rgba(59, 130, 246, 0.12), 0 2px 8px rgba(15, 23, 42, 0.08);
|
--mermaid-action-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||||
--mermaid-btn-bg: rgba(255, 255, 255, 0.8);
|
--mermaid-action-shadow-hover: 0 4px 10px rgba(37, 99, 235, 0.16);
|
||||||
--mermaid-btn-hover-bg: rgba(239, 246, 255, 0.95);
|
--mermaid-action-disabled-bg: rgba(148, 163, 184, 0.18);
|
||||||
--mermaid-btn-fg: #374151;
|
--mermaid-action-disabled-fg: #9aa4b2;
|
||||||
--mermaid-btn-border: rgba(180, 200, 230, 0.6);
|
|
||||||
|
|
||||||
--crepe-color-background: #ffffff;
|
--crepe-color-background: #ffffff;
|
||||||
--crepe-color-on-background: #000000;
|
--crepe-color-on-background: #000000;
|
||||||
@@ -72,30 +67,6 @@
|
|||||||
--crepe-color-inline-area: #cacaca;
|
--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'] {
|
:root[data-theme='dark'] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--app-bg: #0f1117;
|
--app-bg: #0f1117;
|
||||||
@@ -124,21 +95,14 @@
|
|||||||
--toggle-moon: #e2e8f0;
|
--toggle-moon: #e2e8f0;
|
||||||
--ghost-text: #95a0b4;
|
--ghost-text: #95a0b4;
|
||||||
--ghost-code-bg: rgba(226, 232, 240, 0.12);
|
--ghost-code-bg: rgba(226, 232, 240, 0.12);
|
||||||
--code-inline-bg: rgba(30, 41, 59, 0.92);
|
--mermaid-action-bg: linear-gradient(180deg, #30394b 0%, #242c3a 100%);
|
||||||
--code-block-bg: rgba(15, 23, 42, 0.92);
|
--mermaid-action-hover-bg: linear-gradient(180deg, #3a4760 0%, #2a3445 100%);
|
||||||
--code-block-border: rgba(71, 85, 105, 0.55);
|
--mermaid-action-fg: #e5e7eb;
|
||||||
--code-text: #e2e8f0;
|
--mermaid-action-border: #3e4a61;
|
||||||
--mermaid-max-height: 480px;
|
--mermaid-action-shadow: 0 1px 2px rgba(2, 6, 23, 0.45);
|
||||||
--mermaid-mobile-max-height: 320px;
|
--mermaid-action-shadow-hover: 0 5px 12px rgba(2, 6, 23, 0.55);
|
||||||
--mermaid-glass-bg: rgba(20, 28, 44, 0.6);
|
--mermaid-action-disabled-bg: rgba(82, 93, 110, 0.3);
|
||||||
--mermaid-glass-border: rgba(60, 80, 120, 0.4);
|
--mermaid-action-disabled-fg: #9aa4b2;
|
||||||
--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);
|
|
||||||
|
|
||||||
--crepe-color-background: #1a1a1a;
|
--crepe-color-background: #1a1a1a;
|
||||||
--crepe-color-on-background: #e6e6e6;
|
--crepe-color-on-background: #e6e6e6;
|
||||||
@@ -244,181 +208,116 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden-text-preview {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
vertical-align: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-text-preview__summary {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 1.8em;
|
|
||||||
padding: 0.08em 0.52em;
|
|
||||||
border-radius: 0.45em;
|
|
||||||
background: rgba(148, 163, 184, 0.26);
|
|
||||||
color: inherit;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='dark'] .hidden-text-preview__summary {
|
|
||||||
background: rgba(100, 116, 139, 0.32);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mermaid diagram blocks ─────────────────────────────────────────── */
|
/* ── Mermaid diagram blocks ─────────────────────────────────────────── */
|
||||||
.mermaid-block {
|
.mermaid-block {
|
||||||
position: relative;
|
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 1.25em 0;
|
margin: 1em auto;
|
||||||
padding: 0;
|
padding: 12px;
|
||||||
background: var(--mermaid-glass-bg);
|
background: var(--crepe-color-surface, #f7f7f7);
|
||||||
border: 1px solid var(--mermaid-glass-border);
|
border: 1px solid var(--panel-border, #d7deea);
|
||||||
border-radius: 16px;
|
border-radius: 10px;
|
||||||
box-shadow: var(--mermaid-glass-shadow);
|
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
transition: border-color 240ms ease, box-shadow 240ms ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-block:hover {
|
.mermaid-block:hover {
|
||||||
border-color: var(--mermaid-glass-border-hover);
|
border-color: var(--focus-ring, #3b82f6);
|
||||||
box-shadow: var(--mermaid-glass-shadow-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 悬浮控制栏:右上角,hover 时渐现 */
|
|
||||||
.mermaid-controls {
|
.mermaid-controls {
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: flex-end;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
opacity: 0;
|
margin-bottom: 10px;
|
||||||
transform: translateY(-4px);
|
flex-wrap: wrap;
|
||||||
transition: opacity 200ms ease, transform 200ms ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-block:hover .mermaid-controls {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-action-btn {
|
.mermaid-action-btn {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 1px solid var(--mermaid-btn-border);
|
border: 1px solid var(--mermaid-action-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-radius: 999px;
|
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]) {
|
.mermaid-action-btn:hover:not([disabled]) {
|
||||||
background: var(--mermaid-btn-hover-bg);
|
|
||||||
border-color: var(--focus-ring);
|
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);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-action-btn:active:not([disabled]) {
|
.mermaid-action-btn:active:not([disabled]) {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-action-btn[disabled] {
|
.mermaid-action-btn[disabled] {
|
||||||
opacity: 0.35;
|
background: var(--mermaid-action-disabled-bg);
|
||||||
|
color: var(--mermaid-action-disabled-fg);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-inner {
|
.mermaid-inner {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
max-height: var(--mermaid-max-height);
|
max-height: var(--mermaid-max-height);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 20px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
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 {
|
.mermaid-inner::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--scrollbar-thumb);
|
background-color: var(--scrollbar-thumb);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
}
|
|
||||||
.mermaid-inner::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: var(--scrollbar-thumb-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-image {
|
.mermaid-image {
|
||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-width: 100%;
|
max-width: none;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-inner svg { display: block; margin: 0 auto; }
|
.mermaid-inner svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* 三点弹跳加载动画 */
|
|
||||||
.mermaid-loading {
|
.mermaid-loading {
|
||||||
display: flex;
|
padding: 24px;
|
||||||
align-items: center;
|
text-align: center;
|
||||||
justify-content: center;
|
font-size: 1.4em;
|
||||||
gap: 6px;
|
color: var(--muted-text, #6b7280);
|
||||||
padding: 36px;
|
letter-spacing: 0.2em;
|
||||||
|
animation: mermaid-pulse 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-loading span {
|
@keyframes mermaid-pulse {
|
||||||
display: inline-block;
|
0%, 100% { opacity: 0.4; }
|
||||||
width: 7px;
|
50% { opacity: 1; }
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-error {
|
.mermaid-error {
|
||||||
margin: 12px;
|
|
||||||
padding: 12px 16px;
|
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: 1px solid var(--danger-text, #dc2626);
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
color: var(--danger-text, #dc2626);
|
color: var(--danger-text, #dc2626);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
|
||||||
@@ -426,122 +325,20 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root .milkdown .katex {
|
:root[data-theme='dark'] .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 {
|
|
||||||
color: var(--crepe-color-on-background);
|
color: var(--crepe-color-on-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .milkdown .cm-editor,
|
:root[data-theme='dark'] .milkdown .cm-editor,
|
||||||
:root[data-theme='dark'] .milkdown .cm-scroller {
|
:root[data-theme='dark'] .milkdown .cm-scroller {
|
||||||
background-color: var(--code-block-bg);
|
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
|
||||||
color: var(--code-text);
|
color: var(--crepe-color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .milkdown .cm-gutters {
|
: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);
|
color: var(--crepe-color-on-surface-variant);
|
||||||
border-right: 1px solid var(--code-block-border);
|
border-right-color: var(--panel-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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
+109
-287
@@ -1,324 +1,146 @@
|
|||||||
import { API_URL, API_KEY, PRO_STREAM_URL, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL } from './config.js'
|
import { API_URL } from './config.js'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
|
||||||
function generateRequestId() {
|
function generateRequestId() {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID()
|
return crypto.randomUUID()
|
||||||
}
|
}
|
||||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCancelUrl(apiUrl) {
|
function getCancelUrl(apiUrl) {
|
||||||
const normalized = String(apiUrl || '').replace(/\/+$/, '')
|
const normalized = String(apiUrl || '').replace(/\/+$/, '')
|
||||||
if (!normalized) return '/v1/completions/cancel'
|
if (!normalized) return '/v1/completions/cancel'
|
||||||
if (/\/v1\/pro\/completions\/stream$/i.test(normalized)) {
|
if (normalized.endsWith('/v1/completions')) {
|
||||||
return normalized.replace(/\/v1\/pro\/completions\/stream$/i, '/v1/completions/cancel')
|
return `${normalized}/cancel`
|
||||||
}
|
}
|
||||||
if (normalized.endsWith('/v1/completions')) {
|
|
||||||
return `${normalized}/cancel`
|
return `${normalized}/cancel`
|
||||||
}
|
|
||||||
return `${normalized}/cancel`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAbortReason(reason) {
|
function normalizeAbortReason(reason) {
|
||||||
if (typeof reason === 'string' && reason.trim()) {
|
if (typeof reason === 'string' && reason.trim()) {
|
||||||
return reason.trim().slice(0, 64)
|
return reason.trim().slice(0, 64)
|
||||||
}
|
}
|
||||||
return 'abort'
|
return 'abort'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendCancelRequest(cancelUrl, requestId, reason) {
|
async function sendCancelRequest(cancelUrl, requestId, reason) {
|
||||||
try {
|
try {
|
||||||
await fetch(cancelUrl, {
|
await fetch(cancelUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-API-Key': API_KEY,
|
},
|
||||||
},
|
body: JSON.stringify({
|
||||||
body: JSON.stringify({
|
request_id: requestId,
|
||||||
request_id: requestId,
|
reason,
|
||||||
reason,
|
}),
|
||||||
}),
|
})
|
||||||
})
|
} catch {
|
||||||
} catch {
|
// Cancel request failed silently
|
||||||
// Cancel request failed silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAbortError(message = 'Request aborted') {
|
|
||||||
const error = new Error(message)
|
|
||||||
error.name = 'AbortError'
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCompletionBody(settings, prefix, suffix, languageId, extra = {}) {
|
|
||||||
return {
|
|
||||||
prefix,
|
|
||||||
suffix,
|
|
||||||
languageId,
|
|
||||||
model_thinking: settings.modelThinking,
|
|
||||||
privacy_mode: settings.privacyMode,
|
|
||||||
user_preferences: {
|
|
||||||
language: settings.language,
|
|
||||||
currency: settings.currency,
|
|
||||||
timezone: settings.detectedTimezone,
|
|
||||||
},
|
|
||||||
...extra,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSseEvent(rawEvent) {
|
|
||||||
const lines = String(rawEvent || '').replace(/\r/g, '').split('\n')
|
|
||||||
let event = 'message'
|
|
||||||
const dataLines = []
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line) continue
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
event = line.slice(6).trim() || 'message'
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if (line.startsWith('data:')) {
|
|
||||||
dataLines.push(line.slice(5).trimStart())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
event,
|
|
||||||
data: dataLines.join('\n'),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) {
|
export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) {
|
||||||
let normalizedLanguageId = 'markdown'
|
let normalizedLanguageId = 'markdown'
|
||||||
if (typeof languageId === 'string' && languageId.trim()) {
|
if (typeof languageId === 'string' && languageId.trim()) {
|
||||||
normalizedLanguageId = languageId.trim()
|
normalizedLanguageId = languageId.trim()
|
||||||
} else if (languageId && typeof languageId === 'object' && 'aborted' in languageId) {
|
} else if (languageId && typeof languageId === 'object' && 'aborted' in languageId) {
|
||||||
signal = languageId
|
signal = languageId
|
||||||
}
|
|
||||||
if (typeof signal === 'string') {
|
|
||||||
apiUrl = signal
|
|
||||||
signal = undefined
|
|
||||||
}
|
|
||||||
const requestId = generateRequestId()
|
|
||||||
const cancelUrl = getCancelUrl(apiUrl)
|
|
||||||
|
|
||||||
const onAbort = () => {
|
|
||||||
const reason = normalizeAbortReason(signal?.reason)
|
|
||||||
void sendCancelRequest(cancelUrl, requestId, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
if (signal.aborted) {
|
|
||||||
onAbort()
|
|
||||||
} else {
|
|
||||||
signal.addEventListener('abort', onAbort, { once: true })
|
|
||||||
}
|
}
|
||||||
}
|
if (typeof signal === 'string') {
|
||||||
|
apiUrl = signal
|
||||||
|
signal = undefined
|
||||||
|
}
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
const cancelUrl = getCancelUrl(apiUrl)
|
||||||
|
|
||||||
try {
|
const onAbort = () => {
|
||||||
const settings = useSettingsStore()
|
const reason = normalizeAbortReason(signal?.reason)
|
||||||
const headers = {
|
void sendCancelRequest(cancelUrl, requestId, reason)
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-Id': requestId,
|
|
||||||
'X-API-Key': API_KEY,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = buildCompletionBody(settings, prefix, suffix, normalizedLanguageId)
|
|
||||||
|
|
||||||
const res = await fetch(apiUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorText = await res.text()
|
|
||||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
return data.content || ''
|
|
||||||
} catch (e) {
|
|
||||||
if (e.name === 'AbortError') {
|
|
||||||
// ignore abort
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
signal.removeEventListener('abort', onAbort)
|
if (signal.aborted) {
|
||||||
|
onAbort()
|
||||||
|
} else {
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchProSuggestionStream(payload, apiUrl = PRO_STREAM_URL) {
|
try {
|
||||||
const {
|
const settings = useSettingsStore()
|
||||||
prefix = '',
|
const headers = {
|
||||||
suffix = '',
|
'Content-Type': 'application/json',
|
||||||
languageId = 'markdown',
|
'X-Request-Id': requestId,
|
||||||
signal,
|
}
|
||||||
model = '',
|
|
||||||
temperature = 0.7,
|
|
||||||
timeoutMs = 600000,
|
|
||||||
onChunk,
|
|
||||||
} = payload || {}
|
|
||||||
|
|
||||||
const settings = useSettingsStore()
|
const body = {
|
||||||
const requestId = generateRequestId()
|
prefix,
|
||||||
const cancelUrl = getCancelUrl(apiUrl)
|
suffix,
|
||||||
const requestController = new AbortController()
|
languageId: normalizedLanguageId,
|
||||||
const timeoutId = setTimeout(() => {
|
model_thinking: settings.modelThinking,
|
||||||
requestController.abort('timeout')
|
privacy_mode: settings.privacyMode,
|
||||||
}, timeoutMs)
|
user_preferences: {
|
||||||
|
language: settings.language,
|
||||||
|
currency: settings.currency,
|
||||||
|
timezone: settings.detectedTimezone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const relayAbort = () => {
|
const res = await fetch(apiUrl, {
|
||||||
requestController.abort(signal?.reason || 'abort')
|
method: 'POST',
|
||||||
}
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
const onAbort = () => {
|
signal,
|
||||||
const reason = normalizeAbortReason(requestController.signal.reason)
|
|
||||||
void sendCancelRequest(cancelUrl, requestId, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
requestController.signal.addEventListener('abort', onAbort, { once: true })
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
if (signal.aborted) {
|
|
||||||
relayAbort()
|
|
||||||
} else {
|
|
||||||
signal.addEventListener('abort', relayAbort, { once: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(apiUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-Id': requestId,
|
|
||||||
'X-API-Key': API_KEY,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(
|
|
||||||
buildCompletionBody(settings, prefix, suffix, String(languageId || 'markdown').trim() || 'markdown', {
|
|
||||||
model,
|
|
||||||
temperature,
|
|
||||||
})
|
})
|
||||||
),
|
|
||||||
signal: requestController.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorText = await res.text()
|
const errorText = await res.text()
|
||||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.body) {
|
|
||||||
throw new Error('PRO 模式流式响应不可用')
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = res.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
let finalContent = ''
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
|
|
||||||
let boundary = buffer.indexOf('\n\n')
|
|
||||||
while (boundary >= 0) {
|
|
||||||
const chunk = buffer.slice(0, boundary)
|
|
||||||
buffer = buffer.slice(boundary + 2)
|
|
||||||
|
|
||||||
const parsed = parseSseEvent(chunk)
|
|
||||||
if (parsed.event === 'chunk' && parsed.data) {
|
|
||||||
const data = JSON.parse(parsed.data)
|
|
||||||
const delta = String(data.delta || '')
|
|
||||||
if (delta) {
|
|
||||||
finalContent += delta
|
|
||||||
onChunk?.(delta)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.event === 'done' && parsed.data) {
|
const reader = res.body?.getReader()
|
||||||
const data = JSON.parse(parsed.data)
|
if (!reader) {
|
||||||
return String(data.content || finalContent || '')
|
throw new Error('No reader available')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.event === 'error' && parsed.data) {
|
let text = ''
|
||||||
const data = JSON.parse(parsed.data)
|
let buffer = ''
|
||||||
throw new Error(String(data.error || 'PRO 模式请求失败'))
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.event === 'cancelled') {
|
return text
|
||||||
throw createAbortError('PRO 模式请求已取消')
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
// ignore abort
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (signal) {
|
||||||
|
signal.removeEventListener('abort', onAbort)
|
||||||
}
|
}
|
||||||
|
|
||||||
boundary = buffer.indexOf('\n\n')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestController.signal.aborted) {
|
|
||||||
throw createAbortError('PRO 模式请求已中止')
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalContent
|
|
||||||
} catch (e) {
|
|
||||||
if (e?.name === 'AbortError') {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
requestController.signal.removeEventListener('abort', onAbort)
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener('abort', relayAbort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
-8
@@ -1,14 +1,7 @@
|
|||||||
export const DEBUG = import.meta.env.DEV
|
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 || 'https://api.imageteach.tech:8002'
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL
|
|
||||||
|
|
||||||
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
|
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
|
||||||
export const PRO_STREAM_URL = import.meta.env.VITE_PRO_STREAM_URL || `${API_BASE_URL}/v1/pro/completions/stream`
|
|
||||||
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
|
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 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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-API-Key': 'your-secret-key-here',
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
file: base64,
|
file: base64,
|
||||||
|
|||||||
@@ -1,227 +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'
|
|
||||||
const FENCED_DOC_BLOCK_RE = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
|
|
||||||
const LEGACY_DOC_BLOCK_RE = /<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g
|
|
||||||
|
|
||||||
function normalizeMarkdownText(markdown = '') {
|
|
||||||
return String(markdown || '').replace(/\r\n?/g, '\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
function clipDocContext(content = '', limit = 0) {
|
|
||||||
if (!limit || content.length <= limit) return content
|
|
||||||
return `${content.slice(0, limit)}...`
|
|
||||||
}
|
|
||||||
|
|
||||||
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 normalizeMarkdownText(markdown)
|
|
||||||
.replace(IMAGE_MD_RE, '')
|
|
||||||
.replace(IMAGE_HTML_RE, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = normalizeMarkdownText(raw)
|
|
||||||
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 normalized = normalizeMarkdownText(raw)
|
|
||||||
const match = normalized.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 extractDocBlockContextFromMarkdown(markdown = '', contentLimit = 0) {
|
|
||||||
const normalized = normalizeMarkdownText(markdown)
|
|
||||||
const contexts = []
|
|
||||||
const appendContext = (attrs) => {
|
|
||||||
if (!attrs) return
|
|
||||||
const rawContent = String(attrs.content || '')
|
|
||||||
if (!rawContent.trim()) return
|
|
||||||
contexts.push(buildDocContextFence({
|
|
||||||
docType: attrs.docType,
|
|
||||||
content: clipDocContext(rawContent, contentLimit),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized.replace(FENCED_DOC_BLOCK_RE, (_full, _prefix, _fence, value) => {
|
|
||||||
appendContext(parseDocBlockValue(value))
|
|
||||||
return _full
|
|
||||||
})
|
|
||||||
|
|
||||||
normalized.replace(LEGACY_DOC_BLOCK_RE, (full) => {
|
|
||||||
appendContext(parseLegacyDocBlock(full))
|
|
||||||
return full
|
|
||||||
})
|
|
||||||
|
|
||||||
return contexts.join('\n\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transformDocBlockMarkdownForClipboard(markdown = '') {
|
|
||||||
const normalized = normalizeMarkdownText(markdown)
|
|
||||||
const replacedFence = normalized.replace(FENCED_DOC_BLOCK_RE, (full, prefix, _fence, value) => {
|
|
||||||
const attrs = parseDocBlockValue(value)
|
|
||||||
return `${prefix}${buildDocContextFence(attrs)}`
|
|
||||||
})
|
|
||||||
return replacedFence.replace(LEGACY_DOC_BLOCK_RE, (full) => {
|
|
||||||
const attrs = parseLegacyDocBlock(full)
|
|
||||||
return attrs ? buildDocContextFence(attrs) : full
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stripDocBlockMarkdown(markdown = '') {
|
|
||||||
return normalizeMarkdownText(markdown).replace(FENCED_DOC_BLOCK_RE, '$1')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transformLegacyDocBlocksForExport(markdown = '') {
|
|
||||||
return normalizeMarkdownText(markdown).replace(LEGACY_DOC_BLOCK_RE, (full) => {
|
|
||||||
const attrs = parseLegacyDocBlock(full)
|
|
||||||
return attrs ? buildDocBlockMarkdown(attrs) : full
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transformSpecialDocBlocksToLegacy(markdown = '') {
|
|
||||||
return normalizeMarkdownText(markdown).replace(FENCED_DOC_BLOCK_RE, (full, prefix, _fence, value) => {
|
|
||||||
const attrs = parseDocBlockValue(value)
|
|
||||||
return `${prefix}${buildLegacyDocBlock(attrs)}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
export const HIDDEN_TEXT_NODE_TYPE = 'hiddenText'
|
|
||||||
|
|
||||||
function escapeHtml(value = '') {
|
|
||||||
return String(value)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeHiddenTextValue(value = '') {
|
|
||||||
return String(value ?? '').replace(/\r\n?/g, ' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeHiddenTextSegment(value = '', closingChar) {
|
|
||||||
const normalized = normalizeHiddenTextValue(value)
|
|
||||||
const closingCharPattern = new RegExp(`\\${closingChar}`, 'g')
|
|
||||||
return normalized
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(closingCharPattern, `\\${closingChar}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeHiddenTextSyntax(displayed = '', hidden = '') {
|
|
||||||
const safeDisplayed = escapeHiddenTextSegment(displayed, ')')
|
|
||||||
const safeHidden = escapeHiddenTextSegment(hidden, '}')
|
|
||||||
return `(${safeDisplayed}){${safeHidden}}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function readHiddenTextSegment(text = '', start = 0, closingChar = ')') {
|
|
||||||
let index = start
|
|
||||||
let value = ''
|
|
||||||
|
|
||||||
while (index < text.length) {
|
|
||||||
const char = text[index]
|
|
||||||
|
|
||||||
if (char === '\\') {
|
|
||||||
const nextChar = text[index + 1]
|
|
||||||
if (nextChar === undefined) return null
|
|
||||||
value += nextChar
|
|
||||||
index += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === '\n' || char === '\r') return null
|
|
||||||
if (char === closingChar) {
|
|
||||||
return {
|
|
||||||
value,
|
|
||||||
end: index + 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
value += char
|
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseHiddenTextAt(text = '', start = 0) {
|
|
||||||
if (text[start] !== '(') return null
|
|
||||||
|
|
||||||
const displayed = readHiddenTextSegment(text, start + 1, ')')
|
|
||||||
if (!displayed) return null
|
|
||||||
if (text[displayed.end] !== '{') return null
|
|
||||||
|
|
||||||
const hidden = readHiddenTextSegment(text, displayed.end + 1, '}')
|
|
||||||
if (!hidden) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
start,
|
|
||||||
end: hidden.end,
|
|
||||||
displayed: displayed.value,
|
|
||||||
hidden: hidden.value,
|
|
||||||
raw: text.slice(start, hidden.end),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractHiddenTextMatches(text = '') {
|
|
||||||
const matches = []
|
|
||||||
let index = 0
|
|
||||||
|
|
||||||
while (index < text.length) {
|
|
||||||
const match = parseHiddenTextAt(text, index)
|
|
||||||
if (match) {
|
|
||||||
matches.push(match)
|
|
||||||
index = match.end
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitTextWithHiddenSyntax(text = '') {
|
|
||||||
const matches = extractHiddenTextMatches(text)
|
|
||||||
if (matches.length === 0) return null
|
|
||||||
|
|
||||||
const segments = []
|
|
||||||
let cursor = 0
|
|
||||||
|
|
||||||
for (const match of matches) {
|
|
||||||
if (match.start > cursor) {
|
|
||||||
segments.push({
|
|
||||||
type: 'text',
|
|
||||||
value: text.slice(cursor, match.start),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
segments.push({
|
|
||||||
type: HIDDEN_TEXT_NODE_TYPE,
|
|
||||||
displayed: match.displayed,
|
|
||||||
hidden: match.hidden,
|
|
||||||
})
|
|
||||||
|
|
||||||
cursor = match.end
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursor < text.length) {
|
|
||||||
segments.push({
|
|
||||||
type: 'text',
|
|
||||||
value: text.slice(cursor),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments.filter((segment) => segment.type !== 'text' || segment.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderHiddenTextPreviewHtml(displayed = '', hidden = '') {
|
|
||||||
const summary = escapeHtml(displayed || '未命名文本')
|
|
||||||
|
|
||||||
return [
|
|
||||||
`<span class="hidden-text-preview" data-hidden-text="true" title="${escapeHtml(serializeHiddenTextSyntax(displayed, hidden))}">`,
|
|
||||||
`<span class="hidden-text-preview__summary">${summary}</span>`,
|
|
||||||
'</span>',
|
|
||||||
].join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hiddenTextMarkdownItPlugin(md) {
|
|
||||||
md.inline.ruler.before('emphasis', 'hidden_text', (state, silent) => {
|
|
||||||
const match = parseHiddenTextAt(state.src, state.pos)
|
|
||||||
if (!match) return false
|
|
||||||
|
|
||||||
if (!silent) {
|
|
||||||
const token = state.push('hidden_text', '', 0)
|
|
||||||
token.meta = {
|
|
||||||
displayed: match.displayed,
|
|
||||||
hidden: match.hidden,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.pos = match.end
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
md.renderer.rules.hidden_text = (tokens, index) => {
|
|
||||||
const meta = tokens[index]?.meta || {}
|
|
||||||
return renderHiddenTextPreviewHtml(meta.displayed, meta.hidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-290
@@ -22,12 +22,6 @@ export const translations = {
|
|||||||
mediumDesc: 'Brief analysis before suggesting',
|
mediumDesc: 'Brief analysis before suggesting',
|
||||||
highDesc: 'Deep, step-by-step analysis (Slowest)',
|
highDesc: 'Deep, step-by-step analysis (Slowest)',
|
||||||
debounceTime: 'Debounce Time',
|
debounceTime: 'Debounce Time',
|
||||||
proMode: 'PRO Mode',
|
|
||||||
proModeThinking: 'PRO Thinking',
|
|
||||||
proModel: 'PRO Model',
|
|
||||||
proModelPlaceholder: 'e.g. qwen3:32b',
|
|
||||||
proModelDesc: 'Optional stronger model name used only by PRO mode.',
|
|
||||||
proModelEmptyHint: 'Leave empty to use the backend default PRO model.',
|
|
||||||
privacyPreferences: 'Privacy & Preferences',
|
privacyPreferences: 'Privacy & Preferences',
|
||||||
privacyMode: 'Privacy Mode',
|
privacyMode: 'Privacy Mode',
|
||||||
privacyDesc: 'Prevent sending IP and preferences to the AI',
|
privacyDesc: 'Prevent sending IP and preferences to the AI',
|
||||||
@@ -39,84 +33,25 @@ export const translations = {
|
|||||||
exportMd: 'Export Markdown',
|
exportMd: 'Export Markdown',
|
||||||
exportDocx: 'Export DOCX',
|
exportDocx: 'Export DOCX',
|
||||||
exportPdf: 'Export PDF',
|
exportPdf: 'Export PDF',
|
||||||
upload: 'Upload',
|
|
||||||
uploadImg: 'Upload Image',
|
uploadImg: 'Upload Image',
|
||||||
uploadFile: 'Upload File',
|
uploadFile: 'Upload File',
|
||||||
uploadDoc: 'Upload Document',
|
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.',
|
uploadDocSizeWarning: 'File size cannot exceed 10MB.',
|
||||||
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
|
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
|
||||||
uploadDocError: 'Document conversion failed:',
|
uploadDocError: 'Document conversion failed:',
|
||||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||||
uploadFileError: 'File upload failed.',
|
uploadFileError: 'File upload failed.',
|
||||||
uploadConvertError: 'File conversion failed.',
|
uploadConvertError: 'File conversion failed.',
|
||||||
uploadBatchLimit: 'Maximum 10 files at once',
|
enableAI: 'Enable AI',
|
||||||
uploadSizeLimit: 'File exceeds 50MB limit',
|
|
||||||
uploading: 'Uploading files...',
|
|
||||||
enableAI: 'Enable AI',
|
|
||||||
disableAI: 'Disable AI',
|
disableAI: 'Disable AI',
|
||||||
template: 'Template',
|
|
||||||
presetTemplates: 'Preset Templates',
|
|
||||||
customTemplates: 'Custom Templates',
|
|
||||||
newTemplate: 'New Template',
|
|
||||||
previewTemplate: 'Preview Template',
|
|
||||||
applyTemplate: 'Apply Template',
|
|
||||||
copyAsTemplate: 'Copy as Custom Template',
|
|
||||||
editTemplate: 'Edit Template',
|
|
||||||
saveTemplate: 'Save Template',
|
|
||||||
templateName: 'Template Name',
|
|
||||||
templateContent: 'Template Content',
|
|
||||||
templateNamePlaceholder: 'e.g. Meeting Notes',
|
|
||||||
templateContentPlaceholder: 'Enter template content here...',
|
|
||||||
noTemplates: 'No custom templates yet',
|
|
||||||
templateNameRequired: 'Template name is required.',
|
|
||||||
templateContentRequired: 'Template content is required.',
|
|
||||||
templateNameDuplicate: 'Template name already exists.',
|
|
||||||
templateDeleteConfirm: 'Delete this template?',
|
|
||||||
templateSaved: 'Template saved.',
|
|
||||||
templateUpdated: 'Template updated.',
|
|
||||||
templateDeleted: 'Template deleted.',
|
|
||||||
insertUrl: 'Insert Image from URL',
|
insertUrl: 'Insert Image from URL',
|
||||||
insert: 'Insert',
|
insert: 'Insert',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
imgTooLarge: 'Image too large',
|
imgTooLarge: 'Image too large',
|
||||||
docTooLarge: 'Document too large, AI disabled',
|
docTooLarge: 'Document too large, AI disabled',
|
||||||
initialMarkdown: '# Welcome to LLM-IN-TEXT\n\nAn instant LLM system\n\nStart your creative work below...',
|
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: '文件夹名'
|
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
@@ -141,12 +76,6 @@ export const translations = {
|
|||||||
mediumDesc: '简要分析上下文后建议',
|
mediumDesc: '简要分析上下文后建议',
|
||||||
highDesc: '深度逐步分析(最慢但质量最高)',
|
highDesc: '深度逐步分析(最慢但质量最高)',
|
||||||
debounceTime: '防抖时间',
|
debounceTime: '防抖时间',
|
||||||
proMode: 'PRO模式思考',
|
|
||||||
proModeThinking: 'PRO 正在思考',
|
|
||||||
proModel: 'PRO 模型',
|
|
||||||
proModelPlaceholder: '例如 qwen3:32b',
|
|
||||||
proModelDesc: '可选。仅在 PRO 模式下使用的更强模型名称。',
|
|
||||||
proModelEmptyHint: '留空则使用后端默认 PRO 模型。',
|
|
||||||
privacyPreferences: '隐私与偏好',
|
privacyPreferences: '隐私与偏好',
|
||||||
privacyMode: '隐私模式',
|
privacyMode: '隐私模式',
|
||||||
privacyDesc: '不向 AI 发送 IP 地址和偏好设置',
|
privacyDesc: '不向 AI 发送 IP 地址和偏好设置',
|
||||||
@@ -158,84 +87,25 @@ export const translations = {
|
|||||||
exportMd: '导出 Markdown',
|
exportMd: '导出 Markdown',
|
||||||
exportDocx: '导出 DOCX',
|
exportDocx: '导出 DOCX',
|
||||||
exportPdf: '导出 PDF',
|
exportPdf: '导出 PDF',
|
||||||
upload: '上传',
|
|
||||||
uploadImg: '上传图片',
|
uploadImg: '上传图片',
|
||||||
uploadFile: '上传文件',
|
uploadFile: '上传文件',
|
||||||
uploadDoc: '上传文档',
|
uploadDoc: '上传文档',
|
||||||
uploadDocTypeWarning: '仅支持 txt、json、toml、yaml、docx、pptx、pdf 格式的文档',
|
uploadDocTypeWarning: '仅支持 txt、docx、pptx、pdf 格式的文档',
|
||||||
uploadDocSizeWarning: '文件大小不能超过 10MB',
|
uploadDocSizeWarning: '文件大小不能超过 10MB',
|
||||||
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
|
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
|
||||||
uploadDocError: '文档转换失败:',
|
uploadDocError: '文档转换失败:',
|
||||||
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
|
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
|
||||||
uploadMdTypeWarning: '仅支持 Markdown(.md)和图片文件。',
|
uploadMdTypeWarning: '仅支持 Markdown(.md)和图片文件。',
|
||||||
uploadFileError: '文件上传失败',
|
uploadFileError: '文件上传失败',
|
||||||
uploadConvertError: '文件转换失败',
|
uploadConvertError: '文件转换失败',
|
||||||
uploadBatchLimit: '一次最多上传10个文件',
|
enableAI: '启用 AI',
|
||||||
uploadSizeLimit: '文件超过50MB限制',
|
|
||||||
uploading: '正在上传文件...',
|
|
||||||
enableAI: '启用 AI',
|
|
||||||
disableAI: '禁用 AI',
|
disableAI: '禁用 AI',
|
||||||
template: '模板',
|
|
||||||
presetTemplates: '预设模板',
|
|
||||||
customTemplates: '自定义模板',
|
|
||||||
newTemplate: '新建模板',
|
|
||||||
previewTemplate: '预览模板',
|
|
||||||
applyTemplate: '应用模板',
|
|
||||||
copyAsTemplate: '复制为自定义模板',
|
|
||||||
editTemplate: '编辑模板',
|
|
||||||
saveTemplate: '保存模板',
|
|
||||||
templateName: '模板名称',
|
|
||||||
templateContent: '模板内容',
|
|
||||||
templateNamePlaceholder: '例如:会议纪要',
|
|
||||||
templateContentPlaceholder: '在这里输入模板内容...',
|
|
||||||
noTemplates: '暂无自定义模板',
|
|
||||||
templateNameRequired: '请输入模板名称',
|
|
||||||
templateContentRequired: '请输入模板内容',
|
|
||||||
templateNameDuplicate: '模板名称已存在',
|
|
||||||
templateDeleteConfirm: '确认删除此模板吗?',
|
|
||||||
templateSaved: '模板已保存',
|
|
||||||
templateUpdated: '模板已更新',
|
|
||||||
templateDeleted: '模板已删除',
|
|
||||||
insertUrl: '通过 URL 插入图片',
|
insertUrl: '通过 URL 插入图片',
|
||||||
insert: '插入',
|
insert: '插入',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
imgTooLarge: '图片过大',
|
imgTooLarge: '图片过大',
|
||||||
docTooLarge: '文档过大,AI已禁用',
|
docTooLarge: '文档过大,AI已禁用',
|
||||||
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...',
|
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: '文件夹名'
|
|
||||||
},
|
},
|
||||||
ja: {
|
ja: {
|
||||||
settings: '設定',
|
settings: '設定',
|
||||||
@@ -271,58 +141,20 @@ export const translations = {
|
|||||||
exportMd: 'Markdownをエクスポート',
|
exportMd: 'Markdownをエクスポート',
|
||||||
exportDocx: 'DOCXをエクスポート',
|
exportDocx: 'DOCXをエクスポート',
|
||||||
exportPdf: 'PDFをエクスポート',
|
exportPdf: 'PDFをエクスポート',
|
||||||
upload: 'アップロード',
|
|
||||||
uploadImg: '画像をアップロード',
|
uploadImg: '画像をアップロード',
|
||||||
uploadFile: 'Upload File',
|
uploadFile: 'Upload File',
|
||||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||||
uploadFileError: 'File upload failed.',
|
uploadFileError: 'File upload failed.',
|
||||||
uploadConvertError: 'File conversion failed.',
|
uploadConvertError: 'File conversion failed.',
|
||||||
uploadBatchLimit: 'Maximum 10 files at once',
|
enableAI: 'AIを有効化',
|
||||||
uploadSizeLimit: 'File exceeds 50MB limit',
|
|
||||||
uploading: 'Uploading files...',
|
|
||||||
enableAI: 'AIを有効化',
|
|
||||||
disableAI: 'AIを無効化',
|
disableAI: 'AIを無効化',
|
||||||
insertUrl: 'URLから画像を挿入',
|
insertUrl: 'URLから画像を挿入',
|
||||||
insert: '挿入',
|
insert: '挿入',
|
||||||
cancel: 'キャンセル',
|
cancel: 'キャンセル',
|
||||||
imgTooLarge: '画像が大きすぎます',
|
imgTooLarge: '画像が大きすぎます',
|
||||||
docTooLarge: 'ドキュメントが大きすぎます、AI無効',
|
docTooLarge: 'ドキュメントが大きすぎます、AI無効',
|
||||||
initialMarkdown: '# LLM-IN-TEXTへようこそ\n\nすぐに使えるLLMシステム\n\n下から創作を始めましょう...',
|
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: 'フォルダー名'
|
|
||||||
},
|
},
|
||||||
ko: {
|
ko: {
|
||||||
settings: '설정',
|
settings: '설정',
|
||||||
@@ -358,7 +190,6 @@ export const translations = {
|
|||||||
exportMd: 'Markdown 내보내기',
|
exportMd: 'Markdown 내보내기',
|
||||||
exportDocx: 'DOCX 내보내기',
|
exportDocx: 'DOCX 내보내기',
|
||||||
exportPdf: 'PDF 내보내기',
|
exportPdf: 'PDF 내보내기',
|
||||||
upload: '업로드',
|
|
||||||
uploadImg: '이미지 업로드',
|
uploadImg: '이미지 업로드',
|
||||||
uploadFile: 'Upload File',
|
uploadFile: 'Upload File',
|
||||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||||
@@ -372,41 +203,7 @@ export const translations = {
|
|||||||
cancel: '취소',
|
cancel: '취소',
|
||||||
imgTooLarge: '이미지가 너무 큽니다',
|
imgTooLarge: '이미지가 너무 큽니다',
|
||||||
docTooLarge: '문서가 너무 큽니다, AI 비활성화됨',
|
docTooLarge: '문서가 너무 큽니다, AI 비활성화됨',
|
||||||
initialMarkdown: '# LLM-IN-TEXT에 오신 것을 환영합니다\n\n즉시 사용할 수 있는 LLM 시스템\n\n아래에서 창작을 시작하세요...',
|
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: '폴더명'
|
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
settings: 'Einstellungen',
|
settings: 'Einstellungen',
|
||||||
@@ -442,7 +239,6 @@ export const translations = {
|
|||||||
exportMd: 'Markdown exportieren',
|
exportMd: 'Markdown exportieren',
|
||||||
exportDocx: 'DOCX exportieren',
|
exportDocx: 'DOCX exportieren',
|
||||||
exportPdf: 'PDF exportieren',
|
exportPdf: 'PDF exportieren',
|
||||||
upload: 'Hochladen',
|
|
||||||
uploadImg: 'Bild hochladen',
|
uploadImg: 'Bild hochladen',
|
||||||
uploadFile: 'Upload File',
|
uploadFile: 'Upload File',
|
||||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||||
@@ -456,41 +252,7 @@ export const translations = {
|
|||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
imgTooLarge: 'Bild zu groß',
|
imgTooLarge: 'Bild zu groß',
|
||||||
docTooLarge: 'Dokument zu groß, KI deaktiviert',
|
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...',
|
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'
|
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
settings: 'Paramètres',
|
settings: 'Paramètres',
|
||||||
@@ -526,7 +288,6 @@ export const translations = {
|
|||||||
exportMd: 'Exporter Markdown',
|
exportMd: 'Exporter Markdown',
|
||||||
exportDocx: 'Exporter DOCX',
|
exportDocx: 'Exporter DOCX',
|
||||||
exportPdf: 'Exporter PDF',
|
exportPdf: 'Exporter PDF',
|
||||||
upload: 'Télécharger',
|
|
||||||
uploadImg: 'Télécharger image',
|
uploadImg: 'Télécharger image',
|
||||||
uploadFile: 'Upload File',
|
uploadFile: 'Upload File',
|
||||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||||
@@ -540,40 +301,6 @@ export const translations = {
|
|||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
imgTooLarge: 'Image trop grande',
|
imgTooLarge: 'Image trop grande',
|
||||||
docTooLarge: 'Document trop grand, IA désactivée',
|
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...',
|
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'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
export const PRO_BLOCK_NODE_TYPE = 'pro_block'
|
|
||||||
export const PRO_TRIGGER_TEXT = '[PRO]'
|
|
||||||
export const PRO_DISPLAY_LABEL = 'PRO模式思考'
|
|
||||||
|
|
||||||
const PRO_PENDING_PREFIX = '[Pro][{'
|
|
||||||
const PRO_PENDING_SUFFIX = '}]'
|
|
||||||
const PRO_TRIGGER_RE = /^\[pro\]$/i
|
|
||||||
|
|
||||||
function normalizeMarkdownText(value = '') {
|
|
||||||
return String(value || '').replace(/\r\n?/g, '\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeProBlockContent(value = '') {
|
|
||||||
return normalizeMarkdownText(value)
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/\n/g, '\\n')
|
|
||||||
.replace(/]/g, '\\]')
|
|
||||||
.replace(/}/g, '\\}')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unescapeProBlockContent(value = '') {
|
|
||||||
const normalized = String(value || '')
|
|
||||||
let result = ''
|
|
||||||
|
|
||||||
for (let index = 0; index < normalized.length; index += 1) {
|
|
||||||
const char = normalized[index]
|
|
||||||
if (char !== '\\' || index === normalized.length - 1) {
|
|
||||||
result += char
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = normalized[index + 1]
|
|
||||||
if (next === 'n') {
|
|
||||||
result += '\n'
|
|
||||||
} else {
|
|
||||||
result += next
|
|
||||||
}
|
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeProBlockSyntax(content = '') {
|
|
||||||
const normalized = normalizeMarkdownText(content)
|
|
||||||
if (!normalized) return PRO_TRIGGER_TEXT
|
|
||||||
return `${PRO_PENDING_PREFIX}${escapeProBlockContent(normalized)}${PRO_PENDING_SUFFIX}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseProBlockSyntax(value = '') {
|
|
||||||
const text = normalizeMarkdownText(value).trim()
|
|
||||||
if (!text) return null
|
|
||||||
|
|
||||||
if (PRO_TRIGGER_RE.test(text)) {
|
|
||||||
return {
|
|
||||||
content: '',
|
|
||||||
autoStart: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = text.slice(0, PRO_PENDING_PREFIX.length)
|
|
||||||
if (prefix.toLowerCase() === PRO_PENDING_PREFIX.toLowerCase() && text.endsWith(PRO_PENDING_SUFFIX)) {
|
|
||||||
return {
|
|
||||||
content: unescapeProBlockContent(
|
|
||||||
text.slice(PRO_PENDING_PREFIX.length, text.length - PRO_PENDING_SUFFIX.length)
|
|
||||||
),
|
|
||||||
autoStart: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
export const UPLOAD_BLOCK_NODE_TYPE = 'upload_block'
|
|
||||||
|
|
||||||
export const DEFAULT_UPLOAD_BLOCK_TYPES = [
|
|
||||||
'docx',
|
|
||||||
'pptx',
|
|
||||||
'pdf',
|
|
||||||
'txt',
|
|
||||||
'json',
|
|
||||||
'toml',
|
|
||||||
'yaml',
|
|
||||||
'images',
|
|
||||||
]
|
|
||||||
|
|
||||||
const TYPE_ALIAS = {
|
|
||||||
doc: 'docx',
|
|
||||||
docx: 'docx',
|
|
||||||
word: 'docx',
|
|
||||||
ppt: 'pptx',
|
|
||||||
pptx: 'pptx',
|
|
||||||
powerpoint: 'pptx',
|
|
||||||
pdf: 'pdf',
|
|
||||||
txt: 'txt',
|
|
||||||
text: 'txt',
|
|
||||||
plain: 'txt',
|
|
||||||
json: 'json',
|
|
||||||
toml: 'toml',
|
|
||||||
yaml: 'yaml',
|
|
||||||
yml: 'yaml',
|
|
||||||
'[images]': 'images',
|
|
||||||
image: 'images',
|
|
||||||
images: 'images',
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_LABELS = {
|
|
||||||
docx: 'DOCX',
|
|
||||||
pptx: 'PPTX',
|
|
||||||
pdf: 'PDF',
|
|
||||||
txt: 'TXT',
|
|
||||||
json: 'JSON',
|
|
||||||
toml: 'TOML',
|
|
||||||
yaml: 'YAML',
|
|
||||||
images: '图片',
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_ACCEPT_MAP = {
|
|
||||||
docx: [
|
|
||||||
'.docx',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
],
|
|
||||||
pptx: [
|
|
||||||
'.pptx',
|
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
||||||
],
|
|
||||||
pdf: [
|
|
||||||
'.pdf',
|
|
||||||
'application/pdf',
|
|
||||||
],
|
|
||||||
txt: [
|
|
||||||
'.txt',
|
|
||||||
'text/plain',
|
|
||||||
],
|
|
||||||
json: [
|
|
||||||
'.json',
|
|
||||||
'application/json',
|
|
||||||
],
|
|
||||||
toml: [
|
|
||||||
'.toml',
|
|
||||||
'application/toml',
|
|
||||||
'text/toml',
|
|
||||||
],
|
|
||||||
yaml: [
|
|
||||||
'.yaml',
|
|
||||||
'.yml',
|
|
||||||
'text/yaml',
|
|
||||||
'text/x-yaml',
|
|
||||||
'application/x-yaml',
|
|
||||||
],
|
|
||||||
images: ['image/*'],
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
|
|
||||||
const SYNTAX_RE = /^\{\{\{([\s\S]*?)\}\}\}$/
|
|
||||||
const UPLOAD_TYPE_PREFIX_RE = /^upload\s+file\s+type\s*:\s*(.+)$/i
|
|
||||||
|
|
||||||
function parseStrictUploadBlockTypes(values = []) {
|
|
||||||
const normalized = []
|
|
||||||
|
|
||||||
for (const value of values) {
|
|
||||||
const next = normalizeUploadBlockType(value)
|
|
||||||
if (!next) return null
|
|
||||||
if (!normalized.includes(next)) {
|
|
||||||
normalized.push(next)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized.length > 0 ? normalized : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeUploadBlockType(value = '') {
|
|
||||||
const key = String(value || '').trim().toLowerCase()
|
|
||||||
return TYPE_ALIAS[key] || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeUploadBlockTypes(value) {
|
|
||||||
const source = Array.isArray(value) ? value : []
|
|
||||||
const normalized = []
|
|
||||||
|
|
||||||
for (const item of source) {
|
|
||||||
const next = normalizeUploadBlockType(item)
|
|
||||||
if (!next || normalized.includes(next)) continue
|
|
||||||
normalized.push(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized.length > 0 ? normalized : [...DEFAULT_UPLOAD_BLOCK_TYPES]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUploadBlockMenuOptions(allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
|
|
||||||
return normalizeUploadBlockTypes(allowedTypes).map((type) => ({
|
|
||||||
value: type,
|
|
||||||
label: TYPE_LABELS[type] || type.toUpperCase(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUploadBlockAccept(allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
|
|
||||||
const entries = new Set()
|
|
||||||
|
|
||||||
normalizeUploadBlockTypes(allowedTypes).forEach((type) => {
|
|
||||||
const values = TYPE_ACCEPT_MAP[type] || []
|
|
||||||
values.forEach((entry) => entries.add(entry))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(entries).join(',')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUploadBlockAcceptForType(type = '') {
|
|
||||||
const normalized = normalizeUploadBlockType(type)
|
|
||||||
if (!normalized) return ''
|
|
||||||
return (TYPE_ACCEPT_MAP[normalized] || []).join(',')
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUploadBlockInner(raw = '') {
|
|
||||||
const inner = String(raw || '').trim()
|
|
||||||
if (!inner) {
|
|
||||||
return { allowedTypes: [...DEFAULT_UPLOAD_BLOCK_TYPES] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const matched = inner.match(UPLOAD_TYPE_PREFIX_RE)
|
|
||||||
if (!matched) return null
|
|
||||||
|
|
||||||
const segments = matched[1]
|
|
||||||
.split(',')
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
if (segments.length === 0) return null
|
|
||||||
|
|
||||||
const allowedTypes = parseStrictUploadBlockTypes(segments)
|
|
||||||
if (!allowedTypes) return null
|
|
||||||
|
|
||||||
return allowedTypes.length > 0 ? { allowedTypes } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseUploadBlockSyntax(value = '') {
|
|
||||||
const matched = String(value || '').trim().match(SYNTAX_RE)
|
|
||||||
if (!matched) return null
|
|
||||||
return parseUploadBlockInner(matched[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeUploadBlockSyntax(allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
|
|
||||||
const normalized = normalizeUploadBlockTypes(allowedTypes)
|
|
||||||
const isDefault =
|
|
||||||
normalized.length === DEFAULT_UPLOAD_BLOCK_TYPES.length &&
|
|
||||||
normalized.every((type, index) => type === DEFAULT_UPLOAD_BLOCK_TYPES[index])
|
|
||||||
|
|
||||||
if (isDefault) return '{{{}}}'
|
|
||||||
|
|
||||||
const serialized = normalized
|
|
||||||
.map((type) => (type === 'images' ? '[images]' : type))
|
|
||||||
.join(',')
|
|
||||||
|
|
||||||
return `{{{upload file type:${serialized}}}}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isUploadBlockImageFile(file) {
|
|
||||||
if (!file) return false
|
|
||||||
const name = String(file.name || '').toLowerCase()
|
|
||||||
const type = String(file.type || '').toLowerCase()
|
|
||||||
return type.startsWith('image/') || IMAGE_EXT_RE.test(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUploadBlockFileType(file) {
|
|
||||||
if (!file) return ''
|
|
||||||
if (isUploadBlockImageFile(file)) return 'images'
|
|
||||||
|
|
||||||
const name = String(file.name || '').toLowerCase()
|
|
||||||
if (name.endsWith('.docx')) return 'docx'
|
|
||||||
if (name.endsWith('.pptx')) return 'pptx'
|
|
||||||
if (name.endsWith('.pdf')) return 'pdf'
|
|
||||||
if (name.endsWith('.json')) return 'json'
|
|
||||||
if (name.endsWith('.toml')) return 'toml'
|
|
||||||
if (name.endsWith('.yaml') || name.endsWith('.yml')) return 'yaml'
|
|
||||||
if (name.endsWith('.txt')) return 'txt'
|
|
||||||
|
|
||||||
const mime = String(file.type || '').toLowerCase()
|
|
||||||
if (mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'docx'
|
|
||||||
if (mime === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') return 'pptx'
|
|
||||||
if (mime === 'application/pdf') return 'pdf'
|
|
||||||
if (mime === 'application/json') return 'json'
|
|
||||||
if (mime === 'application/toml' || mime === 'text/toml') return 'toml'
|
|
||||||
if (mime === 'text/yaml' || mime === 'text/x-yaml' || mime === 'application/x-yaml') return 'yaml'
|
|
||||||
if (mime === 'text/plain') return 'txt'
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isUploadBlockTypeAllowed(file, allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
|
|
||||||
const fileType = getUploadBlockFileType(file)
|
|
||||||
if (!fileType) return false
|
|
||||||
return normalizeUploadBlockTypes(allowedTypes).includes(fileType)
|
|
||||||
}
|
|
||||||
@@ -1,440 +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 v-if="selectedNode?.type !== 'file'" 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"
|
|
||||||
:update-file="fs.updateFile"
|
|
||||||
:show-sidebar-toggle="selectedNode?.type === 'file'"
|
|
||||||
@navigate="fs.select"
|
|
||||||
@toggle-sidebar="sidebarCollapsed = !sidebarCollapsed"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
+2
-12
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/v1': {
|
'/v1': {
|
||||||
target: 'https://api.imageteach.tech:8002',
|
target: 'http://localhost:8001',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,17 +53,7 @@ export default defineConfig({
|
|||||||
include: [
|
include: [
|
||||||
'@milkdown/crepe',
|
'@milkdown/crepe',
|
||||||
'@milkdown/vue',
|
'@milkdown/vue',
|
||||||
'@milkdown/kit',
|
'@milkdown/kit'
|
||||||
'@univerjs/core',
|
|
||||||
'@univerjs/design',
|
|
||||||
'@univerjs/engine-render',
|
|
||||||
'@univerjs/engine-formula',
|
|
||||||
'@univerjs/ui',
|
|
||||||
'@univerjs/presets',
|
|
||||||
'@univerjs/preset-docs-core',
|
|
||||||
'@univerjs/preset-sheets-core',
|
|
||||||
'@univerjs/slides',
|
|
||||||
'@univerjs/slides-ui'
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user