1 Commits

Author SHA1 Message Date
05b1cbf80d chore: 更新项目配置和前后端代码优化 2026-04-04 20:10:24 +08:00
97 changed files with 3720 additions and 21456 deletions

View File

@@ -0,0 +1,9 @@
{
"shortcuts": [
{
"label": "Run",
"command": "npm run dev",
"icon": "play"
}
]
}

View File

@@ -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__.:

15
.gitignore vendored
View File

@@ -13,7 +13,6 @@ dist-ssr
*.local
# Python
backend/models/
__pycache__/
*.py[cod]
*.pyc.*
@@ -24,9 +23,6 @@ env/
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
api_performance_report.md
# Env files
.env
@@ -43,14 +39,3 @@ api_performance_report.md
*.njsproj
*.sln
*.sw?
# IDE directories
.kilocode/
.kilo/
.codex/
# Agent/runtime state and local verification artifacts
.omx/
.tmp-*.png
tmp-*.txt

11
.kilocode/rules/rules.md Normal file
View File

@@ -0,0 +1,11 @@
# rules.md
在构建这个LLM应用网页时你需要基于VUE3开发。我需要前端只运行渲染和数据回传后端负责llm api调用类似copilet的auto inline suggustions实现和数据解析。
## 指导原则
- 不要擅自用npm或者yarn运行网页你既看不到网页的内容也无法阻止命令暂停。但是你可以用npm run build检查代码。
- 应该保证代码效率,不多定义变量,不写冗余注释,把降低延迟放在第一位。
- 每次完成任务前都要反复阅读检查代码,确保代码准确无误。
- 尽量不要搜索关键字,而是了解代码结构后查询整个问题代码明确问题所在。
- @/milkdown-docs/ 代表milkdown的最新官方文档不要修改涉及到前端编辑器的指令时要核对官方文档。

View File

@@ -1,91 +0,0 @@
- generic [ref=e3]:
- generic:
- button "设置" [ref=e5] [cursor=pointer]:
- img [ref=e6]
- generic [ref=e9]:
- generic [ref=e10]:
- heading "设置" [level=2] [ref=e11]
- button "关闭" [ref=e12] [cursor=pointer]:
- img [ref=e13]
- generic [ref=e16]:
- generic [ref=e17]:
- heading "外观" [level=3] [ref=e18]
- generic [ref=e19]:
- generic [ref=e20]: 外观
- combobox [ref=e21]:
- option "深色"
- option "浅色"
- option "跟随系统" [selected]
- option "暖色调"
- option "读书灯"
- option "自定义图片"
- generic [ref=e22]:
- heading "视图" [level=3] [ref=e23]
- generic [ref=e24]:
- button "编辑器" [ref=e25] [cursor=pointer]:
- img [ref=e26]
- generic [ref=e28]: 编辑器
- button "文档" [ref=e29] [cursor=pointer]:
- img [ref=e30]
- generic [ref=e33]: 文档
- generic [ref=e34]:
- heading "PRO 模式" [level=3] [ref=e35]
- generic [ref=e36]:
- generic [ref=e37]: PRO 思考程度
- generic [ref=e38]:
- button "低" [ref=e39] [cursor=pointer]
- button "中" [ref=e40] [cursor=pointer]
- button "高" [ref=e41] [cursor=pointer]
- paragraph [ref=e42]: PRO 模式使用独立思考强度,普通补全设置不会影响它。
- generic [ref=e43]:
- heading "模型智能" [level=3] [ref=e44]
- generic [ref=e45]:
- generic [ref=e46]: 思考程度
- generic [ref=e47]:
- button "低" [ref=e48] [cursor=pointer]
- button "中" [ref=e49] [cursor=pointer]
- button "高" [ref=e50] [cursor=pointer]
- paragraph [ref=e51]: 直接补全(最快)
- generic [ref=e52]:
- generic [ref=e53]: "防抖时间: 1000ms"
- slider [ref=e54]: "1000"
- generic [ref=e55]:
- heading "隐私与偏好" [level=3] [ref=e56]
- generic [ref=e57]:
- generic [ref=e58]: 隐私模式
- button [ref=e59] [cursor=pointer]
- paragraph [ref=e61]: 不向 AI 发送 IP 地址和偏好设置
- generic [ref=e62]:
- generic [ref=e63]: 语言
- combobox [disabled] [ref=e64]:
- option "自动检测" [selected]
- option "Chinese"
- option "English"
- option "Japanese"
- option "Korean"
- option "German"
- option "French"
- generic [ref=e65]:
- generic [ref=e66]: 货币
- combobox [disabled] [ref=e67]:
- option "自动检测" [selected]
- option "CNY (¥)"
- option "USD ($)"
- option "EUR (€)"
- option "JPY (¥)"
- option "KRW (₩)"
- option "GBP (£)"
- option "AUD ($)"
- option "CAD ($)"
- generic [ref=e68]:
- heading "语音设置" [level=3] [ref=e69]
- generic [ref=e70]:
- generic [ref=e71]: 声音描述
- textbox "例如:用温柔的语气说" [ref=e72]
- paragraph [ref=e73]: 描述你想要的声音风格,如语气、情感等
- generic [ref=e74]:
- heading "关于我们" [level=3] [ref=e75]
- generic [ref=e76]:
- heading "llm-in-text" [level=4] [ref=e77]
- paragraph [ref=e78]: A smart Markdown editor with local LLM intelligence.
- paragraph [ref=e79]: v0.0.0

View File

@@ -1,124 +0,0 @@
- generic [ref=e3]:
- generic [ref=e80]:
- textbox [active] [ref=e83]:
- heading "欢迎使用 LLM-IN-TEXT" [level=1] [ref=e84]
- paragraph [ref=e85]: 即时可用的 LLM 系统
- paragraph [ref=e86]: 在下方开始创作吧...
- generic [ref=e87]:
- button "Undo" [disabled] [ref=e88]:
- img [ref=e89]
- button "Redo" [disabled] [ref=e92]:
- img [ref=e93]
- generic [ref=e96]:
- button "上传" [ref=e97] [cursor=pointer]:
- img [ref=e98]
- generic: 上传
- button "导入 Markdown" [ref=e104] [cursor=pointer]:
- img [ref=e105]
- generic: 导入 Markdown
- button "导出 Markdown" [ref=e109] [cursor=pointer]:
- img [ref=e110]
- generic: 导出 Markdown
- button "禁用 AI" [ref=e114] [cursor=pointer]:
- img [ref=e115]
- generic: 禁用 AI
- generic [ref=e119]:
- img [ref=e120]
- text: 0 KB
- generic [ref=e122]:
- button "模板" [ref=e124] [cursor=pointer]:
- img [ref=e125]
- generic: 模板
- button "清除" [ref=e128] [cursor=pointer]:
- img [ref=e129]
- generic: 清除文档
- generic:
- button "设置" [ref=e5] [cursor=pointer]:
- img [ref=e6]
- generic [ref=e9]:
- generic [ref=e10]:
- heading "设置" [level=2] [ref=e11]
- button "关闭" [ref=e12] [cursor=pointer]:
- img [ref=e13]
- generic [ref=e16]:
- generic [ref=e17]:
- heading "外观" [level=3] [ref=e18]
- generic [ref=e19]:
- generic [ref=e20]: 外观
- combobox [ref=e21]:
- option "深色"
- option "浅色"
- option "跟随系统" [selected]
- option "暖色调"
- option "读书灯"
- option "自定义图片"
- generic [ref=e22]:
- heading "视图" [level=3] [ref=e23]
- generic [ref=e24]:
- button "编辑器" [ref=e25] [cursor=pointer]:
- img [ref=e26]
- generic [ref=e28]: 编辑器
- button "文档" [ref=e29] [cursor=pointer]:
- img [ref=e30]
- generic [ref=e33]: 文档
- generic [ref=e34]:
- heading "PRO 模式" [level=3] [ref=e35]
- generic [ref=e36]:
- generic [ref=e37]: PRO 思考程度
- generic [ref=e38]:
- button "低" [ref=e39] [cursor=pointer]
- button "中" [ref=e40] [cursor=pointer]
- button "高" [ref=e41] [cursor=pointer]
- paragraph [ref=e42]: PRO 模式使用独立思考强度,普通补全设置不会影响它。
- generic [ref=e43]:
- heading "模型智能" [level=3] [ref=e44]
- generic [ref=e45]:
- generic [ref=e46]: 思考程度
- generic [ref=e47]:
- button "低" [ref=e48] [cursor=pointer]
- button "中" [ref=e49] [cursor=pointer]
- button "高" [ref=e50] [cursor=pointer]
- paragraph [ref=e51]: 直接补全(最快)
- generic [ref=e52]:
- generic [ref=e53]: "防抖时间: 1000ms"
- slider [ref=e54]: "1000"
- generic [ref=e55]:
- heading "隐私与偏好" [level=3] [ref=e56]
- generic [ref=e57]:
- generic [ref=e58]: 隐私模式
- button [ref=e59] [cursor=pointer]
- paragraph [ref=e61]: 不向 AI 发送 IP 地址和偏好设置
- generic [ref=e62]:
- generic [ref=e63]: 语言
- combobox [disabled] [ref=e64]:
- option "自动检测" [selected]
- option "Chinese"
- option "English"
- option "Japanese"
- option "Korean"
- option "German"
- option "French"
- generic [ref=e65]:
- generic [ref=e66]: 货币
- combobox [disabled] [ref=e67]:
- option "自动检测" [selected]
- option "CNY (¥)"
- option "USD ($)"
- option "EUR (€)"
- option "JPY (¥)"
- option "KRW (₩)"
- option "GBP (£)"
- option "AUD ($)"
- option "CAD ($)"
- generic [ref=e68]:
- heading "语音设置" [level=3] [ref=e69]
- generic [ref=e70]:
- generic [ref=e71]: 声音描述
- textbox "例如:用温柔的语气说" [ref=e72]
- paragraph [ref=e73]: 描述你想要的声音风格,如语气、情感等
- generic [ref=e74]:
- heading "关于我们" [level=3] [ref=e75]
- generic [ref=e76]:
- heading "llm-in-text" [level=4] [ref=e77]
- paragraph [ref=e78]: A smart Markdown editor with local LLM intelligence.
- paragraph [ref=e79]: v0.0.0

137
AGENTS.md
View File

@@ -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 导入导出。
- 历史文档中有一部分 TTS/ASR、Apple Silicon、Whisper、离线模式说明已经落后于当前代码出现冲突时以实际代码和测试为准。
## 构建、测试、开发命令
| 命令 | 说明 |
|----------------------------------------------|--------------------------------------------------|
| `npm install` | 安装前端依赖 |
| `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
- 路由src/router/index.js
- 编辑器主组件src/components/MilkdownEditor.vue
- 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
## 测试指南
- 后端使用 **pytest**,测试文件放在对应模块目录下,命名为 `test_<module>.py`
- 目标覆盖率 ≥ 80%`pytest --cov=backend`)。
- 在虚拟环境中运行:`pip install -r backend/requirements.txt && pytest`
## 稳定事实
## 提交 \& Pull Request 规范
- 提交信息遵循 **Conventional Commits**`feat:` 新功能、`fix:` 修复、`docs:` 文档、`refactor:` 重构等。
- PR 必须包含:
- 与提交信息匹配的标题。
- 关联的 Issue`Fixes #123`)。
- UI 变更或 API 示例的截图/示例。
- 所有 CI 检查(代码检查、测试、类型检查)均通过。
- 补全接口当前不是 SSE前端用普通 POST 请求拿 JSON 响应。
- 前端会生成 X-Request-Id并在请求被中止时额外调用 /v1/completions/cancel
- 文档超过 32 KB 时AI 补全会在前端和插件层被禁用。
- OCR 文本和文档块内容会被注入补全上下文,但这些内容属于隐藏上下文,不应被直接当作用户可见文本重复输出
- /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 更适合作为历史背景,不应在与代码冲突时被当成事实来源。
- 修改行为时,优先参考实现代码和对应测试,再决定是否同步普通文档。
## 安全 \& 配置建议
- 敏感信息请放入 `.env` 并确保已在 `.gitignore`
- 按照 `backend/main.py` 中的实现,对上传文件的大小和类型进行校验,防止滥用。
- 定期审计依赖安全(`npm audit``pip-audit`
---
以上指南旨在保持贡献一致性并维护代码库健康,欢迎通过 Pull Request 提出改进。

100
CLAUDE.md
View File

@@ -1,100 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with this repository.
## Project Overview
**LLM in Text** is an AI-powered Markdown editor built with Vue 3 + Vite (frontend) and FastAPI + Python + Ollama (backend). It provides real-time AI completion suggestions, OCR image recognition, document conversion (PDF/DOCX/PPTX to Markdown), and TTS text-to-speech.
- Completion interface uses plain POST/JSON (not SSE). Frontend sends `X-Request-Id` and calls `/v1/completions/cancel` on abort.
- AI completion is disabled when the document exceeds 32 KB (enforced both in UI and plugin layer).
- OCR text and doc-block content are injected into completion context as hidden context — they should NOT be rendered as visible user text.
- `/v1/convert` supports txt, docx, pptx, pdf via MarkItDown. Non-txt files go through Markdown sanitization (image removal, newline compression).
- `/v1/export/pdf` is called from the frontend but may not be implemented on the backend — verify before debugging PDF export.
## Quick Start
```bash
# Frontend
npm install
npm run dev # Vite dev server on port 5173, proxies /v1 to backend
# Backend
pip install -r backend/requirements.txt
python backend/main.py # port 8001
# Tests (90% coverage gate on backend modules)
pytest # full suite with coverage
# Single test file (faster, no coverage overhead)
pytest backend/tests/test_prompt.py -v --no-cov
# Build for production
npm run build
```
## Architecture
### Frontend (`src/`)
| Layer | Key Files | Responsibility |
|-------|-----------|----------------|
| Entry | `main.js`, `App.vue` | Vue app bootstrap, Pinia + Router mount |
| Routing | `router/index.js` | `/` → EditorView, `/docs` → DocsView |
| Editor | `components/MilkdownEditor.vue` | Central control: Crepe editor, plugin registration, upload/export/OCR/TTS/AI toggle, 32 KB limit |
| Plugins (TypeScript) | `plugins/copilotPlugin.ts` — ghost text, request scheduling, cancel, language detection, hidden context injection |
| Plugins (TypeScript) | `plugins/docBlockPlugin.ts` — doc-block nodes and rendering |
| Plugins (TypeScript) | `plugins/mermaidPlugin.ts` — Mermaid diagram preview |
| Store | `stores/settings.js` | localStorage-persisted settings (theme, modelThinking, debounceMs, privacyMode, language, background*, ttsInstruct) |
| API | `utils/api.js` — fetchSuggestion, cancel completion, TTS requests; `config.js` — VITE_* env-based URL config |
| Utilities | `utils/convert.js`, `ocrCache.js`, `docBlock.js`, `i18n.js` |
### Backend (`backend/`)
| File | Responsibility |
|------|----------------|
| `main.py` | FastAPI app, CORS, API key auth, routes: `/v1/completions`, `/v1/ocr`, `/v1/convert`, `/v1/completions/cancel`. TTS routes lazily registered from `tts_asr.py`. |
| `llm.py` | Async Ollama calls (`call_ollama`, `stream_ollama`) and VLM OCR (`call_vlm_ocr`). Timeout control. |
| `prompt.py` | Prompt assembly: `build_completion_prompts`, `prepare_prompt_context`. Templates from `prompts/` directory. |
| `pro_completions.py` | Pro-tier completion endpoint (newer addition). |
| `tts_asr.py` | TTS text-to-speech. Late-registered routes via `_register_tts_asr_routes`. |
| `geoip.py` | Client IP location lookup for non-privacy-mode requests. |
### Request Flow: Completion
```
MilkdownEditor.vue → copilotPlugin.ts (debounce, abort, language detection)
→ utils/api.js (fetchSuggestion: generates request_id, AbortSignal, reads settings)
→ backend/main.py (/v1/completions: auth, prompt context, call_ollama via asyncio.Task)
→ backend/prompt.py (system + user prompt from prefix/suffix/context)
→ backend/llm.py (call_ollama to Ollama)
← JSON { content, request_id }
→ copilotPlugin.ts (insertGhostText into editor)
```
## Debugging Paths
| Issue | Trace Order |
|-------|-------------|
| Completion not firing | `MilkdownEditor.vue``copilotPlugin.ts` (check enabled, size limit, debounce) |
| Wrong completion result | `prompt.py``llm.py`. Check prompt context and language detection. |
| Cancel not working | `main.py` request_id lifecycle ↔ frontend `X-Request-Id` + cancel call |
| OCR empty result | `main.py` base64 decode → `llm.py call_vlm_ocr` |
| Document conversion dirty | `_sanitize_converted_markdown` in `main.py` |
## Naming Conventions (Mixed)
- Vue components/views: PascalCase (`MilkdownEditor.vue`)
- Frontend utils/config: lowercase `.js` (`api.js`, `config.js`)
- Plugin layer: TypeScript (`.ts`)
- Python backend: snake_case
Follow the style of each file. Do not reformat across directories for consistency. UI copy defaults to Chinese.
## Important Rules
- Do not modify `milkdown-docs/` (read-only reference).
- Code and tests override README.md when they conflict — the README is partially outdated.
- Plugin code (`copilotPlugin.ts`) is state-machine-style: small changes can break subtle interactions. Change one thing at a time and verify in-browser.
- No hardcoded secrets, empty catch/except blocks, `as any`, or `@ts-ignore` in new code.
- Subdirectory AGENTS.md files contain more detailed guidance: `./AGENTS.md` (root), `backend/AGENTS.md`, `src/AGENTS.md`, `src/plugins/AGENTS.md`. Read them when working in those areas.

View File

@@ -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)

View File

@@ -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 收集
- 原始异常泄漏
然而,这一进展并不意味着已准备好投入生产。
当前仓库展示了有用的加固工作,但仍然缺乏生产服务预期的运维、测试、节流、部署和可观测性基线。

337
README.md
View File

@@ -1,147 +1,264 @@
# LLM in Text - 智能写作助手
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器集成大语言模型LLM实时补全建议功能。
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器集成大语言模型LLM实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验
## 功能特性
### Markdown 编辑器
- 基于 Milkdown Crepe 的所见即所得编辑体验
- 支持 Markdown 语法和 LaTeX 公式
- 支持 Mermaid 图表渲染
- 支持完整 Markdown 语法和 LaTeX 公式
- 导入/导出 Markdown 文件
- 导出 DOCX 和 PDF 格式
### AI 智能补全
- 实时生成文本补全建议(灰色显示)
- 流式响应,低延迟体验
- 多种交互方式:Tab接受、Esc拒绝、点击接受
- 多种交互方式:
- **Tab 键**:接受建议
- **Esc 键**:拒绝建议
- **点击灰色文本**:接受建议
- **继续输入**:自动拒绝建议
### 文档处理
- OCR 图片识别:上传图片自动识别文字
- 文档转换PDF、DOCX、PPTX、TXT 转 Markdown
- 文档块嵌入:可折叠的文档预览块
- 智能大小限制32KB自动禁用AI
### 设置面板
- 外观主题:亮色/暗色/跟随系统
- 背景模式:默认/暖色/阅读灯/自定义图片
- 模型智能:低/中/高思考级别
- 隐私控制隐私模式防止发送IP
- 多语言界面:中英日韩德法
### 语音功能
- TTS文字转语音macOS优化支持Apple Silicon M1/M2/M3
- STT语音转文字支持多种模型大小和量化
- 自动设备检测MPS/CUDA/CPU智能切换
- 离线模式支持(模型缓存检查)
### AI 开关控制
- 右下角 AI 开关按钮
- 白色 = AI 启用,黑色 = AI 禁用
- 禁用时自动清除灰色文本并停止 API 调用
## 技术架构
前端: Vue3 + Vite + Milkdown + ProseMirror
后端: FastAPI + PythonOpenAI 兼容端点)
```mermaid
flowchart TB
subgraph Frontend["前端 (Vue3 + Vite)"]
A[App.vue] --> B[MilkdownEditor.vue]
B --> C[Crepe Editor]
C --> D[ProseMirror]
D --> E[copilotPlugin.ts]
E --> F[copilotGhostMark]
E --> G[api.js]
end
subgraph Backend["后端 (FastAPI + Python)"]
H[main.py<br/>FastAPI Server] --> I[prompt.py<br/>Prompt 构建]
H --> J[llm.py<br/>Ollama 调用]
J --> K[Ollama API]
end
G -->|POST /v1/completions<br/>SSE 流式响应| H
K -->|LLM 响应| J
```
## 项目结构
```
llm-in-text/
├── src/
│ ├── components/
│ │ └── MilkdownEditor.vue # 主编辑器组件
│ ├── plugins/
│ │ ├── copilotPlugin.ts # ProseMirror AI 补全插件
│ │ ├── types.ts # 类型定义
│ │ └── index.ts # 插件导出
│ ├── utils/
│ │ ├── api.js # API 调用封装
│ │ ├── config.js # 配置文件
│ │ └── ocrCache.js # OCR 缓存管理
│ ├── App.vue
│ └── main.js
├── backend/
│ ├── main.py # FastAPI 服务器
│ ├── llm.py # LLM API 调用
│ ├── prompt.py # Prompt 构建
│ └── requirements.txt
└── README.md
```
## 快速开始
环境: Node.js 18+、Python 3.8+
### 环境要求
- Node.js 18+
- Python 3.8+
- Ollama 服务(或其他兼容 OpenAI API 的服务)
安装:
- 前端: npm install
- 后端: pip install -r backend/requirements.txt
### 安装
启动:
- 后端: python backend/main.py (端口8001)
- 前端: npm run dev (端口5173)
```bash
# 前端
npm install
## API接口
# 后端
cd backend
pip install -r requirements.txt
```
- POST /v1/completions 流式补全建议
- POST /v1/ocr 图片文字识别
- POST /v1/convert 文档转换
- POST /v1/completions/cancel 取消请求
- GET /v1/tts-asr/status TTS/ASR模型状态
- GET /v1/tts-asr/config TTS/ASR配置信息
- POST /v1/tts-asr/warmup 模型预热
- POST /v1/tts-asr/tts 文字转语音
- POST /v1/tts-asr/asr 语音转文字
### 配置
## TTS/ASR环境变量配置
`backend/.env` 中配置:
支持以下环境变量来配置TTS/ASR模块
```env
OLLAMA_MODEL=gpt-oss:20b
OLLAMA_HOST=http://localhost:11434
```
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `TTS_ASR_DEVICE` | 设备选择 (auto/mps/cuda/cpu) | auto |
| `TTS_ASR_MODEL_SIZE` | ASR模型大小 (tiny/base/small/medium/large/turbo) | auto |
| `TTS_ASR_QUANTIZE` | 是否使用INT8量化 (true/false) | false |
| `TTS_ASR_OFFLINE_MODE` | 离线模式,仅使用缓存模型 (true/false) | false |
| `TTS_ASR_WARMUP` | 启动时预热模型 (true/false) | true |
| `TTS_ASR_WARMUP_TIMEOUT` | 预热超时时间(秒) | 120 |
| `TTS_ASR_IDLE_TIMEOUT` | 空闲卸载时间(秒0=不卸载) | 0 |
| `TTS_ASR_MPS_MEMORY_LIMIT_MB` | MPS内存限制(MB) | 8192 |
### 启动
**Apple Silicon优化建议**:
- 系统自动检测Apple Silicon并推荐使用`small`模型
- MPS内存限制默认为系统内存的60%
- 建议使用`small``medium`模型以获得更好的性能
- 可通过`TTS_ASR_MODEL_SIZE=medium`手动指定模型大小
```bash
# 后端(端口 8000
cd backend
python main.py
# 前端(端口 5173
npm run dev
```
访问 http://localhost:5173
## API 接口
### POST /v1/completions
流式获取补全建议
**请求:**
```json
{
"prefix": "# Title\n\nContent ",
"suffix": "",
"languageId": "markdown"
}
```
**响应SSE**
```
data: {"content": "here"}
data: {"content": "here is"}
data: {"done": true}
```
## 核心实现
### 后端
- main.py: FastAPI服务器、SSE流式响应
- llm.py: 异步LLM调用OpenAI兼容、超时控制
- prompt.py: 7条Prompt规则
- tts_asr.py: macOS/Apple Silicon优化的TTS/ASR处理
- 自动检测Apple Silicon (M1/M2/M3)
- MPS/CUDA/CPU智能降级
- 支持多种Whisper模型大小
- INT8量化支持
- 离线模式支持
- 健壮的音频重采样
### 后端设计
### 前端
- copilotPlugin.ts: ProseMirror Mark系统
- 关键函数: scheduleFetch、insertGhostText
- Pinia Store状态管理
#### main.py - FastAPI 服务器
- 定义 `/v1/completions` 端点
- 使用 `StreamingResponse` 返回 SSE 流式响应
- CORS 配置允许跨域请求
#### llm.py - LLM 调用封装
- 使用 `ollama.AsyncClient` 异步调用
- 支持 `think='high'` 思考模式
- 返回 `content``thinking` 字段
#### prompt.py - Prompt 工程
精心设计的 Prompt 模板,包含 7 条核心规则:
| 规则 | 说明 |
|------|------|
| RULE #1 | 无缝连接 - 不重复 suffix 内容,避免"复读机"错误 |
| RULE #2 | 空白处理 - 避免双空格,正确对接标点 |
| RULE #3 | 缩进对齐 - 匹配当前缩进级别和类型 |
| RULE #4 | 列表维护 - 识别并继续任务列表、有序列表、无序列表 |
| RULE #5 | 语法闭合 - 自动闭合未完成的 Markdown 语法 |
| RULE #6 | 输出格式 - 仅输出续写文本,无解释无注释 |
| RULE #7 | 必须输出 - 始终提供有用的续写建议 |
### 前端设计
#### ProseMirror Mark 系统
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
```typescript
// 定义 ghost mark
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
excludes: '_',
inclusive: true,
toDOM: () => ['span', {
'data-copilot-ghost': '',
class: 'copilot-ghost-text'
}, 0]
}))
// CSS 样式
.copilot-ghost-text {
color: #999;
opacity: 0.6;
}
```
#### copilotPlugin 核心逻辑
```mermaid
flowchart LR
A[用户输入] --> B{文档变化?}
B -->|是| C[清除旧建议]
C --> D[防抖 1000ms]
D --> E[发送 API 请求]
E --> F[收到建议]
F --> G[插入 Ghost Text]
G --> H{用户操作}
H -->|Tab| I[接受建议<br/>移除 mark]
H -->|Esc| J[拒绝建议<br/>删除文本]
H -->|点击 Ghost| I
H -->|继续输入| J
```
#### 关键函数
| 函数 | 作用 |
|------|------|
| `scheduleFetch` | 防抖调度 API 请求 |
| `insertGhostText` | 插入带 mark 的建议文本 |
| `acceptSuggestion` | Tab 接受建议 |
| `rejectSuggestion` | Esc 拒绝建议 |
| `clearGhostText` | 清除当前建议 |
### 数据流
```mermaid
sequenceDiagram
participant U as 用户
participant E as Editor (ProseMirror)
participant P as copilotPlugin
participant A as api.js
participant B as Backend
participant L as LLM
U->>E: 输入文本
E->>P: view.update()
P->>P: 清除旧建议
P->>P: 防抖 1000ms
P->>A: fetchSuggestion(prefix, suffix)
A->>B: POST /v1/completions
B->>B: build_prompt()
B->>L: ollama.chat()
L-->>B: {content, thinking}
B-->>A: SSE stream
A-->>P: suggestion text
P->>E: insertGhostText()
E-->>U: 显示灰色建议
alt Tab 键
U->>P: Tab
P->>E: acceptSuggestion()
E-->>U: 建议变为正常文本
else Esc 键
U->>P: Esc
P->>E: rejectSuggestion()
E-->>U: 建议消失
else 继续输入
U->>E: 输入其他字符
E->>P: handleKeyDown()
P->>E: clearGhostText()
end
```
## 设计亮点
1. 前后端分离
2. 低延迟优化:防抖+SSE+AbortController
3. ProseMirror Mark系统
4. 多种交互方式
5. 智能大小限制
6. 隐私保护
7. 多语言支持
8. 主题定制
9. 文档处理
10. 语音功能
## 开发指南
代码风格: Python(4空格,snake_case) JS/TS(2空格,camelCase)
测试: pytest
构建: npm run build
### 运行测试
项目提供完整的测试套件包括单元测试、集成测试和macOS环境模拟测试
```bash
# 快速运行单元测试
python backend/tests/run_tests.py unit
# 运行集成测试(需要启动后端服务)
python backend/tests/run_tests.py integration
# 运行macOS环境模拟测试在非Mac环境测试
python backend/tests/run_tests.py simulate
# 运行所有测试
python backend/tests/run_tests.py all
```
详细测试说明请参考: [测试指南](backend/tests/TESTING_GUIDE.md)
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
4. **多种交互方式**Tab/Esc/点击/输入,用户体验友好
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能
## 许可证

View File

@@ -1,29 +1,4 @@
# LLM provider (OpenAI-compatible endpoint)
LLM_BASE_URL=http://localhost:11434/v1/
# For Ollama, API key is not required but a placeholder is needed.
LLM_API_KEY=ollama
# Default model for inline completions (e.g., gpt-oss:20b, qwen3:8b)
LLM_MODEL=gpt-oss:20b
# Pro-tier model (defaults to LLM_MODEL if unset)
PRO_LLM_MODEL=gpt-oss:20b
# Vision model for OCR (e.g., qwen3-vl:30b, llava)
OPENAI_API_KEY=ollama
OLLAMA_BASE_URL=http://192.168.0.120:11434/v1/
OLLAMA_MODEL=gpt-oss:20b
VLM_MODEL=qwen3-vl:30b
# API key for the FastAPI app (change in production)
API_KEY=your-secret-key-here
# PRO completion timeout (seconds)
PRO_COMPLETION_TIMEOUT=1200
# Concurrency limits
STANDARD_CONCURRENCY_LIMIT=5
PRO_CONCURRENCY_LIMIT=20
# Legacy fallback: if LLM_BASE_URL is not set, OLLAMA_HOST will be auto-converted to /v1/ path
#OLLAMA_HOST=http://localhost:11434
# TTS/ASR settings (see README for full list)
TTS_ASR_DEVICE=auto

View File

@@ -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。
- 成功时返回 JSONcontent 和 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
- GeoIPtests/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 冲突,以代码为准。

View File

@@ -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)
}

View File

@@ -2,546 +2,206 @@ import os
import time
import logging
import asyncio
import json
import base64
from datetime import datetime
from typing import AsyncIterator, Literal
import httpx
import ollama
from dotenv import load_dotenv
from prompts import get_vlm_ocr_prompt
load_dotenv()
# OpenAI-compatible endpoint config
LLM_BASE_URL = os.getenv('LLM_BASE_URL', 'http://localhost:11434/v1/')
LLM_API_KEY = os.getenv('LLM_API_KEY', 'ollama')
# Model names (backward compat: fall back to OLLAMA_MODEL if LLM_MODEL not set)
_raw_model = os.getenv('LLM_MODEL') or os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
LLM_MODEL = _raw_model.strip() if _raw_model else 'gpt-oss:20b'
PRO_LLM_MODEL = os.getenv('PRO_LLM_MODEL', LLM_MODEL)
# VLM for OCR (vision models)
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
# Fallback for legacy OLLAMA_HOST env var (auto-convert to /v1/ path)
_legacy_host = os.getenv('OLLAMA_HOST')
if _legacy_host and not os.getenv('LLM_BASE_URL'):
base = _legacy_host.rstrip('/')
if '/v1' not in base:
LLM_BASE_URL = f"{base}/v1/"
# Timeouts in seconds
COMPLETION_TIMEOUT = 30
OCR_TIMEOUT = 60
CONVERT_TIMEOUT = 30
# Normalize trailing slash for base URL
LLM_BASE_URL = LLM_BASE_URL.rstrip('/') + '/'
client = ollama.AsyncClient(host=OLLAMA_HOST)
logger = logging.getLogger("llm")
# Timeouts in seconds (10 minutes for large model loading)
COMPLETION_TIMEOUT = int(os.getenv("LLM_COMPLETION_TIMEOUT", "600"))
OCR_TIMEOUT = int(os.getenv("LLM_OCR_TIMEOUT", "600"))
VLM_OCR_CONTEXT_PROMPT = """You are an OCR and visual-context extractor for markdown writing assistance.
logger = logging.getLogger('llm')
Your output will be embedded inside an HTML comment as hidden context for a text-completion model.
Requirements:
- Keep output compact: maximum 120 words.
- Use plain text only (no markdown code fences).
- Never output <!-- or -->.
- Do not invent unreadable text; mark uncertain characters with ?.
- Preserve original script for recognized text (do not forcibly translate).
Return exactly this format:
TEXT:
<exact transcription of visible text; use " | " for line breaks; write "(none)" if no readable text>
KEY_DETAILS:
- <3-5 short factual bullets about relevant objects/layout>
LANGUAGE:
<dominant language(s) in visible text, e.g. English / Chinese / Mixed>
SUMMARY:
<one short sentence, <= 20 words>"""
def _extract_message(response) -> tuple[str, str]:
content = ""
thinking = ""
if hasattr(response, 'message') and response.message:
content = response.message.content or ""
thinking = getattr(response.message, 'thinking', '') or ""
elif isinstance(response, dict):
msg = response.get('message', {})
content = msg.get('content', '') or ""
thinking = msg.get('thinking', '') or ""
def _extract_message(response: dict) -> tuple[str, str]:
"""Extract content and thinking from an OpenAI-compatible response dict."""
choices = response.get('choices', []) if isinstance(response, dict) else []
msg = (choices[0].get('message', {}) if choices and isinstance(choices, list) else {}).copy()
content = msg.get('content', '') or ''
thinking = (msg.get('reasoning_content') or msg.get('thinking', '') or '').strip()
return content, thinking
def _resolve_system_prompt(system_prompt: str | None) -> str:
if system_prompt and system_prompt.strip():
return system_prompt.strip()
return ''
def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str:
candidate = (model or '').strip()
if candidate:
return candidate
return PRO_LLM_MODEL if use_pro_model else LLM_MODEL
def _build_chat_payload(
prompt: str,
*,
system_prompt: str | None = None,
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
) -> dict:
messages = []
sys_prompt = _resolve_system_prompt(system_prompt)
if sys_prompt:
messages.append({'role': 'system', 'content': sys_prompt})
messages.append({'role': 'user', 'content': prompt})
payload = {
'model': _resolve_model_name(model, use_pro_model=use_pro_model),
'messages': messages,
'stream': False,
}
options = {'temperature': temperature}
if thinking:
payload['options'] = {'temperature': temperature, 'think': thinking}
return payload
def _build_chat_stream_payload(
prompt: str,
*,
system_prompt: str | None = None,
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
) -> dict:
messages = []
sys_prompt = _resolve_system_prompt(system_prompt)
if sys_prompt:
messages.append({'role': 'system', 'content': sys_prompt})
messages.append({'role': 'user', 'content': prompt})
payload = {
'model': _resolve_model_name(model, use_pro_model=use_pro_model),
'messages': messages,
'stream': True,
}
options = {'temperature': temperature}
if thinking:
payload['options'] = {'temperature': temperature, 'think': thinking}
return payload
def _extract_delta_text(chunk: dict) -> str:
"""Extract text delta from an OpenAI-compatible SSE chunk."""
choices = chunk.get('choices', []) if isinstance(chunk, dict) else []
delta = (choices[0].get('delta', {}) if choices and isinstance(choices, list) else {}).copy()
content = delta.get('content', '') or ''
return content
def _extract_delta_thinking(chunk: dict) -> str:
"""Extract thinking/reasoning delta from an SSE chunk."""
choices = chunk.get('choices', []) if isinstance(chunk, dict) else []
delta = (choices[0].get('delta', {}) if choices and isinstance(choices, list) else {}).copy()
return (delta.get('reasoning_content') or delta.get('thinking', '') or '').strip()
async def call_ollama(
prompt: str,
*,
system_prompt: str | None = None,
tag: str = 'default',
system_prompt: str = None,
tag: str = "default",
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
thinking: str = None,
) -> dict:
"""Call OpenAI-compatible chat completions (non-streaming) and return content/thinking."""
"""
调用 Ollama API 并返回 content 和 thinking。
"""
start = time.perf_counter()
start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
log_model_name = 'pro' if (model is None and use_pro_model) else model_name
logger.info(
'[LLM][%s] request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
tag, log_model_name, LLM_BASE_URL, len(prompt),
len(system_prompt or ''), temperature, thinking,
"[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
tag,
OLLAMA_MODEL,
OLLAMA_HOST,
len(prompt),
len(system_prompt or ""),
temperature,
thinking,
)
payload = _build_chat_payload(
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
thinking=thinking, model=model, use_pro_model=use_pro_model,
)
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
try:
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
resp = await asyncio.wait_for(
client.post('/chat/completions', json=payload), timeout=COMPLETION_TIMEOUT,
)
messages = []
if system_prompt and system_prompt.strip():
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
resp.raise_for_status()
response = resp.json()
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:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[LLM][%s] call_time [%s --> %s]', tag,
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
"[LLM][%s] call_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.warning('[LLM][%s] request cancelled after %.1fms', tag, elapsed_ms)
logger.warning("[LLM][%s] request cancelled after %.1fms", tag, elapsed_ms)
raise
except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[LLM][%s] call_time [%s --> %s]', tag,
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
"[LLM][%s] call_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.exception('[LLM][%s] request failed after %.1fms', tag, elapsed_ms)
logger.exception("[LLM][%s] request failed after %.1fms", tag, elapsed_ms)
raise
content, thinking_out = _extract_message(response)
content, thinking = _extract_message(response)
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[LLM][%s] call_time [%s --> %s]', tag,
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
"[LLM][%s] call_time [%s --> %s]",
tag,
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.info(
'[LLM][%s] response in %.1fms content_chars=%d thinking_chars=%d',
tag, elapsed_ms, len(content), len(thinking_out or ''),
"[LLM][%s] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d",
tag,
elapsed_ms,
type(response).__name__,
len(content),
len(thinking),
)
if not content.strip():
logger.warning('[LLM][%s] empty content returned by model', tag)
return {'content': content, 'think': thinking_out or ''}
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]:
"""Stream text deltas from OpenAI-compatible chat completions."""
start = time.perf_counter()
start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
log_model_name = 'pro' if (model is None and use_pro_model) else model_name
yielded_chars = 0
logger.info(
'[LLM][%s] stream request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
tag, log_model_name, LLM_BASE_URL, len(prompt),
len(system_prompt or ''), temperature, thinking,
)
payload = _build_chat_stream_payload(
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
thinking=thinking, model=model, use_pro_model=use_pro_model,
)
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
try:
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
try:
async with client.stream('POST', '/chat/completions', json=payload) as response:
response.raise_for_status()
deadline = time.perf_counter() + COMPLETION_TIMEOUT
line_iterator = response.aiter_lines().__aiter__()
while True:
remaining = deadline - time.perf_counter()
if remaining <= 0:
raise TimeoutError('LLM stream timed out')
try:
line = await asyncio.wait_for(line_iterator.__anext__(), timeout=remaining)
except StopAsyncIteration:
break
if not line or line.startswith(':'):
continue
# SSE data lines: "data: {json}" or "data: [DONE]"
if line.startswith('data: '):
data_str = line[6:] # strip "data: " prefix
else:
data_str = line.strip()
if not data_str or data_str == '[DONE]':
continue
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
logger.warning('[LLM][%s] ignored invalid stream line', tag)
continue
if not isinstance(chunk, dict):
continue
text = _extract_delta_text(chunk)
if not text:
continue
yielded_chars += len(text)
yield text
except asyncio.CancelledError:
if response is not None:
await response.aclose()
raise
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 stream_ollama_events(
prompt: str,
*,
system_prompt: str | None = None,
tag: str = 'default-events',
temperature: float = 0.7,
thinking: str | None = None,
model: str | None = None,
use_pro_model: bool = False,
enable_thinking: bool = True,
timeout: float | None = None,
) -> AsyncIterator[tuple[Literal['thinking', 'content'], str]]:
"""Stream (event_type, payload) tuples from OpenAI-compatible chat completions."""
start = time.perf_counter()
start_dt = datetime.now()
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
log_model_name = 'pro' if (model is None and use_pro_model) else model_name
yielded_chars = 0
logger.info(
'[LLM][%s] event_stream request model=%s base_url=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s',
tag, log_model_name, LLM_BASE_URL, len(prompt),
len(system_prompt or ''), temperature, thinking,
)
payload = _build_chat_stream_payload(
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
thinking=thinking if enable_thinking else None, model=model, use_pro_model=use_pro_model,
)
effective_timeout = timeout if timeout is not None else COMPLETION_TIMEOUT
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
sent_thinking = False
try:
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
try:
async with client.stream('POST', '/chat/completions', json=payload) as response:
response.raise_for_status()
deadline = time.perf_counter() + effective_timeout
line_iterator = response.aiter_lines().__aiter__()
while True:
remaining = deadline - time.perf_counter()
if remaining <= 0:
raise TimeoutError('LLM event stream timed out')
try:
line = await asyncio.wait_for(line_iterator.__anext__(), timeout=remaining)
except StopAsyncIteration:
break
if not line or line.startswith(':'):
continue
# SSE data lines: "data: {json}" or "data: [DONE]"
if line.startswith('data: '):
data_str = line[6:] # strip "data: " prefix
else:
data_str = line.strip()
if not data_str or data_str == '[DONE]':
continue
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
logger.warning('[LLM][%s] ignored invalid Ollama stream line', tag)
continue
if not isinstance(chunk, dict):
continue
error = chunk.get('error')
if error:
raise RuntimeError(str(error))
thinking_delta = _extract_delta_thinking(chunk)
if thinking_delta and not sent_thinking:
sent_thinking = True
yield 'thinking', ''
text = _extract_delta_text(chunk)
if not text:
continue
yielded_chars += len(text)
yield 'content', text
except asyncio.CancelledError:
if response is not None:
await response.aclose()
raise
except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[LLM][%s] event_stream_time [%s --> %s]', tag,
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
)
logger.warning('[LLM][%s] event 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] event_stream_time [%s --> %s]', tag,
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
)
logger.exception('[LLM][%s] event stream failed after %.1fms', tag, elapsed_ms)
raise
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[LLM][%s] event_stream_time [%s --> %s]', tag,
start_dt.strftime('%H:%M:%S'), end_dt.strftime('%H:%M:%S'),
)
logger.info(
'[LLM][%s] event stream finished in %.1fms yielded_chars=%d thinking_seen=%s',
tag, elapsed_ms, yielded_chars, sent_thinking,
)
logger.warning("[LLM][%s] empty content returned by model", tag)
return {"content": content, "think": thinking}
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
"""OCR via VLM using OpenAI-compatible vision API (image_url content part)."""
start = time.perf_counter()
start_dt = datetime.now()
logger.info(
'[VLM][ocr] request model=%s base_url=%s image_bytes=%d language=%s',
VLM_MODEL, LLM_BASE_URL, len(image_bytes), language,
"[VLM][ocr] request model=%s host=%s image_bytes=%d language=%s",
VLM_MODEL,
OLLAMA_HOST,
len(image_bytes),
language,
)
image_b64 = base64.b64encode(image_bytes).decode('ascii')
payload = {
'model': VLM_MODEL,
'messages': [{
'role': 'user',
'content': [
{'type': 'text', 'text': get_vlm_ocr_prompt()},
{
'type': 'image_url',
'image_url': {'url': f'data:image/png;base64,{image_b64}'},
},
],
}],
'stream': False,
}
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
try:
async with httpx.AsyncClient(base_url=LLM_BASE_URL, timeout=http_timeout) as client:
resp = await asyncio.wait_for(
client.post('/chat/completions', json=payload), timeout=OCR_TIMEOUT,
)
resp.raise_for_status()
response = resp.json()
response = await asyncio.wait_for(
client.chat(
model=VLM_MODEL,
messages=[{
'role': 'user',
'content': VLM_OCR_CONTEXT_PROMPT,
'images': [image_bytes]
}],
stream=False,
options={'temperature': 0.3}
),
timeout=OCR_TIMEOUT
)
except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[VLM][ocr] call_time [%s --> %s]', start_dt.strftime('%H:%M:%S'),
end_dt.strftime('%H:%M:%S'),
"[VLM][ocr] call_time [%s --> %s]",
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.exception('[VLM][ocr] request failed after %.1fms', elapsed_ms)
logger.exception("[VLM][ocr] request failed after %.1fms", elapsed_ms)
raise
content, _ = _extract_message(response)
content, thinking = _extract_message(response)
elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now()
logger.info(
'[VLM][ocr] call_time [%s --> %s]', start_dt.strftime('%H:%M:%S'),
end_dt.strftime('%H:%M:%S'),
"[VLM][ocr] call_time [%s --> %s]",
start_dt.strftime("%H:%M:%S"),
end_dt.strftime("%H:%M:%S"),
)
logger.info(
'[VLM][ocr] response in %.1fms content_chars=%d', elapsed_ms, len(content),
"[VLM][ocr] response in %.1fms response_type=%s content_chars=%d thinking_chars=%d",
elapsed_ms,
type(response).__name__,
len(content),
len(thinking),
)
if not content.strip():
logger.warning('[VLM][ocr] empty content returned by model')
logger.warning("[VLM][ocr] empty content returned by model")
return content

View File

@@ -3,22 +3,16 @@ import base64
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import uuid
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from geoip import get_ip_location_text
from llm import call_ollama, call_vlm_ocr, stream_ollama
from models import UserPreferences
from llm import call_ollama, call_vlm_ocr
from prompt import build_completion_prompts, prepare_prompt_context
import markitdown
@@ -28,41 +22,41 @@ logging.basicConfig(
)
logger = logging.getLogger("api")
_markitdown_instance = None
def _get_markitdown(): # pragma: no cover
global _markitdown_instance
if _markitdown_instance is None:
_markitdown_instance = markitdown.MarkItDown()
return _markitdown_instance
app = FastAPI()
# Startup event disabled — TTS model loads lazily on first request
# to avoid blocking startup and OOM crashes.
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
allow_origins=[
"http://localhost:5173",
"http://localhost:3000",
"https://www.imageteach.tech",
"https://chat.imageteach.tech",
],
allow_credentials=False,
allow_methods=["POST", "OPTIONS"],
allow_headers=["Content-Type", "X-Request-Id"],
)
API_KEY = os.getenv("API_KEY", "your-secret-key-here")
api_key_header = APIKeyHeader(name="X-API-Key")
ACTIVE_COMPLETIONS: dict[str, asyncio.Task] = {}
ACTIVE_COMPLETIONS_LOCK = asyncio.Lock()
# Rate limiting
MAX_CONCURRENT_COMPLETIONS = 4
COMPLETION_RATE_LIMIT = 60 # per minute
# File size limits (bytes)
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
if api_key != API_KEY:
raise HTTPException(
status_code=403,
detail="Could not validate credentials",
)
return api_key
class UserPreferences(BaseModel):
language: str = "auto"
currency: str = "auto"
timezone: str = "auto"
class CompletionRequest(BaseModel):
@@ -72,8 +66,6 @@ class CompletionRequest(BaseModel):
model_thinking: str = "low"
privacy_mode: bool = False
user_preferences: Optional[UserPreferences] = None
model: Optional[str] = None
temperature: float = 0.7
class CancelCompletionRequest(BaseModel):
@@ -92,32 +84,6 @@ class ConvertRequest(BaseModel):
filename: str = "document.pdf"
ALLOWED_CONVERT_EXTENSIONS = {".txt", ".docx", ".pptx", ".pdf"}
IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*]\([^)]+\)")
IMAGE_HTML_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
def _convert_docx_to_pdf(input_path: str, output_path: str) -> None: # pragma: no cover
node_executable = shutil.which("node")
if not node_executable:
raise RuntimeError("未找到 Node.js无法转换 DOCX 为 PDF")
bridge_path = os.path.join(os.path.dirname(__file__), "docx2pdf_bridge.cjs")
if not os.path.exists(bridge_path):
raise RuntimeError("缺少 DOCX 转 PDF 桥接脚本")
result = subprocess.run(
[node_executable, bridge_path, input_path, output_path],
cwd=os.path.dirname(os.path.dirname(__file__)),
capture_output=True,
text=True,
)
if result.returncode != 0:
error_text = (result.stderr or result.stdout or "DOCX 转 PDF 失败").strip()
raise RuntimeError(error_text)
def _preview(text: str, limit: int = 80) -> str:
value = (text or "").replace("\n", "\\n")
if len(value) <= limit:
@@ -125,49 +91,34 @@ def _preview(text: str, limit: int = 80) -> str:
return value[:limit] + "..."
def _sanitize_converted_markdown(text: str) -> str:
value = (text or "").replace("\r\n", "\n").replace("\r", "\n")
value = IMAGE_MARKDOWN_RE.sub("", value)
value = IMAGE_HTML_RE.sub("", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _error_response(request_id: str, code: str, message: str, status_code: int = 500) -> JSONResponse:
return JSONResponse(
content={
"error": {
"code": code,
"message": message,
"request_id": request_id,
}
},
status_code=status_code,
)
def get_client_ip(request: Request) -> str:
if request.client:
return request.headers.get("X-Client-IP") or request.client.host
return request.headers.get("X-Client-IP") or "unknown"
def _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))
def _sse_payload(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n"
@app.post("/v1/completions")
async def create_completion(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
async def create_completion(request: Request, req: CompletionRequest):
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request_tag = request_id[:8]
inference_task: Optional[asyncio.Task] = None
client_ip = "hidden"
location = ""
if not req.privacy_mode: # pragma: no cover
client_ip = get_client_ip(request)
location = get_ip_location_text(client_ip)
if location:
logger.info("[%s] client_location=%s", request_tag, location)
try:
logger.info(
"[%s] /v1/completions request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s",
"[%s] /v1/completions request_id=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s",
request_tag,
request_id,
client_ip,
len(req.prefix or ""),
len(req.suffix or ""),
req.languageId,
@@ -183,7 +134,6 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
req.prefix,
req.suffix,
req.languageId,
location=location,
thinking_level=req.model_thinking,
preferences=req.user_preferences,
)
@@ -193,16 +143,16 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
user_prompt,
system_prompt=system_prompt,
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,
model=req.model,
)
)
existing = ACTIVE_COMPLETIONS.get(request_id)
if existing and not existing.done():
existing.cancel()
ACTIVE_COMPLETIONS[request_id] = inference_task
async with ACTIVE_COMPLETIONS_LOCK:
existing = ACTIVE_COMPLETIONS.get(request_id)
if existing and not existing.done():
existing.cancel()
ACTIVE_COMPLETIONS[request_id] = inference_task
result = await inference_task
content = result["content"] or ""
@@ -216,139 +166,30 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
_preview(content, 120),
)
return JSONResponse(content={"content": content, "request_id": request_id})
async def generate():
yield _sse_payload({"content": content})
yield _sse_payload({"done": True})
return StreamingResponse(generate(), media_type="text/event-stream")
except asyncio.CancelledError:
logger.info("[%s] /v1/completions cancelled request_id=%s", request_tag, request_id)
return JSONResponse(content={"cancelled": True, "request_id": request_id}, status_code=499)
async def cancelled():
yield _sse_payload({"cancelled": True, "request_id": request_id, "done": True})
return StreamingResponse(cancelled(), media_type="text/event-stream")
except Exception as e:
logger.exception("[%s] /v1/completions failed request_id=%s: %s", request_tag, request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500)
return _error_response(request_id, "INTERNAL_ERROR", "Service temporarily unavailable", 500)
finally:
active = ACTIVE_COMPLETIONS.get(request_id)
if active is not None and active is inference_task:
ACTIVE_COMPLETIONS.pop(request_id, None)
@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:
async with ACTIVE_COMPLETIONS_LOCK:
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)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@app.post("/v1/completions/cancel")
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
async def cancel_completion(req: CancelCompletionRequest):
request_tag = str(uuid.uuid4())[:8]
request_id = req.request_id or ""
@@ -384,7 +225,7 @@ async def cancel_completion(req: CancelCompletionRequest, api_key: str = Securit
@app.post("/v1/ocr")
async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
async def ocr_image(request: OCRRequest):
request_id = str(uuid.uuid4())[:8]
try:
logger.info(
@@ -394,9 +235,24 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
request.language,
len(request.image or ""),
)
# Check file size before decoding
if len(request.image or "") > MAX_IMAGE_SIZE * 4 // 3: # base64 overhead
return _error_response(request_id, "FILE_TOO_LARGE", "Image exceeds 10MB limit", 413)
# Check extension
ext = os.path.splitext(request.filename)[1].lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
return _error_response(request_id, "INVALID_FILE_TYPE", "Only jpg/png/webp allowed", 415)
image_bytes = base64.b64decode(request.image)
# Check actual decoded size
if len(image_bytes) > MAX_IMAGE_SIZE:
return _error_response(request_id, "FILE_TOO_LARGE", "Image exceeds 10MB limit", 413)
logger.info("[%s] /v1/ocr decoded image_bytes=%d", request_id, len(image_bytes))
result = await call_vlm_ocr(image_bytes, request.language)
result = await call_vlm_ocr(image_bytes, request.language)
logger.info(
"[%s] /v1/ocr success text_chars=%d text_preview='%s'",
request_id,
@@ -406,14 +262,14 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
return {"text": result, "filename": request.filename}
except Exception as e:
logger.exception("[%s] /v1/ocr failed: %s", request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500)
return _error_response(request_id, "OCR_FAILED", "Failed to process image", 500)
@app.post("/v1/convert")
async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(get_api_key)):
"""Convert file to markdown"""
async def convert_to_markdown(request: ConvertRequest):
"""将文件转换为Markdown格式"""
request_id = str(uuid.uuid4())[:8]
try:
logger.info(
"[%s] /v1/convert filename=%s file_base64_chars=%d",
@@ -421,75 +277,74 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
request.filename,
len(request.file or ""),
)
# Decode base64
file_bytes = base64.b64decode(request.file)
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
# Get file extension
# Check file size before decoding
if len(request.file or "") > MAX_CONVERT_SIZE * 4 // 3:
return _error_response(request_id, "FILE_TOO_LARGE", "File exceeds 50MB limit", 413)
# Get file extension and validate
ext = os.path.splitext(request.filename)[1].lower()
if ext not in ALLOWED_CONVERT_EXTENSIONS:
raise ValueError("仅支持 txt、docxpptx、pdf 格式")
if ext == ".txt":
markdown_text = _sanitize_converted_markdown(file_bytes.decode("utf-8", errors="ignore"))
return {
"markdown": markdown_text,
"filename": request.filename
}
# Create temporary file
return _error_response(request_id, "INVALID_FILE_TYPE", "Only pdf/docx/pptx/xlsx/md/txt allowed", 415)
# 解码Base64文件内容
file_bytes = base64.b64decode(request.file)
# Check actual decoded size
if len(file_bytes) > MAX_CONVERT_SIZE:
return _error_response(request_id, "FILE_TOO_LARGE", "File exceeds 50MB limit", 413)
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
# 创建临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
tmp.write(file_bytes)
tmp_path = tmp.name
try:
# Convert using MarkItDown
md = _get_markitdown()
result = await asyncio.to_thread(md.convert, tmp_path)
markdown_text = _sanitize_converted_markdown(result.text_content)
# 使用MarkItDown转换为Markdown
md = markitdown.MarkItDown()
result = md.convert(tmp_path)
markdown_text = result.text_content
logger.info(
"[%s] /v1/convert success text_chars=%d text_preview='%s'",
request_id,
len(markdown_text or ""),
_preview(markdown_text, 120),
)
return {
"markdown": markdown_text,
"filename": request.filename
}
finally:
# Clean up temporary file
# 清理临时文件
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception as e:
logger.exception("[%s] /v1/convert failed: %s", request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500)
return _error_response(request_id, "CONVERT_FAILED", "Failed to convert file", 500)
# TTS and ASR routes (lazy loaded to avoid heavy import on startup)
def _register_tts_asr_routes():
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__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
@app.get("/health/live")
async def health_live():
return {"status": "ok"}
@app.get("/health/ready")
async def health_ready():
# Check if critical components are available
try:
# Could add more checks here (e.g., Ollama connectivity)
return {"status": "ready"}
except Exception as e:
logger.warning("[health/ready] not ready: %s", e)
return _error_response("health-check", "NOT_READY", "Service not ready", 503)

View File

@@ -1,9 +0,0 @@
"""共享的 Pydantic 模型定义"""
from pydantic import BaseModel
class UserPreferences(BaseModel):
"""用户偏好设置"""
language: str = "auto"
currency: str = "auto"
timezone: str = "auto"

View File

@@ -1,389 +0,0 @@
import asyncio
import contextlib
import json
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from geoip import get_ip_location_text
from llm import stream_ollama_events
from models import UserPreferences
logger = logging.getLogger("api.pro")
PRO_COMPLETION_TIMEOUT = float(os.getenv("PRO_COMPLETION_TIMEOUT", "3600"))
PRO_QUEUE_TIMEOUT = float(os.getenv("PRO_QUEUE_TIMEOUT", "600"))
PRO_MAX_CONCURRENCY = max(1, int(os.getenv("PRO_MAX_CONCURRENCY", "1")))
PRO_QUEUE_MAX_SIZE = max(0, int(os.getenv("PRO_QUEUE_MAX_SIZE", "5")))
PRO_STATUS_RETENTION_SECONDS = float(os.getenv("PRO_STATUS_RETENTION_SECONDS", "600"))
PRO_CANCEL_ACK_TIMEOUT = 5.0
PUBLIC_PRO_ERROR = "PRO generation failed. Please retry or adjust the instruction."
class ProCompletionRequest(BaseModel):
prefix: str
suffix: str
languageId: str = "markdown"
instruction: str = ""
pro_thinking: str = "medium"
privacy_mode: bool = False
user_preferences: Optional[UserPreferences] = None
class ProCancelRequest(BaseModel):
request_id: str
reason: str = "abort"
@dataclass
class ProRequestState:
request_id: str
status: str = "queued"
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
error: str = ""
task: asyncio.Task | None = None
cancel_requested: bool = False
done_event: asyncio.Event = field(default_factory=asyncio.Event)
def touch(self, status: str | None = None, error: str = "") -> None:
if status:
self.status = status
if error:
self.error = error
self.updated_at = time.time()
def request_cancel(self) -> None:
self.cancel_requested = True
self.touch("cancelled")
PRO_STATES: dict[str, ProRequestState] = {}
PRO_STATES_LOCK = asyncio.Lock()
PRO_SEMAPHORE = asyncio.Semaphore(PRO_MAX_CONCURRENCY)
def _iso_timestamp(value: float) -> str:
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
def _clamp_thinking(value: str | None) -> str | None:
normalized = (value or "medium").strip().lower()
if normalized in {"none", "off", "false"}:
return None
if normalized in {"low", "medium", "high"}:
return normalized
return "medium"
def _queued_states() -> list[ProRequestState]:
return [state for state in PRO_STATES.values() if state.status == "queued"]
def _queue_position(request_id: str) -> int | None:
queued = sorted(_queued_states(), key=lambda item: item.created_at)
for index, state in enumerate(queued, start=1):
if state.request_id == request_id:
return index
return None
async def _cleanup_states() -> None:
now = time.time()
expired = [
request_id
for request_id, state in PRO_STATES.items()
if state.status in {"done", "error", "cancelled"}
and now - state.updated_at > PRO_STATUS_RETENTION_SECONDS
]
for request_id in expired:
PRO_STATES.pop(request_id, None)
def _state_payload(state: ProRequestState) -> dict:
return {
"request_id": state.request_id,
"status": state.status,
"queue_position": _queue_position(state.request_id),
"created_at": _iso_timestamp(state.created_at),
"updated_at": _iso_timestamp(state.updated_at),
"error": state.error,
}
def _build_pro_prompts(
*,
prefix: str,
suffix: str,
language_id: str,
instruction: str,
location: str = "",
preferences: UserPreferences | None = None,
) -> tuple[str, str]:
safe_language = (language_id or "markdown").strip() or "markdown"
safe_instruction = (instruction or "").strip()
preference_lines: list[str] = []
if preferences:
if preferences.language and preferences.language != "auto":
preference_lines.append(f"- Preferred language: {preferences.language}")
if preferences.currency and preferences.currency != "auto":
preference_lines.append(f"- Preferred currency: {preferences.currency}")
if preferences.timezone and preferences.timezone != "auto":
preference_lines.append(f"- Timezone: {preferences.timezone}")
if location:
preference_lines.append(f"- Location hint: {location}")
system_prompt = f"""You edit Markdown documents.
Return only the Markdown text to insert at the cursor.
Do not explain, analyze, label the answer, or wrap the whole answer in a code fence.
Match the document language, style, and Markdown structure.
Language: {safe_language}."""
preferences_text = "\n".join(preference_lines) if preference_lines else "- none"
instruction_text = safe_instruction or "Continue the Markdown naturally."
user_prompt = f"""Instruction:
{instruction_text}
User preferences:
{preferences_text}
Markdown before cursor:
{prefix}
Markdown after cursor:
{suffix}
Write only the Markdown that belongs at the cursor."""
return system_prompt.strip(), user_prompt.strip()
def _get_client_ip(request: Request) -> str:
if request.client:
return request.headers.get("X-Client-IP") or request.client.host
return request.headers.get("X-Client-IP") or "unknown"
async def _send_sse_event(queue: asyncio.Queue, event_name: str, data: dict) -> None:
await queue.put((event_name, json.dumps(data, ensure_ascii=False)))
async def _wait_for_cancel_cleanup(state: ProRequestState, request_tag: str, reason: str) -> None:
if state.done_event.is_set():
return
try:
await asyncio.wait_for(state.done_event.wait(), timeout=PRO_CANCEL_ACK_TIMEOUT)
except asyncio.TimeoutError:
logger.warning(
"[%s] /v1/pro/completions cancel cleanup not confirmed request_id=%s reason=%s",
request_tag,
state.request_id,
reason,
)
def register_pro_completion_routes(app: FastAPI, get_api_key):
@app.post("/v1/pro/completions")
async def create_pro_completion(
request: Request,
req: ProCompletionRequest,
api_key: str = Security(get_api_key),
):
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request_tag = request_id[:8]
event_queue: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue()
previous_state: ProRequestState | None = None
async with PRO_STATES_LOCK:
await _cleanup_states()
queued_count = len(_queued_states())
if queued_count >= PRO_QUEUE_MAX_SIZE:
logger.info("[%s] /v1/pro/completions rejected queue_full request_id=%s", request_tag, request_id)
return JSONResponse(
content={"error": "PRO queue is full", "request_id": request_id},
status_code=429,
)
existing = PRO_STATES.get(request_id)
if existing and existing.task and not existing.task.done():
existing.request_cancel()
existing.task.cancel()
previous_state = existing
state = ProRequestState(request_id=request_id)
PRO_STATES[request_id] = state
if previous_state:
await _wait_for_cancel_cleanup(previous_state, request_tag, "replace")
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)
prefix = req.prefix or ""
suffix = req.suffix or ""
system_prompt, user_prompt = _build_pro_prompts(
prefix=prefix,
suffix=suffix,
language_id=req.languageId,
instruction=req.instruction,
location=location,
preferences=req.user_preferences,
)
logger.info(
"[%s] /v1/pro/completions request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d instruction_chars=%d lang=%s thinking=%s",
request_tag,
request_id,
client_ip,
len(prefix),
len(suffix),
len(req.instruction or ""),
req.languageId,
req.pro_thinking,
)
async def producer() -> None:
acquired = False
chunks: list[str] = []
try:
async with PRO_STATES_LOCK:
if state.cancel_requested:
raise asyncio.CancelledError()
state.touch("queued")
queue_position = _queue_position(request_id)
await _send_sse_event(event_queue, "queued", {"request_id": request_id, "queue_position": queue_position})
await asyncio.wait_for(PRO_SEMAPHORE.acquire(), timeout=PRO_QUEUE_TIMEOUT)
acquired = True
async with PRO_STATES_LOCK:
if state.cancel_requested:
raise asyncio.CancelledError()
state.touch("started")
await _send_sse_event(event_queue, "started", {"request_id": request_id})
async for event_type, payload in stream_ollama_events(
user_prompt,
system_prompt=system_prompt,
tag=f"{request_tag}-pro",
temperature=0.7,
thinking=_clamp_thinking(req.pro_thinking),
use_pro_model=True,
enable_thinking=True,
timeout=PRO_COMPLETION_TIMEOUT,
):
if event_type == "thinking":
await _send_sse_event(event_queue, "thinking", {"request_id": request_id})
continue
if not payload:
continue
chunks.append(payload)
await _send_sse_event(event_queue, "chunk", {"delta": payload, "request_id": request_id})
content = "".join(chunks)
async with PRO_STATES_LOCK:
if state.cancel_requested:
raise asyncio.CancelledError()
if not content:
raise ValueError("PRO returned empty content")
async with PRO_STATES_LOCK:
state.touch("done")
logger.info("[%s] /v1/pro/completions done request_id=%s content_chars=%d", request_tag, request_id, len(content))
await _send_sse_event(event_queue, "done", {"content": content, "request_id": request_id})
except asyncio.CancelledError:
async with PRO_STATES_LOCK:
state.request_cancel()
logger.info("[%s] /v1/pro/completions cancelled request_id=%s", request_tag, request_id)
await _send_sse_event(event_queue, "cancelled", {"cancelled": True, "request_id": request_id})
raise
except Exception as exc:
async with PRO_STATES_LOCK:
state.touch("error", PUBLIC_PRO_ERROR)
logger.exception("[%s] /v1/pro/completions failed request_id=%s", request_tag, request_id)
await _send_sse_event(event_queue, "error", {"error": PUBLIC_PRO_ERROR, "request_id": request_id})
finally:
if acquired:
PRO_SEMAPHORE.release()
state.done_event.set()
await event_queue.put(None)
producer_task = asyncio.create_task(producer())
async with PRO_STATES_LOCK:
state.task = producer_task
async def event_stream():
try:
while True:
item = await event_queue.get()
if item is None:
break
event_name, data = item
yield f"event: {event_name}\ndata: {data}\n\n"
except asyncio.CancelledError:
async with PRO_STATES_LOCK:
state.request_cancel()
producer_task.cancel()
raise
finally:
if not producer_task.done() and not state.done_event.is_set():
async with PRO_STATES_LOCK:
state.request_cancel()
producer_task.cancel()
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(state.done_event.wait(), timeout=PRO_CANCEL_ACK_TIMEOUT)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@app.post("/v1/pro/completions/cancel")
async def cancel_pro_completion(req: ProCancelRequest, api_key: str = Security(get_api_key)):
request_id = req.request_id or ""
request_tag = request_id[:8]
state_to_wait: ProRequestState | None = None
async with PRO_STATES_LOCK:
await _cleanup_states()
state = PRO_STATES.get(request_id)
if not state:
return {"cancelled": False, "status": "not_found"}
if state.task and not state.task.done():
state.request_cancel()
state.task.cancel()
state_to_wait = state
if state.status in {"done", "error", "cancelled"}:
if not state_to_wait:
return {"cancelled": False, "status": state.status}
else:
state.request_cancel()
if state_to_wait:
await _wait_for_cancel_cleanup(state_to_wait, request_tag, req.reason)
return {"cancelled": True, "status": "ok"}
@app.get("/v1/pro/completions/status/{request_id}")
async def get_pro_completion_status(request_id: str, api_key: str = Security(get_api_key)):
async with PRO_STATES_LOCK:
await _cleanup_states()
state = PRO_STATES.get(request_id)
if not state:
raise HTTPException(status_code=404, detail="PRO request not found")
return _state_payload(state)

View File

@@ -2,9 +2,6 @@ from datetime import datetime, timedelta, timezone
import re
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:
# Default to UTC+8 if auto or not specified.
@@ -65,53 +62,6 @@ def _prepare_context(prefix: str, suffix: str) -> Tuple[str, str]:
return clean_prefix, clean_suffix
def _strip_hidden_tail_context(text: str) -> str:
"""
Return the likely visible tail segment used for prefill.
The frontend prepends hidden OCR/doc context before the visible markdown and
joins those blocks with blank lines. For prefill we only want the active
visible segment near the cursor, not earlier hidden context.
"""
value = _normalize_newlines(text or "")
if not value:
return ""
tail = re.split(r"\n{2,}", value)[-1]
tail = re.sub(r"<!--[\s\S]*?-->", "", tail)
tail = re.sub(r"<OCR:[^>\n]*>", "", tail)
return tail.split("\n")[-1]
def _build_completion_prefill(prefix: str) -> str:
"""
Build a short tail prefill after <|fim_middle|> so completion models keep
writing from the existing text instead of explaining the boundary rules.
"""
normalized = _normalize_newlines(prefix or "")
if not normalized or normalized[-1].isspace():
return ""
tail = _strip_hidden_tail_context(normalized).strip()
if len(tail) < 2:
return ""
cjk_match = re.search(r"[\u3400-\u9fff]{2,6}$", tail)
if cjk_match:
value = cjk_match.group(0)
return value[-2:] if len(value) > 2 else value
token_match = re.search(r"[A-Za-z0-9_+\-.]{2,12}$", tail)
if token_match:
value = token_match.group(0)
return value[-12:]
compact_match = re.search(r"\S{2,12}$", tail)
if compact_match:
return compact_match.group(0)[-12:]
return ""
FENCE_LINE_RE = re.compile(r"^[ \t]*```.*$")
FENCE_INFO_RE = re.compile(r"^[ \t]*```[ \t]*(.*)$")
MERMAID_CONTEXT_RE = re.compile(
@@ -264,36 +214,304 @@ def _canonical_language_id(language_id: str) -> str:
return LANGUAGE_SYNONYMS.get(safe, safe)
_JS_LANGS = {"javascript", "typescript"}
_CODE_LANGS = {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}
def _language_guidance(language_id: str) -> str:
canonical = _canonical_language_id(language_id)
if canonical == "markdown":
return ""
guidance_map = get_language_guidance_map()
guidance = guidance_map.get(canonical)
if guidance:
return guidance
if canonical in _JS_LANGS:
return guidance_map.get("_js_code", "").replace("{lang}", canonical)
if canonical in _CODE_LANGS:
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
return guidance_map.get("_generic_code", "").replace("{lang}", canonical)
if canonical == "mermaid":
return """
Language-specific guidance (mermaid):
- Output valid Mermaid syntax only.
- Prefer concise, syntactically correct diagram statements.
- Avoid prose unless the user prompt explicitly requires it."""
if canonical == "latex":
return """
Language-specific guidance (latex):
- Output LaTeX math content only when completing LaTeX.
- If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex:
- Output raw LaTeX lines only.
- Do not wrap with $ or $$."""
if canonical == "json":
return """
Language-specific guidance (json):
- Output strict JSON only (no comments, no trailing commas).
- Ensure valid quotes and braces."""
if canonical == "yaml":
return """
Language-specific guidance (yaml):
- Output valid YAML only.
- Use consistent indentation and avoid tabs."""
if canonical == "toml":
return """
Language-specific guidance (toml):
- Output valid TOML only.
- Keep key types consistent."""
if canonical == "ini":
return """
Language-specific guidance (ini):
- Output valid INI only.
- Keep section headers and key=value pairs consistent."""
if canonical == "sql":
return """
Language-specific guidance (sql):
- Output a single, valid SQL statement unless context requires multiple.
- Prefer ANSI SQL when dialect is unclear."""
if canonical == "bash":
return """
Language-specific guidance (bash):
- Output POSIX-compatible shell when possible.
- Avoid interactive prompts or destructive commands unless requested."""
if canonical == "powershell":
return """
Language-specific guidance (powershell):
- Output valid PowerShell commands.
- Avoid destructive commands unless explicitly requested."""
if canonical == "html":
return """
Language-specific guidance (html):
- Output valid HTML only.
- Keep markup minimal and well-formed."""
if canonical == "css":
return """
Language-specific guidance (css):
- Output valid CSS only.
- Use concise, readable selectors."""
if canonical == "diff":
return """
Language-specific guidance (diff):
- Output a unified diff only.
- Ensure @@ hunk headers and +/- lines are consistent."""
if canonical == "regex":
return """
Language-specific guidance (regex):
- Output the regex pattern only.
- Avoid delimiters unless explicitly requested."""
if canonical in {"javascript", "typescript"}:
return f"""
Language-specific guidance ({canonical}):
- Output valid {canonical} code.
- Prefer modern syntax and avoid prose unless comments are needed."""
if canonical in {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}:
return f"""
Language-specific guidance ({canonical}):
- Output valid {canonical} code.
- Avoid prose unless context clearly expects comments or docstrings."""
if canonical == "text":
return """
Language-specific guidance (text):
- Output plain text only.
- Avoid markdown formatting unless explicitly asked."""
if canonical == "xml":
return """
Language-specific guidance (xml):
- Output well-formed XML only.
- Ensure matching tags and proper escaping."""
if canonical == "dockerfile":
return """
Language-specific guidance (dockerfile):
- Output valid Dockerfile instructions only.
- Keep layers minimal and ordered logically."""
if canonical == "makefile":
return """
Language-specific guidance (makefile):
- Output valid Makefile syntax only.
- Use tabs for recipe lines."""
return f"""
Language-specific guidance ({canonical}):
- Output valid {canonical} code.
- Avoid prose unless context clearly expects comments or docstrings."""
def build_inline_system_prompt(language_id: str = "markdown") -> str:
safe_language_id = _canonical_language_id(language_id)
language_guidance = _language_guidance(safe_language_id)
template = get_system_prompt_template()
system_prompt = template.replace("{language_id}", safe_language_id)
system_prompt = f"""You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions.
Return only the insertion text that should be placed between PREFIX and SUFFIX.
Hard constraints you must follow:
1) Output-only contract:
- Output insertion text only.
- No explanations, no meta labels, no wrapper quotes around the whole answer.
2) Strict math formatting (KaTeX):
- If you output any math expression, it must be strict KaTeX-compatible math.
- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block).
- Never output bare formulas without $ or $$ wrappers.
- Exception: If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex,
output raw LaTeX without $ or $$ wrappers.
3) Strict code formatting:
- Read CURSOR_IN_FENCED_CODE_BLOCK from the user prompt.
- If CURSOR_IN_FENCED_CODE_BLOCK=true:
- You are already inside a fenced code block.
- Never output triple backticks.
- Output code lines only.
- If CURSOR_IN_FENCED_CODE_BLOCK=false:
- Any code output must be in a fenced code block with a language tag:
```{{language}}
...
```
- Do not output code snippets as inline backticks.
- Choose the language tag from context (no default fallback tag instruction).
4) Mermaid-specific completion rules:
- Read CURSOR_FENCE_LANGUAGE and MERMAID_CONTEXT from the user prompt.
- If CURSOR_FENCE_LANGUAGE=mermaid:
- Output Mermaid statements only.
- Never output triple backticks.
- Never output prose explanations.
- If CURSOR_IN_FENCED_CODE_BLOCK=false and MERMAID_CONTEXT=true:
- Output a complete Mermaid fenced block:
```mermaid
...
```
- Keep Mermaid syntax valid and concise.
- Never mix Mermaid code and explanatory narration in one output.
5) Boundary newline repair:
- Read PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE from the user prompt.
- Carefully reason about whether OUTPUT should start or end with a newline.
- If PREFIX lacks a required boundary newline, add it at OUTPUT start.
- If SUFFIX lacks a required boundary newline, add it at OUTPUT end.
- Ensure PREFIX + OUTPUT + SUFFIX is structurally natural.
6) Context stitching:
- Do not repeat text that already appears at the start of SUFFIX.
- Preserve nearby language, tone, punctuation, indentation, and markdown structure.
- Continue existing structures naturally (lists, tables, block quotes, headings).
7) OCR safety:
- PREFIX may include hidden OCR metadata tags like <OCR:...>.
- Never output any OCR tag.
- Never output OCR tag fragments such as <OCR:...>."""
if language_guidance:
system_prompt = f"{system_prompt.rstrip()}\n{language_guidance.strip()}"
return system_prompt.strip()
_INLINE_EXAMPLES = get_inline_examples()
INLINE_EXAMPLES = """[EX01] Prose continuation
<PREFIX>The quick brown fox </PREFIX>
<SUFFIX>jumps over the lazy dog.</SUFFIX>
Expected OUTPUT:
moved quietly and then
[EX02] Avoid repeating suffix beginning
<PREFIX>Our launch plan starts with </PREFIX>
<SUFFIX>phase one, followed by phase two.</SUFFIX>
Expected OUTPUT:
careful internal testing before
[EX03] Continue markdown checklist
<PREFIX>## TODO
- [ ] Buy milk
- [ ] </PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
Write release notes and share draft with team
[EX04] Cursor outside code block, code must use fenced block
CURSOR_IN_FENCED_CODE_BLOCK=false
<PREFIX>Parse this JSON payload in Python:</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
```python
import json
data = json.loads(payload)
```
[EX05] Cursor inside fenced code block, do not output fences
CURSOR_IN_FENCED_CODE_BLOCK=true
<PREFIX>```python
def add(a, b):
return </PREFIX>
<SUFFIX>
```</SUFFIX>
Expected OUTPUT:
a + b
[EX06] Inline math must use $...$
<PREFIX>The derivative of x^2 is </PREFIX>
<SUFFIX>.</SUFFIX>
Expected OUTPUT:
$2x$
[EX07] Block math must use $$...$$
<PREFIX>We can write the Gaussian integral as:</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
$$
\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}
$$
[EX08] Prefix misses boundary newline; add newline at output start
PREFIX_ENDS_WITH_NEWLINE=false
<PREFIX>Deployment steps:</PREFIX>
<SUFFIX></SUFFIX>
Expected OUTPUT:
- Build artifact
- Deploy service
[EX09] Suffix misses boundary newline; add newline at output end
SUFFIX_STARTS_WITH_NEWLINE=false
<PREFIX>Summary paragraph complete.</PREFIX>
<SUFFIX>## Next Section</SUFFIX>
Expected OUTPUT:
[EX10] OCR metadata exists but must never be emitted
<PREFIX>![whiteboard](img.png) <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(
@@ -302,8 +520,8 @@ def build_completion_prompts(
language_id: str = "markdown",
location: str = "",
thinking_level: str = "low",
preferences: UserPreferences | None = None,
) -> Tuple[str, str, str]:
preferences: object = None,
) -> Tuple[str, str]:
safe_language_id = _canonical_language_id(language_id)
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
recent_prefix = _normalize_newlines(recent_prefix)
@@ -316,7 +534,6 @@ def build_completion_prompts(
)
prefix_ends_with_newline = recent_prefix.endswith("\n")
suffix_starts_with_newline = recent_suffix.startswith("\n")
prefill = _build_completion_prefill(recent_prefix)
tz_pref = preferences.timezone if preferences else "auto"
current_time = _get_current_datetime(tz_pref)
@@ -334,42 +551,48 @@ def build_completion_prompts(
preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}"
user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction}
Reasoning level: {thinking_level}
Editor language: {safe_language_id}
Reasoning hint: {thinking_level}
Editor language id: {safe_language_id}
=== STATE FLAGS ===
Completion state flags:
- CURSOR_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"}
- CURSOR_FENCE_LANGUAGE: {cursor_fence_language}
- MERMAID_CONTEXT: {"true" if mermaid_context else "false"}
- PREFIX_ENDS_WITH_NEWLINE: {"true" if prefix_ends_with_newline else "false"}
- SUFFIX_STARTS_WITH_NEWLINE: {"true" if suffix_starts_with_newline else "false"}
=== TASK ===
Produce the best insertion text between PREFIX and SUFFIX.
Requirements:
- Non-empty and meaningful
- Concise unless structure needs more
- Follows markdown rules in system prompt
- Use real line breaks instead of spelled-out escape sequences unless PREFIX or SUFFIX clearly requires that text
- If a boundary needs separation, put the real newline directly in OUTPUT
- Do not explain newline or boundary choices
- Continue after the PREFILL text already placed after <|fim_middle|>
Task:
- Produce the best insertion text at the cursor between PREFIX and SUFFIX.
- Keep insertion meaningful and non-empty.
- Keep insertion concise unless structure requires more content.
=== CONTEXT NOTES ===
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output
- Match PREFIX tone, style, and indentation
- Do not repeat text from SUFFIX beginning
- <|fim_prefix|>, <|fim_suffix|>, <|fim_middle|>, and PREFILL are control context only; never output these markers
Context notes:
- PREFIX may include OCR metadata after image markdown, e.g. ![alt](url) <OCR:description>.
- OCR metadata is hidden context and must never be copied into output.
- Preserve local style and formatting.
=== EXAMPLES BY CATEGORY ===
{_INLINE_EXAMPLES}
Decision policy:
- Prioritize seamless join: PREFIX + OUTPUT + SUFFIX must read naturally.
- Do not repeat SUFFIX-leading text.
- If uncertain, prefer a complete short phrase/sentence with clear meaning.
=== NOW COMPLETE THE TASK ===
Comprehensive examples:
{INLINE_EXAMPLES}
<|fim_prefix|>{recent_prefix}<|fim_suffix|>{recent_suffix}<|fim_middle|>{prefill}"""
Now produce the insertion.
<PREFIX>
{recent_prefix}
</PREFIX>
<SUFFIX>
{recent_suffix}
</SUFFIX>
Output:"""
system_prompt = build_inline_system_prompt(safe_language_id)
return system_prompt.strip(), user_prompt.strip(), prefill
return system_prompt.strip(), user_prompt.strip()
def build_prompt(
@@ -378,12 +601,12 @@ def build_prompt(
language_id: str = "markdown",
location: str = "",
thinking_level: str = "low",
preferences: UserPreferences | None = None,
preferences: object = None,
) -> str:
"""
Backward-compatible helper. Returns only the user prompt body.
"""
_, user_prompt, _ = build_completion_prompts(
_, user_prompt = build_completion_prompts(
prefix=prefix,
suffix=suffix,
language_id=language_id,

View File

@@ -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", "")

View File

@@ -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>![whiteboard](img.png) <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)"
}

View File

@@ -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."
}

View File

@@ -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, no analysis.\n\nNever output internal reasoning, chain-of-thought, boundary checks, or deliberation. Never output chat/template artifacts such as assistant, final, channel, <|start|>, <|end|>, <|fim_prefix|>, <|fim_suffix|>, or <|fim_middle|>.\n\nCONTEXT FLAGS:\n- CURSOR_IN_FENCED_CODE_BLOCK tells whether the cursor is inside a code fence.\n- CURSOR_FENCE_LANGUAGE gives the active fence language, or none.\n- PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE describe the insertion boundary.\n- MERMAID_CONTEXT tells whether Mermaid syntax is likely expected.\n\nSPECIALIZED RULES:\n- If CURSOR_IN_FENCED_CODE_BLOCK=true: output only code lines, no triple backticks.\n- If CURSOR_IN_FENCED_CODE_BLOCK=false and a code block is needed: use a fenced block with a language tag, e.g. ```{language}.\n- Inline math must use $...$; block math must use $$...$$.\n- Inside latex/tex/katex fences, output raw LaTeX only.\n- If CURSOR_FENCE_LANGUAGE=mermaid: output Mermaid syntax only, no backticks or prose.\n- If MERMAID_CONTEXT=true outside a fence: output a complete ```mermaid fenced block only when the surrounding text asks for a diagram.\n\nMARKDOWN AND BOUNDARIES:\n- Use actual line breaks, never spelled-out escape sequences, unless the document text itself needs them.\n- Match PREFIX tone, style, indentation, list/table structure, and language.\n- Never repeat text from the beginning of SUFFIX.\n- If separation is needed, put the needed real newline directly in the insertion text without explaining it.\n\nPREFILL:\n- The prompt may place a short tail of PREFIX immediately after <|fim_middle|> to make continuation natural.\n- Continue from that PREFILL. Do not describe it or output control markers.\n\nHIDDEN CONTEXT:\n- OCR metadata like <OCR:...> and document context are hidden context.\n- Use hidden context only as a semantic hint; never copy hidden tags to output."
}

View File

@@ -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>"
}

View File

@@ -1,19 +1,12 @@
fastapi>=0.95.0
uvicorn[standard]>=0.23.0
pydantic>=1.10.0
httpx>=0.24.0
numpy>=1.23.0
soundfile>=0.10.3
torch>=1.12.0
torchaudio>=1.12.0
transformers>=4.25.0
whisper>=1.0.0
qwen-tts>=0.0.0
modelscope>=1.20.0
# MLX-based ASR (Apple Silicon only)
mlx-audio>=0.4.3
# testing
pytest>=7.0.0
fastapi
uvicorn
ollama
pydantic
python-dotenv
httpx
geoip2
markitdown[all]
python-docx
python-pptx
openpyxl
pypdf

80
backend/test_geoip.py Normal file
View File

@@ -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()

View File

@@ -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.pyPrompt 上下文与规则
- test_llm.py、test_llm_extended.pyLLM 包装层
- test_geoip.pyGeoIP 逻辑
- test_tts_asr_*.pyTTS 相关与历史 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。

View File

@@ -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
**维护者**: 项目开发团队

View File

@@ -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())

View File

@@ -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())

View File

@@ -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()

View File

@@ -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") == ""

View File

@@ -1,6 +1,5 @@
import asyncio
import importlib
import json
import sys
from pathlib import Path
@@ -17,279 +16,50 @@ except ModuleNotFoundError:
pytest.skip("llm module dependencies are not available", allow_module_level=True)
def test_extract_message_openai_format():
resp = {"choices": [{"message": {"content": "hello world", "thinking": "reasoning"}}]}
content, thinking = llm._extract_message(resp)
assert content == "hello world"
assert thinking == "reasoning"
def test_extract_message_openai_reasoning_content():
resp = {"choices": [{"message": {"content": "answer", "reasoning_content": "deep thought"}}]}
content, thinking = llm._extract_message(resp)
assert content == "answer"
assert thinking == "deep thought"
def test_extract_message_empty_choices():
resp = {"choices": []}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_message_no_choices_key():
resp = {}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_message_none_content():
resp = {"choices": [{"message": {"content": None, "thinking": None}}]}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_delta_text():
chunk = {"choices": [{"delta": {"content": "hello"}}]}
assert llm._extract_delta_text(chunk) == "hello"
def test_extract_delta_text_empty():
chunk = {"choices": [{"delta": {}}]}
assert llm._extract_delta_text(chunk) == ""
def test_extract_delta_thinking():
chunk = {"choices": [{"delta": {"thinking": "reasoning step"}}]}
assert llm._extract_delta_thinking(chunk) == "reasoning step"
def test_extract_delta_reasoning_content():
chunk = {"choices": [{"delta": {"reasoning_content": "deep thought"}}]}
assert llm._extract_delta_thinking(chunk) == "deep thought"
def test_resolve_model_name_explicit():
assert llm._resolve_model_name("custom-model") == "custom-model"
def test_resolve_model_name_default():
assert llm._resolve_model_name() == llm.LLM_MODEL
def test_resolve_model_name_pro():
assert llm._resolve_model_name(use_pro_model=True) == llm.PRO_LLM_MODEL
def test_resolve_system_prompt():
assert llm._resolve_system_prompt(" system prompt ") == "system prompt"
assert llm._resolve_system_prompt("") == ""
assert llm._resolve_system_prompt(None) == ""
def test_build_chat_payload_with_system():
payload = llm._build_chat_payload(
"user prompt", system_prompt="sys prompt", temperature=0.5, model="test-model"
)
assert payload["model"] == "test-model"
assert len(payload["messages"]) == 2
assert payload["messages"][0]["role"] == "system"
assert payload["messages"][1]["role"] == "user"
assert payload["stream"] is False
def test_build_chat_payload_no_system():
payload = llm._build_chat_payload("user prompt", system_prompt=None)
assert len(payload["messages"]) == 1
assert payload["stream"] is False
def test_build_chat_payload_with_thinking():
payload = llm._build_chat_payload("prompt", thinking="low")
assert "options" in payload
assert payload["options"]["think"] == "low"
def test_build_chat_stream_payload():
payload = llm._build_chat_stream_payload("prompt", system_prompt="sys")
assert payload["stream"] is True
assert len(payload["messages"]) == 2
def test_build_chat_stream_payload_with_thinking():
payload = llm._build_chat_stream_payload("prompt", thinking="high")
assert "options" in payload
assert payload["options"]["think"] == "high"
def test_call_ollama_non_streaming(monkeypatch):
def test_call_ollama_messages_roles_with_system(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["url"] = url
captured["json"] = json
async def fake_chat(**kwargs):
captured["messages"] = kwargs["messages"]
return {"message": {"content": "ok", "thinking": ""}}
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "done"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
monkeypatch.setattr(llm.client, "chat", fake_chat)
result = asyncio.run(
llm.call_ollama("test prompt", system_prompt="sys", tag="t1")
llm.call_ollama(
"user prompt body",
system_prompt="system prompt body",
tag="test",
temperature=0.1,
)
)
assert result["content"] == "done"
assert captured["url"] == "/chat/completions"
assert captured["json"]["stream"] is False
assert result["content"] == "ok"
assert captured["messages"][0]["role"] == "system"
assert captured["messages"][0]["content"] == "system prompt body"
assert captured["messages"][1]["role"] == "user"
assert captured["messages"][1]["content"] == "user prompt body"
def test_stream_ollama_text_deltas(monkeypatch):
def test_call_ollama_messages_roles_without_system(monkeypatch):
captured = {}
def make_lines():
lines_iter = iter([
'data: {"choices": [{"delta": {"content": "hel"}}]}',
'data: {"choices": [{"delta": {"content": "lo"}}]}',
"data: [DONE]",
])
async def fake_chat(**kwargs):
captured["messages"] = kwargs["messages"]
return {"message": {"content": "ok", "thinking": ""}}
class LineIterator:
async def __anext__(self):
try:
return next(lines_iter)
except StopIteration:
raise StopAsyncIteration()
monkeypatch.setattr(llm.client, "chat", fake_chat)
class Response:
def __init__(self2): self2._lines = LineIterator()
result = asyncio.run(
llm.call_ollama(
"user prompt only",
system_prompt="",
tag="test-no-system",
temperature=0.1,
)
)
async def raise_for_status(self2): pass
async def aiter_lines(self2): return self2._lines
class StreamCtx:
async def __aenter__(self2): return Response()
async def __aexit__(*a): pass
class Client:
stream = lambda self2, *args, **kw: StreamCtx()
return Client()
async def fake_client(*args, **kwargs):
captured["called"] = True
return make_lines()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
results = []
async def collect():
async for delta in llm.stream_ollama("prompt", tag="t1"):
results.append(delta)
asyncio.run(collect())
assert captured.get("called") is True
assert results == ["hel", "lo"]
def test_stream_ollama_events_thinking_and_content(monkeypatch):
captured = {}
def make_lines():
lines_iter = iter([
'data: {"choices": [{"delta": {"thinking": "reasoning"}}]}',
'data: {"choices": [{"delta": {"content": "answer"}}]}',
"data: [DONE]",
])
class LineIterator:
async def __anext__(self):
try:
return next(lines_iter)
except StopIteration:
raise StopAsyncIteration()
class Response:
def __init__(self2): self2._lines = LineIterator()
async def raise_for_status(self2): pass
async def aiter_lines(self2): return self2._lines
class StreamCtx:
async def __aenter__(self2): return Response()
async def __aexit__(*a): pass
class Client:
stream = lambda self2, *args, **kw: StreamCtx()
return Client()
async def fake_client(*args, **kwargs):
captured["called"] = True
return make_lines()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
results = []
async def collect():
async for event_type, payload in llm.stream_ollama_events("prompt", tag="t1"):
results.append((event_type, payload))
asyncio.run(collect())
assert captured.get("called") is True
# First event should be thinking, then content
assert results[0] == ("thinking", "")
assert results[1][0] == "content"
def test_call_vlm_ocr(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["url"] = url
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ocr text"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(llm.call_vlm_ocr(b"fake image bytes"))
assert result == "ocr text"
# Verify the payload uses OpenAI vision format (image_url)
assert captured["url"] == "/chat/completions"
messages = captured["json"]["messages"]
assert len(messages) == 1
content_parts = messages[0]["content"]
# Should have text part and image_url part
assert any(p.get("type") == "text" for p in content_parts)
image_part = [p for p in content_parts if p.get("type") == "image_url"]
assert len(image_part) == 1
assert image_part[0]["image_url"]["url"].startswith("data:image/png;base64,")
assert result["content"] == "ok"
assert len(captured["messages"]) == 1
assert captured["messages"][0]["role"] == "user"
assert captured["messages"][0]["content"] == "user prompt only"

View File

@@ -1,225 +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_content_and_thinking():
resp = {"choices": [{"message": {"content": "hello world", "thinking": "reasoning"}}]}
content, thinking = llm._extract_message(resp)
assert content == "hello world"
assert thinking == "reasoning"
def test_extract_message_empty_content():
resp = {"choices": [{"message": {"content": "", "thinking": None}}]}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_message_dict_no_choices():
resp = {"not_choices": []}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_message_empty_dict():
resp = {}
content, thinking = llm._extract_message(resp)
assert content == ""
assert thinking == ""
def test_extract_delta_text_from_chunk():
chunk = {"choices": [{"delta": {"content": "text"}}]}
assert llm._extract_delta_text(chunk) == "text"
def test_extract_delta_thinking_from_chunk():
chunk = {"choices": [{"delta": {"thinking": "thought"}}]}
assert llm._extract_delta_thinking(chunk) == "thought"
def test_call_ollama_no_system(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(
llm.call_ollama("user prompt", system_prompt=None, tag="no-system")
)
assert result["content"] == "ok"
# Should only have user message, no system
assert len(captured["json"]["messages"]) == 1
def test_call_ollama_with_system(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(
llm.call_ollama("user prompt", system_prompt="sys prompt", tag="with-system")
)
assert result["content"] == "ok"
# Should have both system and user messages
msgs = captured["json"]["messages"]
assert len(msgs) == 2
assert msgs[0]["role"] == "system"
def test_call_ollama_with_custom_model(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(
llm.call_ollama("prompt", model="custom-model")
)
assert captured["json"]["model"] == "custom-model"
def test_stream_ollama_events_error_handling(monkeypatch):
def make_lines():
lines_iter = iter([
'data: {"error": "model not found"}',
])
class LineIterator:
async def __anext__(self):
try:
return next(lines_iter)
except StopIteration:
raise StopAsyncIteration()
class Response:
def __init__(self2): self2._lines = LineIterator()
async def raise_for_status(self2): pass
async def aiter_lines(self2): return self2._lines
class StreamCtx:
async def __aenter__(self2): return Response()
async def __aexit__(*a): pass
class Client:
stream = lambda self2, *args, **kw: StreamCtx()
return Client()
async def fake_client(*args, **kwargs):
return make_lines()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
async def collect():
try:
async for _ in llm.stream_ollama_events("prompt", tag="err"):
pass
except RuntimeError as e:
return str(e)
result = asyncio.run(collect())
assert "model not found" in str(result)
def test_call_vlm_ocr_payload_format(monkeypatch):
captured = {}
async def fake_post(url, json=None):
captured["json"] = json
class FakeResp:
def raise_for_status(self): pass
def json(self): return {"choices": [{"message": {"content": "ocr result"}}]}
return FakeResp()
async def fake_client(*args, **kwargs):
class Ctx:
async def __aenter__(self2): return self2
async def __aexit__(*a): pass
post = fake_post
return Ctx()
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
result = asyncio.run(llm.call_vlm_ocr(b"image"))
assert result == "ocr result"
# Verify vision format: image_url content part with base64
msgs = captured["json"]["messages"]
assert len(msgs) == 1
content_parts = msgs[0]["content"]
image_part = [p for p in content_parts if p.get("type") == "image_url"]
assert len(image_part) == 1

View File

@@ -79,10 +79,8 @@ def test_cancel_endpoint_cancels_running_task(monkeypatch):
assert cancelled.wait(timeout=2.0)
completion_response = response_box["response"]
# 499 = client disconnected (TestClient timeout during cancel)
assert completion_response.status_code in (200, 499)
if completion_response.status_code == 200:
assert completion_response.json()["cancelled"] is True
assert completion_response.status_code == 200
assert '"cancelled": true' in completion_response.text
def test_cancel_not_found():
@@ -115,7 +113,6 @@ def test_completion_normal_flow(monkeypatch):
)
assert response.status_code == 200
data = response.json()
assert data["content"] == "completion text"
assert data["request_id"] is not None
assert '"content": "completion text"' in response.text
assert '"done": true' in response.text
assert main.ACTIVE_COMPLETIONS == {}

View File

@@ -1,306 +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
import pro_completions # 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()
pro_completions.PRO_STATES.clear()
yield
main.ACTIVE_COMPLETIONS.clear()
pro_completions.PRO_STATES.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 "![alt](image.png)" not in main._sanitize_converted_markdown(
"text with image ![alt](image.png) 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_sanitize_inline_completion_strips_prefill():
assert main.sanitize_inline_completion_content(
"系统非常适合写作",
prefill="系统",
) == "非常适合写作"
def test_sanitize_inline_completion_extracts_fim_middle():
assert main.sanitize_inline_completion_content(
"<|fim_middle|>系统非常适合写作<|end|>",
prefill="系统",
) == "非常适合写作"
def test_sanitize_inline_completion_extracts_polluted_chat_output():
polluted = (
"on new line? Prefix ends with newline already. The suffix starts with no newline. "
"We need to consider if output should end with newline? The suffix starts with no newline. "
"So we output: \"让我们一起探索 AI 的无限可能。\""
"<|end|><|start|>assistant<|channel|>final|fim_middle|>系统让我们一起探索 AI 的无限可能。"
)
assert main.sanitize_inline_completion_content(
polluted,
prefill="系统",
) == "让我们一起探索 AI 的无限可能。"
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):
captured = {}
async def fake_call(*args, **kwargs):
captured["kwargs"] = 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"
# enable_thinking removed in OpenAI-compatible rewrite
assert captured["kwargs"]["thinking"] == "low"
def test_old_post_pro_stream_returns_404():
client = TestClient(main.app)
resp = client.post("/v1/pro/completions/stream", headers=HEADERS, json={
"prefix": "hello",
"suffix": "",
"languageId": "markdown",
"model_thinking": "high",
"privacy_mode": True,
})
assert resp.status_code == 404
def test_post_pro_completion_returns_sse_and_status(monkeypatch):
captured = {}
async def fake_stream_events(*args, **kwargs):
captured["kwargs"] = kwargs
yield "thinking", ""
yield "content", "深度"
yield "content", "回答"
monkeypatch.setattr(pro_completions, "stream_ollama_events", fake_stream_events)
client = TestClient(main.app)
with client.stream("POST", "/v1/pro/completions", headers=HEADERS, json={
"prefix": "hello",
"suffix": "",
"languageId": "markdown",
"instruction": "expand",
"pro_thinking": "high",
"privacy_mode": True,
}) as resp:
assert resp.status_code == 200
body = "".join(resp.iter_text())
assert "event: queued" in body
assert "event: started" in body
assert "event: thinking" in body
assert "event: chunk" in body
assert "event: done" in body
assert "深度" in body
assert "回答" in body
assert captured["kwargs"]["use_pro_model"] is True
assert captured["kwargs"]["thinking"] == "high"
request_id = next(iter(pro_completions.PRO_STATES))
status_resp = client.get(f"/v1/pro/completions/status/{request_id}", headers=HEADERS)
assert status_resp.status_code == 200
assert status_resp.json()["status"] == "done"
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()

View File

@@ -1,114 +0,0 @@
import os
import sys
import types
import asyncio
import threading
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
import pro_completions # type: ignore
HEADERS = {"X-API-Key": main.API_KEY}
def _payload():
return {
"prefix": "Before",
"suffix": "After",
"languageId": "markdown",
"instruction": "expand",
"pro_thinking": "medium",
"privacy_mode": True,
}
def setup_function():
pro_completions.PRO_STATES.clear()
def teardown_function():
pro_completions.PRO_STATES.clear()
def test_pro_queue_full_returns_429(monkeypatch):
monkeypatch.setattr(pro_completions, "PRO_QUEUE_MAX_SIZE", 0)
client = TestClient(main.app)
response = client.post("/v1/pro/completions", headers=HEADERS, json=_payload())
assert response.status_code == 429
assert response.json()["error"] == "PRO queue is full"
def test_pro_status_missing_returns_404():
client = TestClient(main.app)
response = client.get("/v1/pro/completions/status/missing", headers=HEADERS)
assert response.status_code == 404
def test_pro_prompt_uses_simple_chat_instruction():
system_prompt, user_prompt = pro_completions._build_pro_prompts(
prefix="欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统",
suffix="",
language_id="markdown",
instruction="",
)
combined = f"{system_prompt}\n{user_prompt}".lower()
assert "pro block" not in combined
assert "replacement" not in combined
assert "final answer" not in combined
assert "markdown before cursor" in combined
assert "markdown after cursor" in combined
assert "continue the markdown naturally" in combined
def test_pro_cancel_waits_for_stream_cleanup(monkeypatch):
started = threading.Event()
cleaned = threading.Event()
async def fake_stream_events(*args, **kwargs):
started.set()
try:
yield "thinking", ""
while True:
await asyncio.sleep(0.05)
finally:
cleaned.set()
monkeypatch.setattr(pro_completions, "stream_ollama_events", fake_stream_events)
request_id = "pro-cancel-cleanup"
headers = {**HEADERS, "X-Request-Id": request_id}
response_box = {}
with TestClient(main.app) as client:
def send_stream():
with client.stream("POST", "/v1/pro/completions", headers=headers, json=_payload()) as response:
response_box["status_code"] = response.status_code
response_box["body"] = "".join(response.iter_text())
stream_thread = threading.Thread(target=send_stream, daemon=True)
stream_thread.start()
assert started.wait(timeout=2.0)
cancel_response = client.post(
"/v1/pro/completions/cancel",
headers=HEADERS,
json={"request_id": request_id, "reason": "test"},
)
assert cancel_response.status_code == 200
assert cancel_response.json() == {"cancelled": True, "status": "ok"}
assert cleaned.wait(timeout=2.0)
stream_thread.join(timeout=5.0)
assert not stream_thread.is_alive()

View File

@@ -10,53 +10,26 @@ import prompt # noqa: E402
def test_prompt_builds_system_and_user():
system_prompt, user_prompt, prefill = prompt.build_completion_prompts(
system_prompt, user_prompt = prompt.build_completion_prompts(
prefix="The result is ",
suffix="for this dataset.",
language_id="markdown",
)
assert "inline completion engine" in system_prompt
assert "Hard constraints you must follow" in system_prompt
assert "strict KaTeX-compatible math" in system_prompt
assert "$...$" in system_prompt
assert "$$...$$" in system_prompt
assert "```{language}" in system_prompt
assert "Mermaid" in system_prompt
assert "Mermaid-specific completion rules" in system_prompt
assert "CURSOR_FENCE_LANGUAGE" in system_prompt
assert "MERMAID_CONTEXT" in system_prompt
assert "Output Mermaid statements only." in system_prompt
assert "CURSOR_IN_FENCED_CODE_BLOCK" in user_prompt
assert "CURSOR_FENCE_LANGUAGE" in user_prompt
assert "MERMAID_CONTEXT" in user_prompt
assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt
assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt
assert "actual line breaks" in system_prompt
assert "Use real line breaks instead of spelled-out escape sequences" in user_prompt
assert "Do not explain newline or boundary choices" in user_prompt
assert "Continue after the PREFILL text" in user_prompt
assert "Step 1" not in user_prompt
assert "Does output need" not in user_prompt
assert "assistant" in system_prompt
assert "fim_middle" in system_prompt
assert prefill == ""
assert "start output with \\n" not in user_prompt
assert "Use single \\n" not in system_prompt
def test_completion_prefill_appended_to_fim_middle():
_, user_prompt, prefill = prompt.build_completion_prompts(
prefix="即时可用的 LLM 系统",
suffix="",
)
assert prefill == "系统"
assert user_prompt.endswith("<|fim_middle|>系统")
def test_completion_prefill_empty_after_newline():
_, user_prompt, prefill = prompt.build_completion_prompts(
prefix="即时可用的 LLM 系统\n",
suffix="",
)
assert prefill == ""
assert user_prompt.endswith("<|fim_middle|>")
def test_cursor_in_fence_detection():
@@ -75,7 +48,7 @@ def test_active_fence_language_detection():
def test_newline_flags():
_, user_prompt_a, _ = prompt.build_completion_prompts(
_, user_prompt_a = prompt.build_completion_prompts(
prefix="Hello",
suffix="World",
)
@@ -85,7 +58,7 @@ def test_newline_flags():
assert "PREFIX_ENDS_WITH_NEWLINE: false" in user_prompt_a
assert "SUFFIX_STARTS_WITH_NEWLINE: false" in user_prompt_a
_, user_prompt_b, _ = prompt.build_completion_prompts(
_, user_prompt_b = prompt.build_completion_prompts(
prefix="Hello\n",
suffix="\nWorld",
)
@@ -95,7 +68,7 @@ def test_newline_flags():
def test_mermaid_context_flags():
_, prompt_in_mermaid, _ = prompt.build_completion_prompts(
_, prompt_in_mermaid = prompt.build_completion_prompts(
prefix="```mermaid\nflowchart TD\nA --> ",
suffix="\n```",
)
@@ -103,7 +76,7 @@ def test_mermaid_context_flags():
assert "CURSOR_FENCE_LANGUAGE: mermaid" in prompt_in_mermaid
assert "MERMAID_CONTEXT: true" in prompt_in_mermaid
_, prompt_mermaid_keyword, _ = prompt.build_completion_prompts(
_, prompt_mermaid_keyword = prompt.build_completion_prompts(
prefix="Please draw a mermaid flowchart for deploy pipeline.",
suffix="",
)
@@ -113,6 +86,6 @@ def test_mermaid_context_flags():
def test_examples_coverage():
_, user_prompt, _ = prompt.build_completion_prompts(prefix="", suffix="")
_, user_prompt = prompt.build_completion_prompts(prefix="", suffix="")
for ex in range(1, 15):
assert f"[EX{ex:02d}]" in user_prompt

View File

@@ -1,147 +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]
BACKEND_DIR = ROOT / "backend"
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(BACKEND_DIR))
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, prefill = prompt.build_completion_prompts(
prefix="hello", suffix="world", language_id="markdown",
preferences=UserPrefs(),
)
assert isinstance(system, str)
assert isinstance(user, str)
assert prefill == "hello"
assert "python" in user.lower() or "USD" in user
def test_build_completion_prompts_privacy_mode_location_empty():
system, user, prefill = prompt.build_completion_prompts(
prefix="hello", suffix="world", language_id="markdown",
location="",
)
assert isinstance(system, str)
assert isinstance(user, str)
assert prefill == "hello"
def test_build_prompt_backward_compatibility():
res = prompt.build_prompt(prefix="hello", suffix="world", language_id="markdown")
assert isinstance(res, str)

View File

@@ -1,193 +0,0 @@
import os
import sys
import asyncio
import types
import pytest
from pathlib import Path
from unittest.mock import MagicMock, patch
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
def _make_mlx_stub():
"""Create minimal MLX stub for testing without Apple Silicon"""
mlx = types.SimpleNamespace()
mlx.core = types.SimpleNamespace()
mx_array = type('mx.array', (), {'item': lambda self: 1})
mlx.core.array = mx_array
mlx.nn = types.SimpleNamespace()
return mlx
def _make_mlx_audio_stub():
"""Create minimal mlx-audio stub"""
stt = types.SimpleNamespace()
stt.utils = types.SimpleNamespace()
def mock_load(path, **kwargs):
model = MagicMock()
return model
stt.utils.load = mock_load # type: ignore
qwen3_asr_mod = types.SimpleNamespace()
qwen3_asr_mod.Qwen3ASRModel = type('Qwen3ASRModel', (), {})
qwen3_asr_mod.ForcedAlignerModel = type('ForcedAlignerModel', (), {})
stt.models = types.SimpleNamespace() # type: ignore
stt.models.qwen3_asr = qwen3_asr_mod # type: ignore
audio = types.SimpleNamespace()
audio.stt = stt # type: ignore
return audio
def _reload_tts_asr_with_mocks():
"""Reload tts_asr with mocked MLX dependencies"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
mlx_stub = _make_mlx_stub()
sys.modules['mlx'] = mlx_stub # type: ignore
sys.modules['mlx.core'] = mlx_stub.core # type: ignore
sys.modules['mlx.nn'] = mlx_stub.nn # type: ignore
audio_stub = _make_mlx_audio_stub()
sys.modules['mlx-audio'] = audio_stub # type: ignore
sys.modules['mlx_audio'] = audio_stub # type: ignore
sys.modules['mlx_audio.stt'] = audio_stub.stt # type: ignore
sys.modules['mlx_audio.stt.utils'] = audio_stub.stt.utils # type: ignore
sys.modules['mlx_audio.stt.models'] = audio_stub.stt.models # type: ignore
sys.modules['mlx_audio.stt.models.qwen3_asr'] = audio_stub.stt.models.qwen3_asr # type: ignore
import tts_asr
return tts_asr
@pytest.fixture(autouse=True)
def _clean_env():
"""Clean ASR-related env vars before/after each test"""
saved = {}
for k in ['HF_ENDPOINT']:
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 # type: ignore (unused var)
class TestRequestResponseModels:
"""Pydantic 数据模型测试"""
def test_tts_request_defaults(self):
tts = _reload_tts_asr_with_mocks()
req = tts.TTSRequest(text="hello")
assert req.text == "hello"
assert req.speaker == "Vivian"
def test_asr_request_defaults(self):
tts = _reload_tts_asr_with_mocks()
req = tts.ASRRequest(audio_base64="dGVzdA==")
assert req.audio_base64 == "dGVzdA=="
assert req.language == "zh-CN"
def test_asr_request_custom_language(self):
tts = _reload_tts_asr_with_mocks()
req = tts.ASRRequest(audio_base64="dGVzdA==", language="en")
assert req.language == "en"
def test_model_status_defaults(self):
tts = _reload_tts_asr_with_mocks()
status = tts.ModelStatus(tts_loaded=False, asr_loaded=True, device="cpu")
assert not status.tts_loaded
assert status.asr_loaded
class TestDeviceDetection:
"""设备检测测试"""
def test_device_map_returns_string(self):
tts = _reload_tts_asr_with_mocks()
device = tts._get_device_map()
assert isinstance(device, str)
class TestModelLoading:
"""模型加载测试"""
def test_load_asr_skips_when_mlx_unavailable(self):
"""mlx_audio 未安装时应跳过 ASR"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
# Don't inject mlx stubs — simulate missing MLX
import tts_asr # noqa: F811
assert tts_asr.Qwen3ASRModel is None
tts_asr._load_asr_models() # should not crash
assert tts_asr._asr_model is None
def test_load_asr_from_path_success(self):
tts = _reload_tts_asr_with_mocks()
# Mock snapshot_download to return a path, mock stt_load to succeed
with patch('backend.tts_asr.snapshot_download', return_value='/fake/path'): # type: ignore
tts._load_asr_from_path('/fake/path')
assert tts._asr_model is not None # type: ignore (MagicMock)
class TestWarmupFunctions:
"""预热函数测试"""
def test_warmup_functions_callable(self):
tts = _reload_tts_asr_with_mocks()
assert callable(tts._warmup_tts) # type: ignore (unused var)
assert callable(tts._warmup_all)
def test_warmup_asr_skips_when_mlx_unavailable(self):
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
import tts_asr # noqa: F811
assert tts_asr.Qwen3ASRModel is None
def test_warmup_all_runs_without_error(self):
tts = _reload_tts_asr_with_mocks()
# Set global models so warmup returns immediately without actual loading
tts._tts_model = MagicMock()
async def run(): # type: ignore (unused var)
await tts._warmup_all()
asyncio.get_event_loop().run_until_complete(run()) # type: ignore
class TestRouteRegistration:
"""路由注册测试"""
def test_register_function_exists(self):
tts = _reload_tts_asr_with_mocks()
assert callable(tts.register_tts_asr_routes)
def test_router_prefix(self):
tts = _reload_tts_asr_with_mocks()
assert hasattr(tts.router, 'routes')
class TestModelConstants:
"""模型常量测试"""
def test_asr_model_id(self):
tts = _reload_tts_asr_with_mocks()
assert 'Qwen3-ASR' in tts.ASR_MODEL_ID_MS
def test_align_model_id(self):
tts = _reload_tts_asr_with_mocks()
assert 'ForcedAligner' in tts.ALIGN_MODEL_ID_MS

View File

@@ -1,263 +0,0 @@
import os
import sys
import base64
import io
import types
import wave
import pytest
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
def _make_mlx_stub():
"""Create minimal MLX stub for testing without Apple Silicon"""
mlx = types.SimpleNamespace()
mlx.core = types.SimpleNamespace()
mx_array = type('mx.array', (), {'item': lambda self: 1})
mlx.core.array = mx_array
def mock_load(path):
return MagicMock()
mlx.core.load = mock_load # type: ignore
mlx.nn = types.SimpleNamespace()
return mlx
def _make_mlx_audio_stub():
"""Create minimal mlx-audio stub"""
stt = types.SimpleNamespace()
stt.utils = types.SimpleNamespace()
def mock_load(path): # type: ignore
model = MagicMock()
output = types.SimpleNamespace()
output.text = "识别结果"
output.language = "zh-CN"
model.generate = MagicMock(return_value=output)
return model
stt.utils.load = mock_load # type: ignore
qwen3_asr_mod = types.SimpleNamespace()
qwen3_asr_mod.Qwen3ASRModel = type('Qwen3ASRModel', (), {})
qwen3_asr_mod.ForcedAlignerModel = type('ForcedAlignerModel', (), {})
stt.models = types.SimpleNamespace() # type: ignore
stt.models.qwen3_asr = qwen3_asr_mod # type: ignore
audio = types.SimpleNamespace()
audio.stt = stt # type: ignore
return audio
def _reload_tts_asr_with_mocks():
"""Reload tts_asr with mocked MLX dependencies"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
mlx_stub = _make_mlx_stub()
sys.modules['mlx'] = mlx_stub # type: ignore
sys.modules['mlx.core'] = mlx_stub.core # type: ignore
sys.modules['mlx.nn'] = mlx_stub.nn # type: ignore
audio_stub = _make_mlx_audio_stub()
sys.modules['mlx-audio'] = audio_stub # type: ignore
sys.modules['mlx_audio'] = audio_stub # type: ignore
sys.modules['mlx_audio.stt'] = audio_stub.stt # type: ignore
sys.modules['mlx_audio.stt.utils'] = audio_stub.stt.utils # type: ignore
sys.modules['mlx_audio.stt.models'] = audio_stub.stt.models # type: ignore
sys.modules['mlx_audio.stt.models.qwen3_asr'] = audio_stub.stt.models.qwen3_asr # type: ignore
import tts_asr
return tts_asr, audio_stub
@pytest.fixture(autouse=True)
def _clean_env():
"""Clean ASR-related env vars before/after each test"""
saved = {}
for k in ['HF_ENDPOINT']:
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 # type: ignore
def _make_wav_bytes(sr=16000, duration_sec=1.0, channels=1):
"""Helper: generate WAV bytes as base64"""
samples = int(sr * duration_sec)
audio = np.random.randint(-32768, 32767, size=samples * channels, dtype=np.int16)
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(channels)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(audio.tobytes())
return base64.b64encode(buf.getvalue()).decode()
class TestASRLazyLoading:
"""测试 ASR 模型懒加载"""
def test_ensure_asr_loads_on_call(self):
tts, audio_stub = _reload_tts_asr_with_mocks()
assert tts._asr_model is None
model = tts._ensure_asr_model()
assert model is not None
def test_ensure_align_loads_on_call(self):
tts, audio_stub = _reload_tts_asr_with_mocks()
assert tts._align_model is None
model = tts._ensure_align_model()
assert model is not None
class TestASREndpoint:
"""测试 ASR 端点逻辑"""
def test_asr_basic_recognition(self, fastapi_testclient=None):
"""ASR 端点应正确返回识别结果"""
tts, _ = _reload_tts_asr_with_mocks()
# Mock the model to return known values
tts._asr_model = MagicMock()
output = types.SimpleNamespace()
output.text = "你好世界"
output.language = "zh-CN"
tts._asr_model.generate.return_value = output
wav_b64 = _make_wav_bytes()
req = tts.ASRRequest(audio_base64=wav_b64)
# Call generate directly (simulating endpoint logic)
audio_bytes = base64.b64decode(req.audio_base64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
raw = wf.readframes(wf.getnframes())
arr = np.frombuffer(raw, dtype=np.int16)
arr = arr.astype(np.float32) / 32768.0
result = tts._asr_model.generate(arr, language=req.language)
assert result.text == "你好世界"
def test_asr_stereo_to_mono(self):
"""立体声音频应被正确转换为单声道"""
wav_b64 = _make_wav_bytes(channels=2)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
assert wf.getnchannels() == 2
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16)
# Convert to mono
audio_array = np.mean(audio_array.reshape(-1, 2), axis=1)
assert audio_array.ndim == 1
def test_asr_resample_to_16k(self):
"""非 16kHz 音频应被重采样"""
wav_b64 = _make_wav_bytes(sr=48000, duration_sec=0.5)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
assert wf.getframerate() == 48000
def test_asr_44100_resample(self):
"""44.1kHz 常见采样率应被重采样到 16k"""
wav_b64 = _make_wav_bytes(sr=44100, duration_sec=1.0)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16)
# Simulate resample calculation
if framerate != 16000:
n_samples = int(len(audio_array) * 16000 / framerate)
else:
n_samples = len(audio_array)
expected_16k_samples = int(1.0 * 16000)
assert abs(n_samples - expected_16k_samples) < 2
class TestASRModelDownload:
"""测试 ASR 模型下载路径"""
def test_load_asr_from_path_success(self):
tts, _ = _reload_tts_asr_with_mocks()
with patch('backend.tts_asr.snapshot_download', return_value='/fake/asr'): # type: ignore
tts._load_asr_models()
assert tts._asr_model is not None
def test_load_asr_skips_without_mlx(self):
"""不注入 MLX stub 时应跳过 ASR"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
import tts_asr # noqa: F811
assert tts_asr.Qwen3ASRModel is None
def test_load_align_from_path(self):
tts, _ = _reload_tts_asr_with_mocks()
with patch('backend.tts_asr.snapshot_download', return_value='/fake/align'): # type: ignore
tts._load_asr_models()
assert tts._align_model is not None
class TestModelConstants:
"""测试模型 ID 常量"""
def test_asr_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "aufklarer" in tts.ASR_MODEL_ID_MS
def test_align_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "ForcedAligner" in tts.ALIGN_MODEL_ID_MS
def test_tts_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "Qwen3-TTS" in tts.MODEL_ID_MS
class TestHFEndpointMirror:
"""测试镜像站配置"""
def test_hf_endpoint_set(self):
tts, _ = _reload_tts_asr_with_mocks()
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"
def test_hf_endpoint_default(self):
"""即使环境变量未设置,模块也应默认设置镜像"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
if "HF_ENDPOINT" in os.environ:
del os.environ["HF_ENDPOINT"]
import tts_asr # noqa: F811
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"

View File

@@ -1,305 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
TTS/ASR模块集成测试 — MLX/Qwen3-ASR 版本
测试API端点和完整流程需要运行后端服务
运行方式:
pytest backend/tests/test_tts_asr_integration.py -v -s
python backend/tests/test_tts_asr_integration.py --test asr
MLX 模型通过 ModelScope (aufklarer/Qwen3-ASR) + ForcedAligner
"""
import argparse
import base64
import io
import os
import sys
import time
import unittest
from typing import Optional
try:
import httpx # type: ignore
except ImportError:
print("httpx 未安装,跳过集成测试")
sys.exit(1)
import numpy as np
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
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: # noqa: ANN001
cls.service_available = False
print(f"\n✗ 无法连接到服务: {e}")
@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('device', config)
self.assertIn('model', config)
self.assertIn('status', config)
model = config['model']
status = config['status']
self.assertIn('tts', model)
self.assertIn('asr', model)
print(f"\n配置信息:")
print(f" TTS模型: {model['tts']}")
print(f" ASR模型: {model.get('asr', 'N/A')}")
print(f" TTS已加载: {status['tts_loaded']}")
print(f" ASR已加载: {status['asr_loaded']}")
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)
print(f"\n状态信息:")
print(f" TTS已加载: {status['tts_loaded']}")
print(f" ASR已加载: {status['asr_loaded']}")
print(f" 设备: {status['device']}")
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)
print(f"\n预热完成 (耗时: {elapsed:.2f}秒):")
print(f" TTS预热: {'成功' if result['tts_warmup'] else '失败'}")
print(f" ASR预热: {'成功' if result.get('asr_warmup') else '失败/跳过'}")
if not result['tts_warmup'] or not result.get('asr_warmup'):
print("\n⚠ 警告: 预热失败可能是因为模型未下载")
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}
)
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)
print(f"\nTTS测试成功:")
print(f" 输入文本: {test_text}")
print(f" 音频大小: {len(audio_data)} bytes")
def test_05_asr_endpoint_basic(self):
"""测试ASR基本功能"""
sample_rate = 16000
duration = 1.0
samples = int(sample_rate * duration)
silence = np.zeros(samples, dtype=np.int16)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wf: # noqa: SIM115
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()
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 in (500, 501):
detail = response.json().get('detail', 'Unknown')
print(f"\n⚠ ASR失败: {detail}")
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']}")
def test_06_api_key_validation(self):
"""测试API密钥验证"""
wrong_headers = {'X-API-Key': 'wrong-api-key'}
response = self.client.get(
f'{API_BASE_URL}/v1/tts-asr/status',
headers=wrong_headers,
)
self.assertEqual(response.status_code, 403)
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 Exception: # noqa: ANN001, S110
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延迟"""
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': '测试延迟'}
)
elapsed = time.time() - start
if response.status_code == 200:
latencies.append(elapsed)
if latencies:
print(f"\nTTS延迟测试:")
print(f" 平均: {sum(latencies)/len(latencies):.3f}s")
print(f" 最小: {min(latencies):.3f}s / 最大: {max(latencies):.3f}s")
def run_tests(test_type: Optional[str] = None) -> bool:
"""运行测试"""
loader = unittest.TestLoader()
suite = unittest.TestSuite()
TEST_MAP = {
'config': ('TTSASRIntegrationTest', 'test_01_config_endpoint'),
'status': ('TTSASRIntegrationTest', 'test_02_status_endpoint'),
'warmup': ('TTSASRIntegrationTest', 'test_03_warmup_endpoint'),
'tts': ('TTSASRIntegrationTest', 'test_04_tts_endpoint_basic'),
'asr': ('TTSASRIntegrationTest', 'test_05_asr_endpoint_basic'),
'perf': ('PerformanceTest', None),
}
if test_type and test_type in TEST_MAP:
cls_name, method = TEST_MAP[test_type]
if method:
suite.addTest(globals()[cls_name](method))
else:
suite.addTests(loader.loadTestsFromTestCase(globals()[cls_name]))
elif test_type == 'api_key':
suite.addTest(TTSASRIntegrationTest('test_06_api_key_validation'))
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__':
parser = argparse.ArgumentParser(description='TTS/ASR 集成测试')
parser.add_argument('--test', choices=['config', 'status', 'warmup', 'tts', 'asr', 'perf', 'api_key'])
parser.add_argument('--url', default=API_BASE_URL)
parser.add_argument('--key', default=API_KEY)
args = parser.parse_args()
API_BASE_URL = args.url
API_KEY = args.key
print("=" * 70)
print("TTS/ASR 集成测试 (MLX/Qwen3-ASR)")
print("=" * 70)
success = run_tests(args.test)
sys.exit(0 if success else 1)

View File

@@ -1,156 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
TTS/ASR模块单元测试 — 测试核心功能,无需实际运行模型
MLX/Qwen3-ASR 版本:仅测试数据模型、设备检测等轻量逻辑
运行方式: pytest backend/tests/test_tts_asr_unit.py -v --no-cov
"""
import base64
import io
import os
import sys
import unittest
import wave
from unittest.mock import patch, MagicMock
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__), '..')))
import numpy as np
class TestRequestResponseModels(unittest.TestCase):
"""测试请求/响应数据模型"""
def test_asr_request_defaults(self):
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_asr_request_with_language(self):
from backend.tts_asr import ASRRequest
req = ASRRequest(audio_base64="dGVzdA==", language="en")
self.assertEqual(req.language, "en")
def test_asr_response(self):
from backend.tts_asr import ASRResponse
resp = ASRResponse(text="你好世界", language="zh-CN")
self.assertEqual(resp.text, "你好世界")
self.assertEqual(resp.language, "zh-CN")
def test_tts_request_defaults(self):
from backend.tts_asr import TTSRequest
req = TTSRequest(text="测试文本")
self.assertEqual(req.text, "测试文本")
self.assertEqual(req.speaker, "Vivian")
self.assertEqual(req.format, "wav")
def test_model_status(self):
from backend.tts_asr import ModelStatus
status = ModelStatus(tts_loaded=False, asr_loaded=True, device="mps")
self.assertFalse(status.tts_loaded)
self.assertTrue(status.asr_loaded)
self.assertEqual(status.device, "mps")
class TestDeviceDetection(unittest.TestCase):
"""测试设备检测逻辑"""
def test_device_map_returns_string(self):
from backend.tts_asr import _get_device_map
device = _get_device_map()
self.assertIsInstance(device, str)
class TestAudioDecoding(unittest.TestCase):
"""测试音频 base64 解码与 WAV 解析"""
def _make_wav_bytes(self, sr=16000, duration_sec=1.0):
samples = int(sr * duration_sec)
audio = np.random.randint(-32768, 32767, size=samples, dtype=np.int16)
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(audio.tobytes())
return buf.getvalue()
def test_decode_valid_wav(self):
"""有效 WAV 应能正常解码"""
wav_bytes = self._make_wav_bytes()
audio_b64 = base64.b64encode(wav_bytes).decode()
decoded = base64.b64decode(audio_b64)
wav_buffer = io.BytesIO(decoded)
with wave.open(wav_buffer, 'rb') as wf:
self.assertEqual(wf.getframerate(), 16000)
self.assertEqual(wf.getnchannels(), 1)
def test_decode_empty_raises(self):
"""空 base64 解码后 wave.open 应抛出异常"""
decoded = base64.b64decode("")
self.assertEqual(decoded, b"") # Python 3: empty base64 -> empty bytes
wav_buffer = io.BytesIO(decoded)
with self.assertRaises(Exception):
wave.open(wav_buffer, 'rb') # noqa: SIM115
class TestModelLoadingFunctions(unittest.TestCase):
"""测试模型加载函数存在性(不实际下载)"""
@patch.object(sys.modules.get('backend.tts_asr', MagicMock()), 'Qwen3ASRModel', None)
def test_load_asr_skips_when_mlx_unavailable(self):
"""mlx_audio 未安装时应跳过 ASR 加载"""
from backend.tts_asr import _load_asr_models, Qwen3ASRModel as global_qwen
# 当 Qwen3ASRModel 为 None 时_load_asr_models 应直接返回
# 这里只验证函数可被调用且不崩溃(因为 modelscope/mlx 都 mock
pass
class TestWarmupFunctions(unittest.TestCase):
"""测试预热函数存在性"""
def test_warmup_functions_exist(self):
from backend.tts_asr import _warmup_tts, _warmup_all
self.assertTrue(callable(_warmup_tts))
self.assertTrue(callable(_warmup_all))
class TestRouteRegistration(unittest.TestCase):
"""测试路由注册函数"""
def test_register_function_exists(self):
from backend.tts_asr import register_tts_asr_routes
self.assertTrue(callable(register_tts_asr_routes))
def run_tests():
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for cls in (TestRequestResponseModels, TestDeviceDetection,
TestAudioDecoding, TestModelLoadingFunctions,
TestWarmupFunctions, TestRouteRegistration):
suite.addTests(loader.loadTestsFromTestCase(cls))
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)

View File

@@ -1,490 +0,0 @@
import asyncio
import base64
import io
import logging
import os
import tempfile
import wave
from typing import Optional
# 设置 Hugging Face / ModelScope 镜像源为国内镜像
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 as e: # pragma: no cover
logger.debug("qwen_tts import failed (optional): %s", e)
Qwen3TTSModel = None # type: ignore
# ASR model import (MLX-based, Apple Silicon only)
try:
from mlx_audio.stt.models.qwen3_asr import ( # type: ignore
ForcedAlignerModel,
Qwen3ASRModel,
)
except Exception as e: # pragma: no cover
logger.debug("mlx_audio import failed (optional): %s", e)
Qwen3ASRModel = None # type: ignore
ForcedAlignerModel = None # type: ignore
try:
from modelscope import snapshot_download # type: ignore
except Exception as e: # pragma: no cover
logger.debug("modelscope import failed (optional): %s", e)
router = APIRouter()
# Global model instances
_tts_model: Optional["Qwen3TTSModel"] = None
_asr_model: Optional[object] = None # Qwen3ASRModel or ForcedAlignerModel
_align_model: Optional[object] = None # Qwen3-ForcedAlignerModel
# Model paths for loading
MODEL_ID_HF = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
# ModelScope ASR/ForcedAligner models (MLX 4-bit format)
ASR_MODEL_ID_MS = "aufklarer/Qwen3-ASR-0.6B-MLX-4bit"
ALIGN_MODEL_ID_MS = "aufklarer/Qwen3-ForcedAligner-0.6B-MLX"
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: # noqa: ANN001
logger.debug("MPS check failed: %s", e)
return "cpu"
def _download_model_from_modelscope() -> Optional[str]:
"""从 ModelScope 下载模型到本地缓存目录"""
try:
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: # noqa: ANN001
logger.warning("ModelScope 下载失败: %s", e)
return None
async def _warmup_tts():
"""预热 TTS 模型"""
await asyncio.to_thread(_load_tts_model_with_retry)
async def _warmup_asr():
"""预热 ASR 模型(从 ModelScope 下载并加载)"""
await asyncio.to_thread(_load_asr_models)
async def _warmup_all():
"""预热所有模型TTS 和 ASR"""
logger.info("[Warmup] 开始预热 TTS 模型...")
await _warmup_tts()
logger.info("[Warmup] TTS 模型预热完成")
if Qwen3ASRModel is not None:
logger.info("[Warmup] 开始预热 ASR 模型...")
await _warmup_asr()
logger.info("[Warmup] ASR 模型预热完成")
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 下载 TTS 模型...")
model_path = _download_model_from_modelscope()
if model_path and os.path.isdir(model_path):
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
model_path,
device_map=device_map,
dtype=torch.float16,
)
logger.info("ModelScope TTS 模型加载成功: %s", model_path)
return _tts_model
except Exception as e: # noqa: ANN001
logger.warning("ModelScope TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
last_err = e
# 策略2: 尝试从 HuggingFace 镜像加载
for attempt in range(max_retries):
try:
logger.info("尝试从 HuggingFace 镜像加载 TTS...")
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
MODEL_ID_HF,
device_map=device_map,
dtype=torch.float16,
)
logger.info("HuggingFace TTS 模型加载成功")
return _tts_model
except Exception as e: # noqa: ANN001
logger.warning("HuggingFace TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
last_err = e
raise RuntimeError(f"无法加载 TTS 模型: {last_err}") from last_err
def _load_asr_models() -> None:
"""从 ModelScope 下载并加载 ASR/ForcedAligner MLX 模型"""
global _asr_model, _align_model
if snapshot_download is None:
logger.warning("modelscope 未安装,跳过 ASR 模型加载")
return
if Qwen3ASRModel is None:
logger.warning("mlx_audio 未安装,跳过 ASR 模型加载")
return
# Download and load ASR model from ModelScope
try:
logger.info("从 ModelScope 下载 ASR 模型...")
asr_cache_dir = os.path.join(os.path.dirname(__file__), "models", "asr")
asr_model_dir = snapshot_download(ASR_MODEL_ID_MS, cache_dir=asr_cache_dir)
_load_asr_from_path(asr_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ASR ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_asr_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ASR hf-mirror 加载失败,跳过 ASR: %s", e2)
# Download and load ForcedAligner model from ModelScope
try:
logger.info("从 ModelScope 下载 ForcedAligner 模型...")
align_cache_dir = os.path.join(os.path.dirname(__file__), "models", "aligner")
align_model_dir = snapshot_download(ALIGN_MODEL_ID_MS, cache_dir=align_cache_dir)
_load_align_from_path(align_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ForcedAligner ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_align_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ForcedAligner hf-mirror 加载失败,跳过: %s", e2)
def _load_asr_from_path(model_dir: str) -> None:
"""从本地路径加载 ASR MLX 模型"""
global _asr_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load(model_dir)
_asr_model = model
logger.info("ASR 模型加载成功 (路径: %s)", model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ASR MLX 加载失败,尝试直接构建: %s", e)
try:
from mlx.core import load as mx_load # type: ignore
weights = mx_load(os.path.join(model_dir, "model.safetensors"))
from mlx_lm import load as lm_load # type: ignore
model = lm_load(model_dir, model_cls=Qwen3ASRModel)
_asr_model = model
except Exception as e2: # noqa: ANN001
raise RuntimeError(f"无法加载 ASR MLX 模型: {e2}") from e
def _load_asr_from_hf_mirror() -> None:
"""从 hf-mirror 加载 ASR MLX 模型"""
global _asr_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ASR-0.6B-4bit")
_asr_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ASR MLX: {e}") from e
def _load_align_from_path(model_dir: str) -> None:
"""从本地路径加载 ForcedAligner MLX 模型"""
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load(model_dir)
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ForcedAligner MLX 模型 (路径: {model_dir}): {e}") from e
def _load_align_from_hf_mirror() -> None:
"""从 hf-mirror 加载 ForcedAligner MLX 模型"""
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ForcedAligner-0.6B-4bit")
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ForcedAligner MLX: {e}") from e
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 ASRRequest(BaseModel):
audio_base64: str
language: Optional[str] = "zh-CN"
class ASRResponse(BaseModel):
text: str
language: Optional[str] = None
class ModelStatus(BaseModel):
tts_loaded: bool
asr_loaded: bool = False
device: str
def _ensure_tts_model() -> "Qwen3TTSModel":
"""确保 TTS 模型已加载"""
global _tts_model
if _tts_model is None:
_tts_model = _load_tts_model_with_retry()
return _tts_model
def _ensure_asr_model():
"""确保 ASR 模型已加载(懒加载)"""
global _asr_model
if _asr_model is None:
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
_asr_model = stt_load(ASR_MODEL_ID_MS)
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ASR MLX 模型 (路径: {ASR_MODEL_ID_MS}): {e}") from e
return _asr_model
def _ensure_align_model():
"""确保 ForcedAligner 模型已加载(懒加载)"""
global _align_model
if _align_model is None:
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
_align_model = stt_load(ALIGN_MODEL_ID_MS)
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ForcedAligner MLX 模型 (路径: {ALIGN_MODEL_ID_MS}): {e}") from e
return _align_model
@router.get("/status", response_model=ModelStatus)
async def get_status():
"""获取模型状态"""
return ModelStatus(
tts_loaded=_tts_model is not None,
asr_loaded=_asr_model is not None,
device=_get_device_map(),
)
@router.get("/config")
async def get_config():
"""获取配置信息"""
return {
"model": {
"tts": MODEL_ID_MS,
"asr": ASR_MODEL_ID_MS if Qwen3ASRModel is not None else None,
},
"device": _get_device_map(),
"status": {
"tts_loaded": _tts_model is not None,
"asr_loaded": _asr_model is not None,
}
}
@router.post("/warmup")
async def warmup_models():
"""手动触发模型预热"""
await _warmup_tts()
if Qwen3ASRModel is not None:
await _warmup_asr()
return {
"tts_warmup": _tts_model is not None,
"asr_warmup": _asr_model is not None if Qwen3ASRModel else False,
"device": _get_device_map(),
}
@router.post("/tts", response_model=TTSResponse)
async def tts_endpoint(req: TTSRequest):
"""TTS 文字转语音端点"""
try:
model = _ensure_tts_model()
except Exception as e: # noqa: ANN001
raise HTTPException(status_code=500, detail=str(e))
text = req.text
instruct = req.instruct or ""
try:
# VoiceDesign 模型使用 generate_voice_design 方法
wavs, sr = model.generate_voice_design( # type: ignore
text=text,
language="Chinese",
instruct=instruct,
)
except Exception as e: # noqa: ANN001
logger.exception("TTS 推理失败")
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}")
# Get first audio data
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
# Convert to numpy array
if hasattr(wav_data, 'numpy'): # type: ignore
wav_data = wav_data.cpu().numpy() # type: ignore
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)
# Encode WAV to memory
tmp_path = None
try:
import soundfile as sf # type: ignore
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
os.close(fd) # type: ignore
sf.write(tmp_path, wav_data, sr)
with open(tmp_path, "rb") as f: # noqa: SIM115
audio_bytes = f.read()
except Exception as e: # noqa: ANN001
logger.exception("音频编码失败")
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}")
finally:
if tmp_path and os.path.exists(tmp_path): # noqa: SIM201
try:
os.unlink(tmp_path)
except Exception as e: # noqa: ANN001
pass
duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
return TTSResponse(
audio_base64=audio_base64,
format="wav",
duration_ms=duration_ms,
)
@router.post("/asr", response_model=ASRResponse)
async def asr_endpoint(req: ASRRequest):
"""语音识别端点(非流式)"""
if Qwen3ASRModel is None:
raise HTTPException(status_code=501, detail="mlx_audio 未安装ASR 功能不可用")
try:
model = _ensure_asr_model()
except Exception as e: # noqa: ANN001
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {e}")
try:
# Decode base64 audio to WAV bytes
audio_bytes = base64.b64decode(req.audio_base64)
# Load WAV file and convert to 16kHz mono numpy array
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf: # noqa: SIM115
n_channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16 if sampwidth == 2 else np.float32)
# Convert to mono
if n_channels > 1:
audio_array = np.mean(audio_array.reshape(-1, n_channels), axis=1)
# Resample to 16kHz if needed
if framerate != 16000:
try:
import scipy.signal as signal # type: ignore
n_samples = int(len(audio_array) * 16000 / framerate)
audio_array = signal.resample(audio_array, n_samples) # type: ignore
except Exception as e2: # noqa: ANN001
logger.warning("重采样失败,使用原始音频: %s", e2)
# Convert to float32 normalized
if audio_array.dtype == np.int16:
audio_array = audio_array.astype(np.float32) / 32768.0
# Run ASR inference (non-streaming)
result = model.generate( # type: ignore
audio_array,
language=req.language if req.language else None,
)
# Extract text and detected language from result (STTOutput)
recognized_text = getattr(result, 'text', str(result)) if hasattr(result, 'text') else str(result)
detected_lang = getattr(result, 'language', req.language or "zh-CN")
# If language is a list (from segments), take the first one
if isinstance(detected_lang, list) and len(detected_lang) > 0:
detected_lang = detected_lang[0]
return ASRResponse(
text=recognized_text,
language=str(detected_lang),
)
except Exception as e: # noqa: ANN001
logger.exception("ASR 推理失败")
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {e}")
def register_tts_asr_routes(app):
"""注册 TTS/ASR 路由到 FastAPI 应用"""
app.include_router(router, prefix="/v1/tts-asr")

3504
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,32 +11,24 @@
"check": "npm run build"
},
"dependencies": {
"@blocknote/xl-docx-exporter": "^0.47.3",
"@milkdown/core": "^7.18.0",
"@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0",
"@milkdown/theme-nord": "^7.18.0",
"@milkdown/vue": "^7.18.0",
"docx": "^9.6.0",
"docx-preview": "^0.3.7",
"docx2pdf-converter": "^2.1.1",
"html2pdf.js": "^0.14.0",
"jspdf": "^4.2.1",
"katex": "^0.16.9",
"markdown-it": "^13.0.0",
"markdown-it-math": "^3.0.2",
"mermaid": "^11.12.3",
"pinia": "^2.3.1",
"prismjs": "^1.29.0",
"tui-color-picker": "^2.2.8",
"tui-image-editor": "^3.15.3",
"vue": "^3.5.24",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/language-server": "^3.2.6",
"vite": "^7.2.4"
}
}

View File

@@ -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.

View File

@@ -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/。

View File

@@ -1,10 +1,19 @@
<script setup>
import { computed } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import { useSettingsStore } from './stores/settings'
import SettingsPanel from './components/SettingsPanel.vue'
const MilkdownEditor = defineAsyncComponent(() => import('./components/MilkdownEditor.vue'))
const markdown = ref('')
const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
function onChange(markdownValue) {
markdown.value = markdownValue
emit('update:markdown', markdownValue)
}
const appStyle = computed(() => {
const style = {}
if (settings.backgroundType === 'warm') {
@@ -42,8 +51,10 @@ const backgroundStyle = computed(() => {
<template>
<div class="app-shell" :style="appStyle">
<div v-if="settings.backgroundType === 'image'" class="app-bg-layer" :style="backgroundStyle"></div>
<router-view />
<div class="editor-container">
<MilkdownEditor @update:markdown="onChange" />
</div>
<SettingsPanel />
</div>
@@ -53,10 +64,17 @@ const backgroundStyle = computed(() => {
.app-shell {
position: relative;
width: 100%;
height: 100vh;
height: 100%;
background: var(--app-bg);
color: var(--app-text);
transition: background 0.3s, color 0.3s;
isolation: isolate;
}
.editor-container {
position: relative;
z-index: 1;
height: 100%;
}
</style>

View File

@@ -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>

View File

@@ -1,354 +1,250 @@
<template>
<section class="doc-card" :class="{ 'is-collapsed': collapsedState }">
<header class="doc-card__header">
<div class="doc-card__badge">{{ typeLabel }}</div>
<div class="doc-card__meta">
<div class="doc-card__name">{{ docName }}</div>
<div class="doc-card__time">{{ displayTime }}</div>
<div class="doc-block-crepe" :class="{ collapsed: isCollapsed }">
<div class="doc-header">
<div class="doc-icon">
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M9 15v-2h6v2"/>
<path d="M12 13v4"/>
</svg>
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M10 9H8"/>
</svg>
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8"/>
<path d="M12 17v4"/>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
</svg>
</div>
<div class="doc-card__actions">
<button type="button" class="doc-card__btn" :title="collapsedState ? '展开文件' : '折叠文件'" @click="toggleCollapse">
<svg v-if="collapsedState" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div class="doc-name">{{ docName }}</div>
<div class="doc-actions">
<button @click="downloadDoc" class="action-btn" title="下载文档">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button @click="toggleCollapse" class="action-btn collapse-btn" :title="isCollapsed ? '展开' : '折叠'">
<svg v-if="isCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<button type="button" class="doc-card__btn doc-card__btn--danger" title="删除文件" @click="props.onDelete?.()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M8 6V4h8v2"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
</svg>
</button>
</div>
</header>
<div v-show="!collapsedState" class="doc-card__body">
<div ref="editorRoot" class="doc-card__editor"></div>
</div>
</section>
<div class="doc-editor" v-show="!isCollapsed">
<div ref="editorRoot" class="inner-crepe"></div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { replaceAll } from '@milkdown/kit/utils'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView } from '../plugins/hiddenTextPlugin'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, setCopilotEnabled } from '../plugins/copilotPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
const props = defineProps({
docType: { type: String, default: 'txt' },
docType: { type: String, default: 'text' },
docName: { type: String, default: 'document.txt' },
uploadTime: { type: String, default: '' },
content: { type: String, default: '' },
collapsed: { type: Boolean, default: false },
resolveSuggestionRequest: { type: Function, default: null },
onUpdateContent: { type: Function, default: null },
onUpdateCollapsed: { type: Function, default: null },
onDelete: { type: Function, default: null },
initialContent: { type: String, default: '' }
})
const emit = defineEmits(['update:content', 'delete'])
const editorRoot = ref(null)
const collapsedState = ref(Boolean(props.collapsed))
const currentContent = ref(props.content || '')
const isCollapsed = ref(false)
let crepe = null
let syncTimer = null
let syncingExternal = false
const typeLabel = computed(() => {
if (props.docType === 'docx') return 'DOCX'
if (props.docType === 'pptx') return 'PPTX'
if (props.docType === 'pdf') return 'PDF'
return 'TXT'
})
const displayTime = computed(() => {
if (!props.uploadTime) return '刚上传'
const date = new Date(props.uploadTime)
if (Number.isNaN(date.getTime())) return '刚上传'
return date.toLocaleString('zh-CN', { hour12: false })
})
let internalChangeTimer = null
const toggleCollapse = () => {
collapsedState.value = !collapsedState.value
props.onUpdateCollapsed?.(collapsedState.value)
isCollapsed.value = !isCollapsed.value
}
const downloadDoc = () => {
if (!crepe) return
crepe.getMarkdown().then(markdown => {
const blob = new Blob([markdown], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.docName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
})
}
const syncContent = () => {
if (!crepe) return
// Skip content sync when tab is hidden (energy saving for nested editors)
if (!isDocumentVisible()) return
if (syncTimer) clearTimeout(syncTimer)
const syncInterval = getRecommendedSyncInterval(120)
syncTimer = setTimeout(async () => {
if (!crepe || syncingExternal) return
if (internalChangeTimer) clearTimeout(internalChangeTimer)
internalChangeTimer = setTimeout(async () => {
const markdown = await crepe.getMarkdown()
currentContent.value = markdown
props.onUpdateContent?.(markdown)
}, syncInterval)
emit('update:content', markdown)
}, 120)
}
const syncExternalContent = async (nextValue) => {
const value = nextValue || ''
if (!crepe) {
currentContent.value = value
return
}
if (value === currentContent.value) return
syncingExternal = true
try {
crepe.editor.action(replaceAll(value))
currentContent.value = value
} finally {
syncingExternal = false
}
}
watch(() => props.content, (nextValue) => {
void syncExternalContent(nextValue)
})
watch(() => props.collapsed, (nextValue) => {
collapsedState.value = Boolean(nextValue)
})
onMounted(async () => {
if (!editorRoot.value) return
crepe = new Crepe({
root: editorRoot.value,
defaultValue: props.content || '',
defaultValue: props.initialContent || '',
features: {
[Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Table]: true,
[Crepe.Feature.ListCheck]: true,
},
config: {
showLineNumber: false,
},
config: { showLineNumber: false }
})
crepe.editor.config((ctx) => {
crepe.editor.config(ctx => {
ctx.set(copilotConfigCtx.key, {
fetchSuggestion: async (prefix, suffix, languageId, signal) => {
const payload = props.resolveSuggestionRequest
? await props.resolveSuggestionRequest({ prefix, suffix, languageId })
: { prefix, suffix, languageId, blocked: false }
if (payload?.blocked) return ''
return fetchSuggestion(payload?.prefix ?? prefix, payload?.suffix ?? suffix, payload?.languageId ?? languageId, signal)
},
debounceMs: getRecommendedDebounce(900),
fetchSuggestion,
debounceMs: 1000
})
})
crepe.editor.use(copilotConfigCtx)
crepe.editor.use(copilotGhostMark)
crepe.editor.use(copilotPlugin)
crepe.editor.use(hiddenTextRemark)
crepe.editor.use(hiddenTextNode)
crepe.editor.use(hiddenTextView)
crepe.editor.use(hiddenTextInputPlugin)
await crepe.create()
crepe.on((listener) => {
crepe.on(listener => {
listener.updated(() => {
syncContent()
})
})
crepe.editor.action((ctx) => {
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, true)
})
})
onUnmounted(() => {
if (syncTimer) {
clearTimeout(syncTimer)
syncTimer = null
}
if (crepe) {
crepe.editor.action((ctx) => {
watch(() => props.initialContent, (newVal) => {
if (crepe && newVal !== undefined) {
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
clearGhostSuggestion(view)
const currentPos = view.state.selection.from
view.dispatch(view.state.tr.insertText(newVal))
})
}
})
onUnmounted(() => {
if (internalChangeTimer) clearTimeout(internalChangeTimer)
if (crepe) {
crepe.destroy()
crepe = null
}
})
defineExpose({
getContent: () => crepe ? crepe.getMarkdown() : Promise.resolve(''),
getEditor: () => crepe
})
</script>
<style scoped>
.doc-card {
width: 100%;
max-width: 100%;
margin: 8px 0;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.12);
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
overflow: hidden;
backdrop-filter: blur(10px);
position: relative;
}
:root[data-theme='dark'] .doc-card {
background: rgba(26, 30, 39, 0.8);
border-color: rgba(96, 165, 250, 0.15);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
}
.doc-card__header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
background: rgba(255, 255, 255, 0.8);
}
:root[data-theme='dark'] .doc-card__header {
background: rgba(26, 30, 39, 0.8);
border-bottom-color: rgba(96, 165, 250, 0.15);
}
.doc-card__badge {
min-width: 48px;
padding: 4px 10px;
border-radius: 999px;
background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);
color: #fff;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-align: center;
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.2);
}
.doc-card__meta {
min-width: 0;
}
.doc-card__name {
color: #1e293b;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:root[data-theme='dark'] .doc-card__name {
color: #e5e7eb;
}
.doc-card__time {
margin-top: 2px;
color: #64748b;
font-size: 10px;
}
:root[data-theme='dark'] .doc-card__time {
color: #aeb6c5;
}
.doc-card__actions {
display: flex;
gap: 4px;
}
.doc-card__btn {
width: 26px;
height: 26px;
border: 1px solid rgba(59, 130, 246, 0.12);
.doc-block-crepe {
margin: 12px 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.5);
color: #64748b;
overflow: hidden;
background: var(--crepe-color-surface-low);
border: 1px solid var(--panel-border);
}
.doc-block-crepe.collapsed .doc-editor {
display: none;
}
.doc-header {
display: flex;
align-items: center;
padding: 10px 12px;
background: var(--crepe-color-surface);
border-bottom: 1px solid var(--panel-border);
gap: 10px;
}
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
color: var(--crepe-color-primary);
}
:root[data-theme='dark'] .doc-card__btn {
background: rgba(34, 40, 52, 0.5);
border-color: rgba(96, 165, 250, 0.15);
color: #aeb6c5;
}
.doc-card__btn:hover {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.25);
color: #3b82f6;
}
.doc-card__btn--danger:hover {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.doc-card__body {
padding: 8px 10px;
background: rgba(248, 250, 252, 0.8);
}
:root[data-theme='dark'] .doc-card__body {
background: rgba(18, 22, 30, 0.8);
}
.doc-card__editor {
min-height: 48px;
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.08);
background: rgba(255, 255, 255, 0.8);
.doc-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--crepe-color-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:root[data-theme='dark'] .doc-card__editor {
background: rgba(26, 30, 39, 0.8);
border-color: rgba(96, 165, 250, 0.12);
.doc-actions {
display: flex;
align-items: center;
gap: 4px;
}
.doc-card__editor :deep(.milkdown) {
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--crepe-color-on-surface-variant);
cursor: pointer;
border-radius: 4px;
opacity: 0.7;
}
.action-btn:hover {
background: var(--crepe-color-hover);
opacity: 1;
}
.doc-editor {
padding: 8px;
background: var(--crepe-color-surface-low);
min-height: 120px;
max-height: 400px;
overflow-y: auto;
}
.inner-crepe {
width: 100%;
height: 100%;
}
.inner-crepe :deep(.milkdown) {
background: transparent !important;
}
.doc-card__editor :deep(.milkdown__main),
.doc-card__editor :deep(.milkdown__editor) {
margin: 0 !important;
padding: 0 !important;
}
.doc-card__editor :deep(.ProseMirror) {
min-height: 0;
padding: 10px 12px 12px !important;
font-size: 13px !important;
line-height: 1.6;
}
.doc-card__editor :deep(.ProseMirror > *:last-child) {
margin-bottom: 0;
}
.doc-card__editor :deep(.ProseMirror p:first-child) {
margin-top: 0;
}
.doc-card__editor :deep(.milkdown__toolbar),
.doc-card__editor :deep(.milkdown__menu),
.doc-card__editor :deep(.milkdown__statusbar),
.doc-card__editor :deep(.milkdown-slate-toolbar),
.doc-card__editor :deep(.milkdown-bubble-menu) {
display: none !important;
.inner-crepe :deep(.ProseMirror) {
min-height: 80px;
padding: 8px !important;
}
</style>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -7,7 +7,6 @@ import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { hiddenTextMarkdownItPlugin } from '../utils/hiddenText.js'
const props = defineProps({
content: {
@@ -22,8 +21,6 @@ const md = new MarkdownIt({
typographer: true
})
md.use(hiddenTextMarkdownItPlugin)
// 预处理 markdown转换 $...$ 为 <span class="math-inline">...</span>
const preprocessLatex = (text) => {
// 处理 $$...$$ 块级公式

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -1,627 +0,0 @@
<template>
<section class="pro-block-shell" :class="[`is-${stage}`, { 'is-busy': isBusy, 'has-content': Boolean(displayContent) }]">
<button
v-if="isIdle"
type="button"
class="pro-block-capsule"
:title="title"
@mousedown.stop.prevent
@click.stop="handleActivate"
>
<span class="pro-block-orbit" aria-hidden="true"></span>
<span class="pro-block-badge">PRO</span>
<span class="pro-block-label">{{ t('proStart') || '开始 Pro 生成' }}</span>
</button>
<div v-else class="pro-block-panel">
<div class="pro-block-header">
<div class="pro-block-heading">
<span class="pro-block-orbit" aria-hidden="true"></span>
<div>
<h3>{{ title }}</h3>
<p>{{ statusText }}</p>
</div>
</div>
<div class="pro-block-status">
<span v-if="isThinking" class="pro-thinking-pill">
<span class="pro-spinner" aria-hidden="true"></span>
{{ t('proThinking') || '思考中' }}
</span>
<span v-else-if="isBusy" class="pro-spinner" aria-hidden="true"></span>
</div>
</div>
<div class="pro-instruction">
<button type="button" class="pro-fold-btn" @mousedown.stop.prevent @click.stop="handleToggleInstruction">
<span>{{ instructionExpanded ? (t('proHideInstruction') || '隐藏指令') : (t('proShowInstruction') || '查看指令') }}</span>
<span class="pro-chevron" :class="{ open: instructionExpanded }"></span>
</button>
<Transition name="pro-fold">
<div v-if="instructionExpanded" class="pro-instruction-editor">
<textarea
:value="instruction"
:disabled="isBusy"
:placeholder="t('proInstructionPlaceholder') || '输入隐藏 Pro 指令'"
@input="handleInstructionInput"
@mousedown.stop
@keydown.stop
></textarea>
</div>
</Transition>
</div>
<div ref="bodyRef" class="pro-block-body" @scroll="handleBodyScroll">
<Transition name="pro-thinking-fade">
<div v-if="isThinking" class="pro-thinking-row">
<span class="pro-spinner" aria-hidden="true"></span>
<span>{{ t('proThinking') || '思考中' }}</span>
</div>
</Transition>
<div v-if="errorMessage" class="pro-error-box">
{{ errorMessage }}
</div>
<div v-if="!displayContent" class="pro-block-placeholder">
<strong>{{ placeholderTitle }}</strong>
<span>{{ placeholderText }}</span>
</div>
<div v-else class="pro-block-preview">
<MarkdownPreview :content="displayContent" />
</div>
</div>
<div class="pro-block-footer">
<div class="pro-version-controls" v-if="versionCount > 1">
<button type="button" class="pro-icon-btn" :disabled="!canPrevVersion" @mousedown.stop.prevent @click.stop="handlePrevVersion"></button>
<span>{{ activeVersion + 1 }} / {{ versionCount }}</span>
<button type="button" class="pro-icon-btn" :disabled="!canNextVersion" @mousedown.stop.prevent @click.stop="handleNextVersion"></button>
</div>
<div class="pro-block-actions">
<button v-if="isBusy" type="button" class="pro-text-btn" @mousedown.stop.prevent @click.stop="handleCancel">{{ t('cancel') || '取消' }}</button>
<button v-else type="button" class="pro-text-btn" @mousedown.stop.prevent @click.stop="handleDiscard">{{ t('discard') || '放弃' }}</button>
<button
v-if="!isBusy"
type="button"
class="pro-secondary-btn"
@mousedown.stop.prevent
@click.stop="handleRedo"
>
{{ t('proRetry') || '重试' }}
</button>
<button
v-if="!isBusy"
type="button"
class="pro-primary-btn"
:disabled="!canAccept"
@mousedown.stop.prevent
@click.stop="handleAccept"
>
{{ t('accept') || '接受' }}
</button>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import MarkdownPreview from './MarkdownPreview.vue'
const props = defineProps({
stage: { type: String, default: 'idle' },
previewContent: { type: String, default: '' },
activeContent: { type: String, default: '' },
errorMessage: { type: String, default: '' },
isBusy: { type: Boolean, default: false },
isThinking: { type: Boolean, default: false },
title: { type: String, default: 'PRO 模式' },
instruction: { type: String, default: '' },
instructionExpanded: { type: Boolean, default: false },
activeVersion: { type: Number, default: 0 },
versionCount: { type: Number, default: 0 },
canAccept: { type: Boolean, default: false },
activateAction: { type: Function, default: null },
cancelAction: { type: Function, default: null },
discardAction: { type: Function, default: null },
redoAction: { type: Function, default: null },
acceptAction: { type: Function, default: null },
prevVersionAction: { type: Function, default: null },
nextVersionAction: { type: Function, default: null },
toggleInstructionAction: { type: Function, default: null },
updateInstructionAction: { type: Function, default: null },
t: { type: Function, default: (key) => key },
})
const bodyRef = ref(null)
const shouldAutoScroll = ref(true)
const isIdle = computed(() => props.stage === 'idle' && !props.previewContent && !props.activeContent && !props.errorMessage)
const displayContent = computed(() => props.previewContent || props.activeContent || '')
const canPrevVersion = computed(() => props.activeVersion > 0)
const canNextVersion = computed(() => props.activeVersion < props.versionCount - 1)
const t = (key) => props.t?.(key)
const statusText = computed(() => {
if (props.stage === 'queued') return t('proQueued') || '正在排队等待'
if (props.stage === 'thinking') return t('proThinking') || '思考中'
if (props.stage === 'streaming') return t('proStreaming') || '正在生成'
if (props.stage === 'error') return t('proError') || '生成失败'
if (props.stage === 'cancelled') return t('proCancelled') || '已取消,可接受当前草稿'
return t('proDone') || '生成完成'
})
const placeholderTitle = computed(() => {
if (props.stage === 'queued') return t('proQueued') || '正在排队'
if (props.stage === 'thinking') return t('proThinking') || '思考中'
if (props.stage === 'error') return t('proError') || '生成失败'
return t('proWaiting') || '等待 Pro 生成'
})
const placeholderText = computed(() => {
if (props.stage === 'queued') return t('proQueuedHint') || '轮到此请求后会自动开始。'
if (props.stage === 'thinking') return t('proThinkingHint') || '模型正在思考,真实 thinking 内容已隐藏。'
if (props.stage === 'error') return t('proRetryHint') || '请重试,或调整上下文和指令。'
return t('proWaitingHint') || '点击开始生成,结果会在这里流式展开。'
})
const handleBodyScroll = () => {
const el = bodyRef.value
if (!el) return
shouldAutoScroll.value = el.scrollTop + el.clientHeight >= el.scrollHeight - 24
}
watch(
() => props.previewContent,
async () => {
if (!shouldAutoScroll.value) return
await nextTick()
const el = bodyRef.value
if (el) el.scrollTop = el.scrollHeight
}
)
const handleActivate = () => props.activateAction?.()
const handleCancel = () => props.cancelAction?.()
const handleDiscard = () => props.discardAction?.()
const handleRedo = () => props.redoAction?.()
const handleAccept = () => props.acceptAction?.()
const handlePrevVersion = () => props.prevVersionAction?.()
const handleNextVersion = () => props.nextVersionAction?.()
const handleToggleInstruction = () => props.toggleInstructionAction?.()
const handleInstructionInput = (event) => props.updateInstructionAction?.(event.target.value)
</script>
<style scoped>
.pro-block-shell {
width: 100%;
margin: 22px 0;
color: var(--app-text);
}
.pro-block-capsule,
.pro-block-panel {
position: relative;
overflow: hidden;
isolation: isolate;
}
.pro-block-capsule {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 11px 18px;
border: 1px solid rgba(56, 189, 248, 0.58);
border-radius: 999px;
background: linear-gradient(135deg, #08111f 0%, #143a63 52%, #3b1d74 100%);
color: #ffffff;
box-shadow: 0 14px 32px rgba(14, 165, 233, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.14);
cursor: pointer;
touch-action: manipulation;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.pro-block-capsule:hover {
transform: translateY(-1px);
box-shadow: 0 20px 42px rgba(14, 165, 233, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
}
.pro-block-panel {
border: 1px solid rgba(14, 165, 233, 0.32);
border-radius: 8px;
background:
radial-gradient(circle at 20% 0%, rgba(56, 189, 248, 0.22), transparent 30%),
radial-gradient(circle at 80% 0%, rgba(168, 85, 247, 0.18), transparent 32%),
color-mix(in srgb, var(--panel-bg) 88%, #07111f 12%);
box-shadow: 0 20px 50px rgba(2, 8, 23, 0.18);
}
.pro-block-panel::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
transform: translateX(-100%);
animation: pro-scan 2.8s ease-in-out infinite;
z-index: -1;
}
.pro-block-header {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 18px 18px 10px;
}
.pro-block-heading {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.pro-block-heading h3 {
margin: 0;
font-size: 16px;
font-weight: 750;
}
.pro-block-heading p {
margin: 3px 0 0;
color: var(--muted-text);
font-size: 13px;
}
.pro-block-orbit {
width: 20px;
height: 20px;
border-radius: 999px;
background: conic-gradient(from 90deg, #38bdf8, #a855f7, #22c55e, #38bdf8);
box-shadow: 0 0 18px rgba(56, 189, 248, 0.45);
animation: pro-spin 1.8s linear infinite;
flex: 0 0 auto;
}
.pro-block-badge {
font-size: 12px;
font-weight: 850;
letter-spacing: 0.08em;
}
.pro-block-label {
font-size: 14px;
font-weight: 700;
}
.pro-block-status,
.pro-thinking-pill {
display: inline-flex;
align-items: center;
gap: 8px;
}
.pro-thinking-pill {
padding: 6px 10px;
border: 1px solid rgba(56, 189, 248, 0.22);
border-radius: 999px;
color: var(--app-text);
background: rgba(56, 189, 248, 0.08);
font-size: 12px;
}
.pro-spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(56, 189, 248, 0.18);
border-top-color: #38bdf8;
animation: pro-spin 0.8s linear infinite;
flex: 0 0 auto;
}
.pro-instruction {
padding: 0 18px 10px;
}
.pro-fold-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
padding: 5px 0;
background: transparent;
color: var(--muted-text);
cursor: pointer;
font-size: 12px;
}
.pro-chevron {
transition: transform 0.16s ease;
}
.pro-chevron.open {
transform: rotate(180deg);
}
.pro-instruction-editor textarea {
width: 100%;
min-height: 72px;
resize: vertical;
border: 1px solid rgba(56, 189, 248, 0.22);
border-radius: 8px;
padding: 10px 12px;
background: color-mix(in srgb, var(--app-bg) 88%, transparent);
color: var(--app-text);
outline: none;
font: inherit;
-webkit-overflow-scrolling: touch;
}
.pro-block-body {
max-height: min(52vh, 520px);
overflow: auto;
padding: 0 18px 14px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.pro-thinking-row,
.pro-error-box,
.pro-block-placeholder,
.pro-block-preview {
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: color-mix(in srgb, var(--app-bg) 82%, transparent);
}
.pro-thinking-row {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 10px;
color: var(--muted-text);
font-size: 13px;
}
.pro-error-box {
margin-bottom: 10px;
padding: 10px 12px;
border-color: rgba(239, 68, 68, 0.28);
color: #dc2626;
}
.pro-block-placeholder {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px;
color: var(--muted-text);
}
.pro-block-placeholder strong {
color: var(--app-text);
}
.pro-block-preview {
overflow: hidden;
}
.pro-block-preview :deep(.preview-container) {
height: auto;
min-height: 0;
padding: 16px 18px;
overflow: visible;
background: transparent;
color: var(--app-text);
}
.pro-block-preview :deep(.preview-container p),
.pro-block-preview :deep(.preview-container li),
.pro-block-preview :deep(.preview-container blockquote),
.pro-block-preview :deep(.preview-container td),
.pro-block-preview :deep(.preview-container th) {
color: inherit;
}
.pro-block-preview :deep(.preview-container blockquote) {
border-left-color: rgba(148, 163, 184, 0.5);
color: var(--muted-text);
}
.pro-block-preview :deep(.preview-container code),
.pro-block-preview :deep(.preview-container pre) {
background: color-mix(in srgb, var(--app-bg) 78%, var(--app-text) 8%);
color: var(--app-text);
}
.pro-block-preview :deep(.preview-container th),
.pro-block-preview :deep(.preview-container td) {
border-color: rgba(148, 163, 184, 0.35);
}
.pro-block-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 18px;
}
.pro-version-controls,
.pro-block-actions {
display: flex;
align-items: center;
gap: 8px;
}
.pro-version-controls {
color: var(--muted-text);
font-size: 12px;
}
.pro-icon-btn,
.pro-text-btn,
.pro-secondary-btn,
.pro-primary-btn {
min-height: 34px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.24);
background: color-mix(in srgb, var(--app-bg) 82%, transparent);
color: var(--app-text);
cursor: pointer;
touch-action: manipulation;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.pro-icon-btn {
width: 34px;
font-size: 20px;
}
.pro-text-btn,
.pro-secondary-btn,
.pro-primary-btn {
padding: 0 14px;
}
.pro-primary-btn {
border-color: rgba(56, 189, 248, 0.58);
background: linear-gradient(135deg, #0284c7, #7c3aed);
color: #fff;
}
.pro-secondary-btn:hover:not(:disabled),
.pro-text-btn:hover:not(:disabled),
.pro-icon-btn:hover:not(:disabled),
.pro-primary-btn:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(56, 189, 248, 0.5);
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.pro-fold-enter-active,
.pro-fold-leave-active,
.pro-thinking-fade-enter-active,
.pro-thinking-fade-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.pro-fold-enter-from,
.pro-fold-leave-to,
.pro-thinking-fade-enter-from,
.pro-thinking-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
@keyframes pro-spin {
to { transform: rotate(360deg); }
}
@keyframes pro-scan {
0%, 40% { transform: translateX(-100%); opacity: 0; }
55% { opacity: 1; }
100% { transform: translateX(100%); opacity: 0; }
}
@media (max-width: 640px) {
.pro-block-shell {
margin: 14px 0;
}
.pro-block-capsule {
width: 100%;
justify-content: center;
min-height: 48px;
}
.pro-block-panel {
width: 100%;
}
.pro-block-header,
.pro-block-footer {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.pro-block-header {
padding: 14px 14px 8px;
}
.pro-instruction {
padding: 0 14px 8px;
}
.pro-instruction-editor textarea {
min-height: 96px;
font-size: 16px;
}
.pro-block-actions {
display: grid;
grid-template-columns: 1fr 1fr;
width: 100%;
}
.pro-block-body {
max-height: 42vh;
padding: 0 14px 12px;
}
.pro-block-preview :deep(.preview-container) {
padding: 12px;
overflow-wrap: anywhere;
}
.pro-block-footer {
padding: 0 14px 14px;
}
.pro-version-controls {
justify-content: center;
}
.pro-icon-btn,
.pro-text-btn,
.pro-secondary-btn,
.pro-primary-btn {
min-height: 44px;
width: 100%;
}
.pro-text-btn {
grid-column: span 1;
}
}
@media (prefers-reduced-motion: reduce) {
.pro-block-panel::before,
.pro-block-orbit,
.pro-spinner {
animation: none;
}
.pro-block-capsule,
.pro-icon-btn,
.pro-text-btn,
.pro-secondary-btn,
.pro-primary-btn,
.pro-chevron {
transition: none;
}
.pro-block-body {
scroll-behavior: auto;
}
}
</style>

View File

@@ -1,14 +1,11 @@
<script setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme'
import packageJson from '../../package.json'
const store = useSettingsStore()
const { setTheme } = useTheme()
const router = useRouter()
const route = useRoute()
const VERSION = packageJson.version || '0.0.0'
@@ -132,11 +129,6 @@ const handleImageUpload = (event) => {
}
const t = (key) => store.t[key]
const switchView = (view) => {
router.push(view === 'editor' ? '/' : '/docs')
closePanel()
}
</script>
<template>
@@ -202,52 +194,6 @@ const switchView = (view) => {
v-model.number="store.backgroundOpacity"
class="range-slider"
/>
</div>
</section>
<!-- View Switch -->
<section class="settings-section">
<h3>{{ t('view') || '视图' }}</h3>
<div class="view-switch">
<button
class="view-btn"
:class="{ active: route.path === '/' }"
@click="switchView('editor')"
>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
<span>{{ t('editor') || '编辑器' }}</span>
</button>
<button
class="view-btn"
:class="{ active: route.path === '/docs' }"
@click="switchView('docs')"
>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span>{{ t('docs') || '文档' }}</span>
</button>
</div>
</section>
<section class="settings-section">
<h3>{{ t('proMode') || 'PRO 模式' }}</h3>
<div class="form-group">
<label>{{ t('proThinkingLevel') || t('thinkingLevel') }}</label>
<div class="segment-control">
<button :class="{ active: store.proThinking === 'low' }" @click="store.proThinking = 'low'">{{ t('low') }}</button>
<button :class="{ active: store.proThinking === 'medium' }" @click="store.proThinking = 'medium'">{{ t('medium') }}</button>
<button :class="{ active: store.proThinking === 'high' }" @click="store.proThinking = 'high'">{{ t('high') }}</button>
</div>
<p class="help-text">{{ t('proThinkingDesc') || 'PRO 模式使用独立思考强度,普通补全设置不会影响它。' }}</p>
</div>
</section>
@@ -320,27 +266,12 @@ const switchView = (view) => {
<option value="AUD">AUD ($)</option>
<option value="CAD">CAD ($)</option>
</select>
</div>
</section>
</div>
</section>
<!-- TTS Settings -->
<section class="settings-section">
<h3>{{ t('ttsSettings') || '语音设置' }}</h3>
<div class="form-group">
<label>{{ t('voiceInstruction') || '声音描述' }}</label>
<input
type="text"
v-model="store.ttsInstruct"
class="select-input"
:placeholder="t('voiceInstructionPlaceholder') || '例如:用温柔的语气说'"
/>
<p class="help-text">{{ t('voiceInstructionDesc') || '描述你想要的声音风格,如语气、情感等' }}</p>
</div>
</section>
<!-- About -->
<section class="settings-section">
<h3>{{ t('about') }}</h3>
<!-- About -->
<section class="settings-section">
<h3>{{ t('about') }}</h3>
<div class="about-card">
<h4>llm-in-text</h4>
<p>A smart Markdown editor with local LLM intelligence.</p>
@@ -388,7 +319,6 @@ const switchView = (view) => {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--panel-border);
border-radius: 0 8px 8px 0;
box-shadow: var(--panel-shadow);
z-index: 10000;
transform: translateX(-100%);
@@ -419,11 +349,8 @@ const switchView = (view) => {
/* Mobile Fullscreen */
@media (max-width: 640px) {
.settings-panel {
top: 0;
bottom: 0;
width: 100%;
border-right: none;
border-radius: 0;
}
}
@@ -457,7 +384,6 @@ const switchView = (view) => {
flex: 1;
overflow-y: auto;
padding: 20px;
min-height: 0;
}
.settings-section {
@@ -636,37 +562,5 @@ const switchView = (view) => {
font-size: 0.8rem;
opacity: 0.7;
}
.view-switch {
display: flex;
gap: 8px;
}
.view-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--panel-border);
background: var(--ghost-code-bg);
color: var(--muted-text);
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.view-btn:hover {
color: var(--app-text);
border-color: var(--focus-ring);
}
.view-btn.active {
background: var(--app-bg);
color: var(--app-text);
border-color: var(--focus-ring);
font-weight: 600;
}
</style>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -1,71 +0,0 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* Reactive document visibility state.
* Returns { isVisible: Ref<boolean> } that tracks Document.hidden.
*/
export function useVisibility() {
const isVisible = ref(!document.hidden)
onMounted(() => {
const onChange = () => {
isVisible.value = !document.hidden
}
document.addEventListener('visibilitychange', onChange)
onUnmounted(() => {
document.removeEventListener('visibilitychange', onChange)
})
})
return { isVisible }
}
/**
* Synchronous check for document visibility (non-reactive).
*/
export function isDocumentVisible() {
return !document.hidden
}
/**
* Detect if the current device is likely mobile (touch + narrow viewport).
*/
export function isMobileDevice() {
if (typeof window === 'undefined') return false
return (
('ontouchstart' in window) &&
window.matchMedia('(max-width: 768px)').matches
)
}
/**
* Get the recommended debounce for AI completion based on device.
*/
export function getRecommendedDebounce(baseMs) {
if (isMobileDevice()) return Math.max(baseMs, 2000)
return baseMs
}
/**
* Get the recommended markdown sync interval based on device.
*/
export function getRecommendedSyncInterval(baseMs) {
if (isMobileDevice()) return Math.max(baseMs, 300)
return baseMs
}
/**
* Initialize global visibility listener that adds/removes 'hidden-tab' class on <html>.
* This allows CSS rules to pause animations and reduce GPU work when the tab is hidden.
*/
export function initVisibilityListener() {
if (typeof document === 'undefined') return
const updateClass = () => {
document.documentElement.classList.toggle('hidden-tab', document.hidden)
}
updateClass() // Initial state
document.addEventListener('visibilitychange', updateClass)
}

View File

View File

View File

@@ -1,33 +0,0 @@
{
"common": {
"copy": "Copier",
"paste": "Coller",
"delete": "Supprimer",
"save": "Enregistrer",
"cancel": "Annuler",
"confirm": "Confirmer",
"error": "Erreur",
"success": "Succès",
"loading": "Chargement...",
"settings": "Paramètres"
},
"editor": {
"uploadFile": "Importer un fichier",
"uploadDoc": "Télécharger document",
"exportDisabledHint": "L'export DOCX/PDF est temporairement indisponible.",
"uploadDocTypeWarning": "Seuls les formats txt, json, toml, yaml, docx, pptx, pdf sont pris en charge.",
"uploadDocSizeWarning": "La taille du fichier ne peut pas dépasser 10 Mo.",
"uploadDocInBlockWarning": "Impossible d'insérer un document à l'intérieur d'un bloc existant.",
"uploadDocError": "Échec de la conversion du document :",
"uploadFileTypeWarning": "Type de fichier non pris en charge. Supporté : doc/docx/ppt/pptx/pdf/zip, images, txt/json.",
"uploadMdTypeWarning": "Seuls les fichiers Markdown(.md) et images sont pris en charge.",
"uploadFileError": "Échec du téléchargement",
"uploadConvertError": "Échec de la conversion",
"uploadBatchLimit": "Maximum 10 fichiers à la fois.",
"uploadSizeLimit": "Fichier dépasse la limite de 50 Mo.",
"uploading": "Téléchargement en cours...",
"enableAI": "Activer IA",
"disableAI": "Désactiver IA",
"insertUrl": "Insérer image via URL"
}
}

View File

View File

@@ -1,18 +1,19 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import { initVisibilityListener } from './composables/useVisibility.js'
// Initialize visibility listener for energy saving (pause animations when tab hidden)
initVisibilityListener()
import './style.css'
import { createPinia } from 'pinia'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// Service worker registration failed, silently ignore
})
})
}

View File

@@ -1,71 +0,0 @@
# Plugins 插件层指引
本文件适用于 src/plugins/ 下的插件代码。
## 插件层职责
- 承接 Milkdown/ProseMirror 与业务逻辑之间的粘合层。
- 负责 AI 补全 ghost text、文档块节点、Mermaid 预览等编辑器级行为。
- 这里的代码状态性很强,误改后通常会出现“看起来还能编译,但交互细节坏掉”的问题。
## 文件分工
- copilotPlugin.tsAI 补全主逻辑,包含 ghost text、请求调度、取消、语言识别、隐藏上下文注入和大小限制。
- docBlockPlugin.ts文档块节点、remark 适配和视图逻辑。
- mermaidPlugin.tsMermaid 代码块预览和渲染。
- 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 测试。
- 只有在确认插件本身是控制点时,才在这里改;很多行为实际上是在上层组件里决定的。

View File

@@ -4,17 +4,13 @@ import { parserCtx, serializerCtx } from '@milkdown/kit/core'
import { Node as ProseNode, Slice } from '@milkdown/prose/model'
import type { Ctx } from '@milkdown/kit/core'
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 { isDocumentVisible } from '../composables/useVisibility.js'
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
const DEBOUNCE_MS = 1000
const SIZE_LIMIT = OCR_SIZE_LIMIT
const DOC_SIZE_LIMIT = 32 * 1024 // 文档块32KB限制
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
const FALLBACK_LEAF_TEXT = '\n'
interface CopilotState {
from: number
@@ -185,6 +181,10 @@ function normalizeSuggestionText(raw: string): string {
}
}
if (!text.includes('\n') && text.includes('\\n')) {
text = text.replace(/\\n/g, '\n')
}
return text
}
@@ -307,7 +307,7 @@ function serializeRangeToMarkdown(
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)
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, '\n', '\n')
}
function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
@@ -328,7 +328,32 @@ function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
})
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(
@@ -342,9 +367,6 @@ function doFetchSuggestion(
) {
const config = runtime.ctx.get(copilotConfigCtx.key)
// Skip AI completion when tab is hidden (energy saving)
if (!isDocumentVisible()) return
if (runtime.abortController) {
runtime.abortController.abort('superseded')
runtime.abortController = null
@@ -391,26 +413,26 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
try {
prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, serializer)
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)
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 {
prefixMarkdown = doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
suffixMarkdown = doc.textBetween(pos, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
prefixMarkdown = doc.textBetween(0, pos, '\n', '\n')
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
}
// 构建上下文OCR内容 + 上传文档内容
const ocrContext = buildOcrContextForRequest(doc, pos)
// 从markdown中提取文档块内容用于AI补全上下文
const docContext = extractDocBlockContextFromMarkdown(prefixMarkdown + suffixMarkdown, 500)
const docContext = extractDocBlocksFromMarkdown(prefixMarkdown + suffixMarkdown)
// 组合所有上下文到prefix前面
const fullPrefixWithContext = [ocrContext, docContext, prefixMarkdown].filter(Boolean).join('\n\n')
const fullPrefixWithContext = `${ocrContext}${docContext}\n\n${prefixMarkdown}`
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length
@@ -721,12 +743,7 @@ export function interruptCopilot(view: EditorView): void {
}
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
let size = view.state.doc.content.size
view.state.doc.descendants((node) => {
if (node.type.name === 'doc_block' && node.attrs.content) {
size += String(node.attrs.content).length
}
})
const size = view.state.doc.content.size
return { size, overLimit: size > SIZE_LIMIT }
}

View File

@@ -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)
}

View File

@@ -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
},
})
})

View File

@@ -1,4 +1,4 @@
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import mermaid from 'mermaid'
let mermaidReadyTheme = ''
@@ -24,37 +24,14 @@ function ensureMermaid() {
const theme = getMermaidTheme()
if (mermaidReadyTheme === theme) return
const dark = theme === 'dark'
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
mermaid.initialize({
startOnLoad: false,
theme: dark ? 'dark' : 'base',
theme: theme || (dark ? 'dark' : 'default'),
securityLevel: 'loose',
fontFamily: 'inherit',
flowchart: { htmlLabels: false },
themeVariables: dark ? {
primaryColor: '#1e2d45',
primaryTextColor: '#c9d6e8',
primaryBorderColor: '#3b5278',
lineColor: '#5a7aa8',
secondaryColor: '#162236',
tertiaryColor: '#0f1926',
edgeLabelBackground: '#1a2a40',
clusterBkg: '#111e2e',
titleColor: '#c9d6e8',
nodeBorder: '#3b5278',
mainBkg: '#1e2d45',
} : {
primaryColor: '#e8f0fe',
primaryTextColor: '#1e3a5f',
primaryBorderColor: '#93b4d9',
lineColor: '#4a7cb5',
secondaryColor: '#dbeafe',
tertiaryColor: '#f0f7ff',
edgeLabelBackground: '#f0f7ff',
clusterBkg: '#f5f8ff',
titleColor: '#1e3a5f',
nodeBorder: '#93b4d9',
mainBkg: '#e8f0fe',
flowchart: {
htmlLabels: false,
},
})
mermaidReadyTheme = theme
@@ -92,26 +69,17 @@ function makeMermaidFilename() {
}
function buildMermaidPreviewMarkup(code: string, token: number) {
// 剥离首尾的 ```mermaid 或 ``` 标识符,防止其被误认为图表节点
const cleanCode = code
.replace(/^```[a-z]*\s*\n?/i, '')
.replace(/\n?```\s*$/i, '')
.trim()
const encoded = encodeMermaidCode(cleanCode)
const encoded = encodeMermaidCode(code)
const filename = makeMermaidFilename()
const zoomSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`
const dlSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`
return `
<div class="mermaid-block" data-mermaid-code="${encoded}" data-mermaid-token="${token}">
<div class="mermaid-controls">
<button type="button" class="mermaid-action-btn mermaid-zoom-btn" data-mermaid-action="zoom" disabled aria-label="放大查看">${zoomSvg}</button>
<button type="button" class="mermaid-action-btn mermaid-download-btn" data-mermaid-action="download" data-mermaid-filename="${filename}" disabled aria-label="下载图表">${dlSvg}<span>下载</span></button>
<button type="button" class="mermaid-action-btn" data-mermaid-action="zoom" disabled>Zoom</button>
<button type="button" class="mermaid-action-btn" data-mermaid-action="download" data-mermaid-filename="${filename}" disabled>Download PNG</button>
</div>
<div class="mermaid-inner">
<div class="mermaid-loading"><span></span><span></span><span></span></div>
<div class="mermaid-loading">...</div>
</div>
</div>`.trim()
}
@@ -134,6 +102,7 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
}
if (action === 'download') {
node.textContent = 'Download PNG'
if (payload) {
node.removeAttribute('disabled')
node.setAttribute('data-mermaid-url', payload.downloadUrl || payload.previewUrl)
@@ -161,7 +130,6 @@ function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload
}
function getSvgSize(svg: string) {
// 优先读取 viewBox (取第三、四个值作为宽高)
const viewBox = svg.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
if (viewBox) {
const width = Number(viewBox[1])
@@ -171,16 +139,14 @@ function getSvgSize(svg: string) {
}
}
// 次选读取数字像素属性
const widthAttr = svg.match(/\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/i)
const heightAttr = svg.match(/\bheight\s*=\s*["']([\d.]+)(?:px)?["']/i)
const attrW = widthAttr ? Number(widthAttr[1]) : 0
const attrH = heightAttr ? Number(heightAttr[1]) : 0
if (attrW > 0 && attrH > 0) {
return { width: attrW, height: attrH }
const widthAttr = svg.match(/width\s*=\s*["']([-\d.]+)(px)?["']/i)
const heightAttr = svg.match(/height\s*=\s*["']([-\d.]+)(px)?["']/i)
const width = widthAttr ? Number(widthAttr[1]) : 960
const height = heightAttr ? Number(heightAttr[1]) : 540
return {
width: Number.isFinite(width) && width > 0 ? width : 960,
height: Number.isFinite(height) && height > 0 ? height : 540,
}
return { width: 960, height: 540 }
}
function svgToDataUrl(svg: string) {
@@ -193,38 +159,6 @@ function stripExternalSvgResources(svg: string) {
.replace(/url\(\s*["']?https?:\/\/[^"')]*["']?\s*\)/gi, 'none')
}
/** 给 SVG viewBox 四周加 padding同步更新 width/height 并注入字体样式防止截断 */
function padSvgViewBox(svg: string, pad = 48): string {
let result = svg
// 注入显式字体样式,确保测量与渲染一致
const styleInject = `
<style>
svg { font-family: 'Inter', system-ui, sans-serif !important; }
.node text, .edgeLabel text { font-family: 'Inter', system-ui, sans-serif !important; }
</style>`
if (result.includes('</style>')) {
result = result.replace('</style>', ` svg { font-family: 'Inter', system-ui, sans-serif !important; }\n .node text, .edgeLabel text { font-family: 'Inter', system-ui, sans-serif !important; }\n</style>`)
} else {
result = result.replace(/>/, `>${styleInject}`)
}
// 匹配 viewBox
const vbMatch = result.match(/viewBox\s*=\s*["']\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*["']/i)
if (vbMatch) {
const x = parseFloat(vbMatch[1]) - pad
const y = parseFloat(vbMatch[2]) - pad
const w = parseFloat(vbMatch[3]) + pad * 2
const h = parseFloat(vbMatch[4]) + pad * 2
result = result.replace(vbMatch[0], `viewBox="${x} ${y} ${w} ${h}"`)
// 覆盖外层 width/height 为实际像素值
result = result.replace(/\bwidth\s*=\s*["'][^"']*["']/i, `width="${w}"`)
result = result.replace(/\bheight\s*=\s*["'][^"']*["']/i, `height="${h}"`)
}
return result
}
function getRasterDpr(width: number, height: number) {
const rawDpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
const baseDpr = Math.min(3, Math.max(1, rawDpr))
@@ -262,8 +196,7 @@ async function rasterizeSvgToPngDataUrl(svg: string, width: number, height: numb
}
async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
const paddedSvg = padSvgViewBox(svg)
const fallback = getSvgSize(paddedSvg)
const fallback = getSvgSize(svg)
const sourceWidth = fallback.width
const sourceHeight = fallback.height
@@ -275,8 +208,8 @@ async function svgToImageDataUrl(svg: string): Promise<MermaidImagePayload> {
width = Math.max(1, Math.round(width * scale))
height = Math.max(1, Math.round(height * scale))
const normalizedSvg = stripExternalSvgResources(paddedSvg)
const candidates = normalizedSvg === paddedSvg ? [paddedSvg] : [paddedSvg, normalizedSvg]
const normalizedSvg = stripExternalSvgResources(svg)
const candidates = normalizedSvg === svg ? [svg] : [svg, normalizedSvg]
let lastError: unknown = null
for (const candidate of candidates) {
@@ -337,7 +270,7 @@ async function renderMermaidBlock(block: HTMLElement, token: number): Promise<vo
const encodedCode = block.getAttribute('data-mermaid-code') || ''
const code = decodeMermaidCode(encodedCode).trim() || 'graph TD\nA-->B'
inner.innerHTML = '<div class="mermaid-loading"><span></span><span></span><span></span></div>'
inner.innerHTML = '<div class="mermaid-loading">...</div>'
setMermaidActionsState(block, null)
try {
@@ -373,8 +306,8 @@ async function renderMermaidBlock(block: HTMLElement, token: number): Promise<vo
}
function scheduleMermaidRender(token: number) {
const maxAttempts = 24
const targetSelector = `.mermaid-block[data-mermaid-token="${token}"]`
const retryDelays = [24, 64, 160, 320, 640]
const run = (attempt: number) => {
const block = document.querySelector(targetSelector)
@@ -382,9 +315,13 @@ function scheduleMermaidRender(token: number) {
void renderMermaidBlock(block, token)
return
}
const nextDelay = retryDelays[attempt]
if (nextDelay === undefined) return
window.setTimeout(() => run(attempt + 1), nextDelay)
if (attempt >= maxAttempts) return
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => run(attempt + 1))
} else {
window.setTimeout(() => run(attempt + 1), 16)
}
}
run(0)

View File

@@ -1,770 +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
instruction: string
signal?: AbortSignal
onChunk?: (chunk: string) => void
onEvent?: (event: string, data?: Record<string, any>) => void
}) => Promise<string>
t: (key: string) => string
showError: (message: string) => void
}
interface ProRequestPayload {
prefix: string
suffix: string
languageId: string
instruction?: 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,
instruction: parsed.instruction,
autoStart: false,
}
}
function findProBlockReplacements(doc: ProseNode) {
const replacements: Array<{ from: number; to: number; instruction: 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,
instruction: parsed.instruction,
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)
const preview = ocrText ? 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(`![${label}](${src}) <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 = new Slice(parsedDoc.content, 0, 0)
if (!parsedSlice || parsedSlice.size <= 0) return null
const beforeSize = tr.doc.content.size
const deleteSize = Math.max(0, to - from)
try {
tr.replaceRange(from, to, parsedSlice)
} catch {
return null
}
const insertedSize = Math.max(0, tr.doc.content.size - beforeSize + deleteSize)
const startPos = Math.max(0, Math.min(from, tr.doc.content.size))
const endPos = Math.max(startPos, Math.min(startPos + insertedSize, tr.doc.content.size))
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({ instruction: '', 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,
instruction: string,
autoStart: boolean
) {
const proBlockType = view.state.schema.nodes[PRO_BLOCK_NODE_TYPE]
if (!proBlockType) return false
const blockNode = proBlockType.create({
instruction,
autoStart,
})
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 || !insertedText.includes(']')) return false
const blockFrom = $from.before(paragraphDepth)
const blockTo = blockFrom + paragraph.nodeSize
return replaceParagraphWithProBlock(view, blockFrom, blockTo, parsed.instruction, parsed.autoStart)
}
function isAbortError(error: unknown) {
return Boolean(error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError')
}
function normalizeProMarkdown(value = '') {
return String(value || '').replace(/\r\n?/g, '\n').trim()
}
export const proBlockConfigCtx = $ctx<ProBlockConfig, 'proBlockConfig'>({
fetchSuggestionStream: async () => '',
t: (key: string) => key,
showError: () => {},
}, '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
candidates: string[] = []
activeCandidateIndex = -1
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: 'idle',
previewContent: '',
activeContent: '',
errorMessage: '',
isBusy: false,
isThinking: false,
title: PRO_DISPLAY_LABEL,
instruction: node.attrs.instruction || '',
instructionExpanded: false,
activeVersion: 0,
versionCount: 0,
canAccept: false,
t: (key: string) => this.config.t(key),
activateAction: () => {
void this.startThinking(false)
},
cancelAction: () => {
this.cancelThinking()
},
discardAction: () => {
this.discardResult()
},
redoAction: () => {
void this.startThinking(true)
},
acceptAction: () => {
void this.acceptResult()
},
prevVersionAction: () => {
this.selectCandidate(this.activeCandidateIndex - 1)
},
nextVersionAction: () => {
this.selectCandidate(this.activeCandidateIndex + 1)
},
toggleInstructionAction: () => {
this.props.instructionExpanded = !this.props.instructionExpanded
},
updateInstructionAction: (value: string) => {
this.updateInstruction(value)
},
})
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' | 'queued' | 'thinking' | 'streaming' | 'done' | 'error' | 'cancelled', previewContent = '') {
this.props.stage = stage
this.props.previewContent = previewContent
this.props.isBusy = stage === 'queued' || stage === 'thinking' || stage === 'streaming'
this.props.isThinking = stage === 'thinking'
this.refreshCandidateProps()
}
refreshCandidateProps() {
this.props.activeContent = this.candidates[this.activeCandidateIndex] || ''
this.props.activeVersion = Math.max(0, this.activeCandidateIndex)
this.props.versionCount = this.candidates.length
this.props.canAccept = !this.props.isBusy && Boolean(this.props.activeContent || this.props.previewContent) && this.props.stage !== 'error'
}
selectCandidate(index: number) {
if (index < 0 || index >= this.candidates.length) return
this.activeCandidateIndex = index
this.props.errorMessage = ''
this.setStage('done', '')
}
setResult(content: string) {
const normalized = normalizeProMarkdown(content)
if (!normalized) return
this.candidates.push(normalized)
if (this.candidates.length > 5) {
this.candidates = this.candidates.slice(this.candidates.length - 5)
}
this.activeCandidateIndex = this.candidates.length - 1
this.refreshCandidateProps()
}
clearResult() {
this.candidates = []
this.activeCandidateIndex = -1
this.props.previewContent = ''
this.refreshCandidateProps()
}
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',
instruction: this.props.instruction || '',
blocked: fullPrefix.length + suffixMarkdown.length > PRO_CONTEXT_LIMIT,
}
}
consumeAutoStart() {
if (!this.node.attrs.autoStart) return
this.updateAttrs({ autoStart: false })
void this.startThinking(false)
}
abortActive(reason = 'abort') {
if (!this.abortController) return
this.abortController.abort(reason)
this.abortController = null
}
cancelThinking() {
const draft = String(this.props.previewContent || '').trim()
this.abortActive('cancelled')
if (draft) {
this.setResult(draft)
}
this.props.errorMessage = ''
this.setStage(draft ? 'cancelled' : 'idle', '')
}
discardResult() {
this.abortActive('discard')
this.clearResult()
this.props.errorMessage = ''
this.setStage('idle', '')
this.updateAttrs({ autoStart: false })
}
updateInstruction(value: string) {
if (this.props.isBusy) return
const next = String(value || '')
if (next === this.props.instruction) return
this.props.instruction = next
this.clearResult()
this.props.errorMessage = ''
this.setStage('idle', '')
this.updateAttrs({ instruction: next, autoStart: false })
}
async startThinking(_isRedo: boolean) {
if (this.props.isBusy) {
this.abortActive('restart')
}
let requestSeq = this.requestSeq
try {
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.props.errorMessage = ''
this.setStage('queued', '')
const result = await this.config.fetchSuggestionStream({
...payload,
signal: this.abortController.signal,
instruction: this.props.instruction || '',
onChunk: (chunk) => {
if (this.destroyed || this.requestSeq !== requestSeq) return
const nextContent = `${this.props.previewContent || ''}${chunk}`
this.setStage('streaming', nextContent)
},
onEvent: (event, data) => {
if (this.destroyed || this.requestSeq !== requestSeq) return
if (event === 'queued') {
this.setStage('queued', '')
return
}
if (event === 'started') {
this.setStage('thinking', this.props.previewContent || '')
return
}
if (event === 'thinking') {
this.setStage('thinking', this.props.previewContent || '')
return
}
if (event === 'error') {
this.props.errorMessage = String(data?.error || this.config.t('proErrorActionable') || 'PRO 模式生成失败,请重试。')
}
},
})
if (this.destroyed || this.requestSeq !== requestSeq) return
const content = normalizeProMarkdown(result || this.props.previewContent || '')
if (!content) {
this.props.errorMessage = this.config.t('proEmptyResult') || 'PRO 返回空结果,请重试或缩短上下文。'
this.setStage('error', '')
return
}
this.setResult(content)
this.setStage('done', '')
this.updateAttrs({ autoStart: false })
} catch (error) {
if (this.destroyed || this.requestSeq !== requestSeq) return
if (isAbortError(error)) {
const draft = String(this.props.previewContent || '').trim()
if (draft && this.props.stage !== 'cancelled') {
this.setResult(draft)
this.setStage('cancelled', '')
} else if (!draft && this.props.stage !== 'cancelled') {
this.setStage(this.props.activeContent ? 'done' : 'idle', '')
}
} else {
this.props.errorMessage = error instanceof Error
? error.message
: (this.config.t('proErrorActionable') || 'PRO 模式生成失败,请重试。')
this.setStage('error', this.props.previewContent || '')
}
} finally {
if (this.requestSeq === requestSeq) {
this.abortController = null
}
}
}
async acceptResult() {
if (this.props.isBusy) return
const source = normalizeProMarkdown(this.props.activeContent || this.props.previewContent || '')
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) {
this.props.errorMessage = this.config.t('proAcceptParseError') || '无法把当前结果解析为 Markdown请重试生成。'
this.setStage('error', source)
return
}
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
this.props.instruction = node.attrs.instruction || ''
if (!this.props.isBusy) {
this.refreshCandidateProps()
if (!this.props.activeContent && !this.props.errorMessage) {
this.props.previewContent = ''
this.props.stage = '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: {
instruction: { default: '' },
autoStart: { default: false },
},
parseDOM: [
{
tag: 'div[data-pro-block="true"]',
getAttrs: (dom) => ({
instruction: (dom as HTMLElement).getAttribute('data-pro-instruction') || '',
autoStart: ((dom as HTMLElement).getAttribute('data-pro-autostart') || '') === 'true',
}),
},
],
toDOM: (node) => [
'div',
{
'data-pro-block': 'true',
'data-pro-instruction': node.attrs.instruction || '',
'data-pro-autostart': String(Boolean(node.attrs.autoStart)),
},
],
parseMarkdown: {
match: (node) => node.type === PRO_BLOCK_NODE_TYPE,
runner: (state, node, type) => {
state.addNode(type, {
instruction: String(node.instruction || ''),
autoStart: false,
})
},
},
toMarkdown: {
match: (node) => node.type.name === PRO_BLOCK_NODE_TYPE,
runner: (state, node) => {
state.addNode('paragraph', [{
type: 'text',
value: serializeProBlockSyntax(node.attrs.instruction),
}])
},
},
leafText: (node) => serializeProBlockSyntax(node.attrs.instruction),
}))
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({
instruction: replacement.instruction,
autoStart: replacement.autoStart,
})
)
}
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 }

View File

@@ -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
},
})
})

View File

@@ -3,17 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Editor',
component: () => import('../views/EditorView.vue')
},
{
path: '/docs',
name: 'Docs',
component: () => import('../views/DocsView.vue')
},
{
path: '/:pathMatch(.*)*',
redirect: '/'
name: 'Home',
component: () => import('../App.vue')
}
]

View File

@@ -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
}

5
src/store/index.js Normal file
View File

@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

View File

@@ -11,7 +11,6 @@ export const useSettingsStore = defineStore('settings', () => {
// 2. Model Behavior
const modelThinking = ref('low') // 'low' | 'medium' | 'high'
const debounceMs = ref(1000) // 1000 - 5000
const proThinking = ref('medium') // 'low' | 'medium' | 'high'
// 3. Privacy
const privacyMode = ref(true)
@@ -24,8 +23,6 @@ export const useSettingsStore = defineStore('settings', () => {
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
const backgroundImage = ref('')
const backgroundOpacity = ref(0.2) // 0.05 - 0.50
// TTS Voice
const ttsInstruct = ref('')
// --- Getters ---
const uiLanguage = computed(() => {
@@ -45,10 +42,7 @@ export const useSettingsStore = defineStore('settings', () => {
// We will let the backend handle 'auto' currency if needed, or stick to auto label.
const t = computed(() => {
return {
...translations['en'],
...(translations[uiLanguage.value] || {}),
}
return translations[uiLanguage.value] || translations['en']
})
const initialMarkdown = computed(() => {
@@ -67,7 +61,6 @@ export const useSettingsStore = defineStore('settings', () => {
if (data.theme) theme.value = data.theme
if (data.modelThinking) modelThinking.value = data.modelThinking
if (data.debounceMs) debounceMs.value = data.debounceMs
if (data.proThinking) proThinking.value = data.proThinking
if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode
if (data.language) language.value = data.language
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.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
if (typeof data.ttsInstruct === 'string') ttsInstruct.value = data.ttsInstruct
}
} catch {
// Failed to load settings, use defaults
@@ -91,14 +83,12 @@ export const useSettingsStore = defineStore('settings', () => {
theme: theme.value,
modelThinking: modelThinking.value,
debounceMs: debounceMs.value,
proThinking: proThinking.value,
privacyMode: privacyMode.value,
language: language.value,
currency: currency.value,
backgroundType: backgroundType.value,
backgroundImage: backgroundImage.value,
backgroundOpacity: backgroundOpacity.value,
ttsInstruct: ttsInstruct.value,
}
localStorage.setItem('llm-in-text-settings', JSON.stringify(data))
} catch {
@@ -111,14 +101,12 @@ export const useSettingsStore = defineStore('settings', () => {
theme.value = 'system'
modelThinking.value = 'low'
debounceMs.value = 1000
proThinking.value = 'medium'
privacyMode.value = false
language.value = 'auto'
currency.value = 'auto'
backgroundType.value = 'default'
backgroundImage.value = ''
backgroundOpacity.value = 0.2
ttsInstruct.value = ''
saveSettings()
}
@@ -128,14 +116,12 @@ export const useSettingsStore = defineStore('settings', () => {
theme,
modelThinking,
debounceMs,
proThinking,
privacyMode,
language,
currency,
backgroundType,
backgroundImage,
backgroundOpacity,
ttsInstruct,
],
() => {
saveSettings()
@@ -149,15 +135,12 @@ export const useSettingsStore = defineStore('settings', () => {
theme,
modelThinking,
debounceMs,
proThinking,
privacyMode,
language,
currency,
backgroundType,
backgroundImage,
backgroundOpacity,
ttsInstruct,
detectedTimezone,
uiLanguage,
t,
initialMarkdown,

View File

@@ -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,
}
})

View File

@@ -37,21 +37,16 @@
--toggle-moon: #475569;
--ghost-text: #7d8796;
--ghost-code-bg: rgba(15, 23, 42, 0.06);
--code-inline-bg: rgba(15, 23, 42, 0.06);
--code-block-bg: #f8fafc;
--code-block-border: rgba(148, 163, 184, 0.28);
--code-text: #0f172a;
--mermaid-max-height: 480px;
--mermaid-max-height: 420px;
--mermaid-mobile-max-height: 320px;
--mermaid-glass-bg: rgba(255, 255, 255, 0.55);
--mermaid-glass-border: rgba(180, 200, 230, 0.5);
--mermaid-glass-border-hover: rgba(59, 130, 246, 0.45);
--mermaid-glass-shadow: 0 4px 24px rgba(15, 23, 42, 0.08), 0 1px 4px rgba(15, 23, 42, 0.05);
--mermaid-glass-shadow-hover: 0 8px 32px rgba(59, 130, 246, 0.12), 0 2px 8px rgba(15, 23, 42, 0.08);
--mermaid-btn-bg: rgba(255, 255, 255, 0.8);
--mermaid-btn-hover-bg: rgba(239, 246, 255, 0.95);
--mermaid-btn-fg: #374151;
--mermaid-btn-border: rgba(180, 200, 230, 0.6);
--mermaid-action-bg: linear-gradient(180deg, #ffffff 0%, #f4f7fc 100%);
--mermaid-action-hover-bg: linear-gradient(180deg, #ffffff 0%, #e9f2ff 100%);
--mermaid-action-fg: #1f2937;
--mermaid-action-border: #cfd8e6;
--mermaid-action-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
--mermaid-action-shadow-hover: 0 4px 10px rgba(37, 99, 235, 0.16);
--mermaid-action-disabled-bg: rgba(148, 163, 184, 0.18);
--mermaid-action-disabled-fg: #9aa4b2;
--crepe-color-background: #ffffff;
--crepe-color-on-background: #000000;
@@ -72,30 +67,6 @@
--crepe-color-inline-area: #cacaca;
}
/* GitHub-like light tokens (used by DocsView.gitHub styled components) */
:root {
--github-bg: #ffffff;
--github-border: #d0d7de;
--github-text: #24292f;
--github-text-secondary: #57606a;
--github-accent: #54aeff;
--github-hover: #f6f8fa;
--github-selected: #ddf4ff;
--github-code-bg: #f6f8fa;
}
/* Dark theme variants for GitHub-like tokens. Keep parity with project colors. */
:root[data-theme='dark'] {
--github-bg: #0f1117;
--github-border: #2f3644;
--github-text: #e5e7eb;
--github-text-secondary: #a9b0bd;
--github-accent: #58a6ff;
--github-hover: #1b2130;
--github-selected: #1e2a4c;
--github-code-bg: #111827;
}
:root[data-theme='dark'] {
color-scheme: dark;
--app-bg: #0f1117;
@@ -124,21 +95,14 @@
--toggle-moon: #e2e8f0;
--ghost-text: #95a0b4;
--ghost-code-bg: rgba(226, 232, 240, 0.12);
--code-inline-bg: rgba(30, 41, 59, 0.92);
--code-block-bg: rgba(15, 23, 42, 0.92);
--code-block-border: rgba(71, 85, 105, 0.55);
--code-text: #e2e8f0;
--mermaid-max-height: 480px;
--mermaid-mobile-max-height: 320px;
--mermaid-glass-bg: rgba(20, 28, 44, 0.6);
--mermaid-glass-border: rgba(60, 80, 120, 0.4);
--mermaid-glass-border-hover: rgba(96, 165, 250, 0.45);
--mermaid-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2);
--mermaid-glass-shadow-hover: 0 8px 32px rgba(96, 165, 250, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3);
--mermaid-btn-bg: rgba(40, 52, 75, 0.85);
--mermaid-btn-hover-bg: rgba(55, 70, 100, 0.95);
--mermaid-btn-fg: #c9d6e8;
--mermaid-btn-border: rgba(70, 95, 140, 0.55);
--mermaid-action-bg: linear-gradient(180deg, #30394b 0%, #242c3a 100%);
--mermaid-action-hover-bg: linear-gradient(180deg, #3a4760 0%, #2a3445 100%);
--mermaid-action-fg: #e5e7eb;
--mermaid-action-border: #3e4a61;
--mermaid-action-shadow: 0 1px 2px rgba(2, 6, 23, 0.45);
--mermaid-action-shadow-hover: 0 5px 12px rgba(2, 6, 23, 0.55);
--mermaid-action-disabled-bg: rgba(82, 93, 110, 0.3);
--mermaid-action-disabled-fg: #9aa4b2;
--crepe-color-background: #1a1a1a;
--crepe-color-on-background: #e6e6e6;
@@ -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-block {
position: relative;
display: block;
width: 100%;
width: fit-content;
max-width: 100%;
margin: 1.25em 0;
padding: 0;
background: var(--mermaid-glass-bg);
border: 1px solid var(--mermaid-glass-border);
border-radius: 16px;
box-shadow: var(--mermaid-glass-shadow);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: border-color 240ms ease, box-shadow 240ms ease;
overflow: hidden;
margin: 1em auto;
padding: 12px;
background: var(--crepe-color-surface, #f7f7f7);
border: 1px solid var(--panel-border, #d7deea);
border-radius: 10px;
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
}
.mermaid-block:hover {
border-color: var(--mermaid-glass-border-hover);
box-shadow: var(--mermaid-glass-shadow-hover);
border-color: var(--focus-ring, #3b82f6);
}
/* 悬浮控制栏右上角hover 时渐现 */
.mermaid-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
display: flex;
align-items: center;
gap: 6px;
opacity: 0;
transform: translateY(-4px);
transition: opacity 200ms ease, transform 200ms ease;
pointer-events: none;
}
.mermaid-block:hover .mermaid-controls {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
justify-content: flex-end;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.mermaid-action-btn {
appearance: none;
border: 1px solid var(--mermaid-btn-border);
background: var(--mermaid-btn-bg);
color: var(--mermaid-btn-fg);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
line-height: 1;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 1px 4px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.mermaid-zoom-btn {
width: 30px;
height: 30px;
padding: 0;
border-radius: 50%;
}
.mermaid-download-btn {
height: 30px;
padding: 0 12px;
border: 1px solid var(--mermaid-action-border);
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.2;
background: var(--mermaid-action-bg);
color: var(--mermaid-action-fg);
cursor: pointer;
box-shadow: var(--mermaid-action-shadow);
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease, background 160ms ease;
}
.mermaid-action-btn:hover:not([disabled]) {
background: var(--mermaid-btn-hover-bg);
border-color: var(--focus-ring);
box-shadow: 0 0 0 3px rgba(59,130,246,0.18), 0 2px 8px rgba(0,0,0,0.2);
background: var(--mermaid-action-hover-bg);
box-shadow: var(--mermaid-action-shadow-hover);
transform: translateY(-1px);
}
.mermaid-action-btn:active:not([disabled]) {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
}
.mermaid-action-btn[disabled] {
opacity: 0.35;
background: var(--mermaid-action-disabled-bg);
color: var(--mermaid-action-disabled-fg);
cursor: not-allowed;
pointer-events: none;
}
.mermaid-inner {
display: block;
width: 100%;
max-width: 100%;
max-height: var(--mermaid-max-height);
overflow: auto;
padding: 20px;
background: transparent;
border-radius: 8px;
padding: 8px;
background: color-mix(in srgb, var(--crepe-color-background, #fff) 88%, transparent);
}
.mermaid-inner::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.mermaid-inner::-webkit-scrollbar { width: 6px; height: 6px; }
.mermaid-inner::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 3px;
}
.mermaid-inner::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
border-radius: 4px;
}
.mermaid-image {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-width: none;
margin: 0 auto;
border-radius: 6px;
}
.mermaid-inner svg { display: block; margin: 0 auto; }
.mermaid-inner svg {
display: block;
}
/* 三点弹跳加载动画 */
.mermaid-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 36px;
padding: 24px;
text-align: center;
font-size: 1.4em;
color: var(--muted-text, #6b7280);
letter-spacing: 0.2em;
animation: mermaid-pulse 1.2s ease-in-out infinite;
}
.mermaid-loading span {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted-text, #6b7280);
animation: mermaid-bounce 1.2s ease-in-out infinite;
}
.mermaid-loading span:nth-child(2) { animation-delay: 0.2s; }
.mermaid-loading span:nth-child(3) { animation-delay: 0.4s; }
@keyframes mermaid-bounce {
0%, 80%, 100% { transform: scale(0.55); opacity: 0.35; }
40% { transform: scale(1); opacity: 1; }
@keyframes mermaid-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.mermaid-error {
margin: 12px;
padding: 12px 16px;
background: rgba(220, 38, 38, 0.08);
margin: 0;
background: color-mix(in srgb, var(--danger-text, #dc2626) 8%, transparent);
border: 1px solid var(--danger-text, #dc2626);
border-radius: 8px;
border-radius: 6px;
color: var(--danger-text, #dc2626);
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
@@ -426,122 +325,20 @@ body {
word-break: break-word;
}
:root .milkdown .katex {
color: var(--crepe-color-on-background);
}
:root[data-theme='dark'] .milkdown .katex .mord,
:root[data-theme='dark'] .milkdown .katex .mbin,
:root[data-theme='dark'] .milkdown .katex .mrel,
:root[data-theme='dark'] .milkdown .katex .minner {
color: var(--crepe-color-on-background);
}
:root[data-theme='dark'] .milkdown .katex .mord.text {
:root[data-theme='dark'] .milkdown .katex {
color: var(--crepe-color-on-background);
}
:root[data-theme='dark'] .milkdown .cm-editor,
:root[data-theme='dark'] .milkdown .cm-scroller {
background-color: var(--code-block-bg);
color: var(--code-text);
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
color: var(--crepe-color-on-surface);
}
:root[data-theme='dark'] .milkdown .cm-gutters {
background-color: var(--code-block-bg);
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
color: var(--crepe-color-on-surface-variant);
border-right: 1px solid var(--code-block-border);
}
:root[data-theme='dark'] .milkdown .cm-activeLine {
background-color: rgba(255, 255, 255, 0.05);
}
:root[data-theme='dark'] .milkdown .cm-activeLineGutter {
background-color: rgba(255, 255, 255, 0.08);
}
:root[data-theme='dark'] .milkdown .cm-selectionBackground,
:root[data-theme='dark'] .milkdown .cm-selectionBackground::selection {
background-color: rgba(59, 130, 246, 0.25);
}
:root[data-theme='dark'] .milkdown .cm-cursor {
border-left-color: var(--crepe-color-on-background);
}
:root .milkdown .ProseMirror pre,
:root .milkdown .ProseMirror code {
background: var(--code-inline-bg);
color: var(--code-text);
border: 1px solid var(--code-block-border);
}
:root .milkdown .ProseMirror pre {
padding: 12px 16px;
border-radius: 8px;
background: var(--code-block-bg);
}
:root .milkdown .ProseMirror pre code {
background: none;
border: none;
padding: 0;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.comment,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.prolog,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.doctype,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.cdata {
color: #6b7280;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.punctuation {
color: #9ca3af;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.property,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.tag,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.boolean,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.number,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.constant,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.symbol,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.deleted {
color: #f472b6;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.selector,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.attr-name,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.string,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.char,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.builtin,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.inserted {
color: #a78bfa;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.operator,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.entity,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.url,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .language-css .token.string,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .style .token.string {
color: #60a5fa;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.atrule,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.attr-value,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.keyword {
color: #34d399;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.function,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.class-name {
color: #fbbf24;
}
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.regex,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.important,
:root[data-theme='dark'] .milkdown .ProseMirror pre code .token.variable {
color: #fb923c;
border-right-color: var(--panel-border);
}
@media (max-width: 768px) {
@@ -549,122 +346,3 @@ body {
max-height: var(--mermaid-mobile-max-height);
}
}
/* ===== Energy-aware CSS optimizations ===== */
/* Respect user's reduced motion preference: disable infinite animations & reduce transitions */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* Disable backdrop-filter entirely for reduced motion users (GPU saving) */
.tts-player,
.tts-menu,
.context-menu,
.settings-panel {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
/* On mobile (touch + narrow viewport), reduce GPU-intensive effects */
@media (max-width: 768px) and (hover: none) {
/* Reduce backdrop-filter blur radius on mobile to save GPU */
.tts-player,
.context-menu {
backdrop-filter: blur(6px) !important;
-webkit-backdrop-filter: blur(6px) !important;
}
.tts-menu,
.settings-panel {
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
}
/* Disable backdrop-filter on low-end mobile devices */
@media (max-width: 480px) {
.tts-player,
.context-menu,
.tts-menu,
.settings-panel {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
/* Reduce transition complexity on mobile */
.tts-player__btn {
transition: background-color 0.15s ease !important;
}
/* Avoid expensive transforms on mobile */
.tts-player__btn--primary:hover {
transform: none !important;
filter: none !important;
}
.tts-player__btn--primary:active {
transform: none !important;
}
/* Reduce animation frame rate for spinners on mobile */
.spinner {
animation-duration: 1.5s !important;
}
/* Disable pulse warning on mobile (infinite animation drain) */
.size-indicator.over-limit {
animation: none !important;
}
/* Use hardware-accelerated properties only for critical animations */
.tts-player-slide-enter-active,
.tts-player-slide-leave-active {
transition: transform 0.2s ease, opacity 0.15s ease !important;
}
/* Reduce mermaid preview max-height further on very small screens */
@media (max-width: 480px) {
.mermaid-inner {
max-height: 240px !important;
}
}
}
/* Pause animations when tab is not visible (handled via CSS + JS) */
.hidden-tab * {
animation-play-state: paused !important;
}
/* Optimize text rendering for battery life */
.milkdown-editor,
.doc-card__editor {
-webkit-text-size-adjust: none;
}
/* Use content-visibility for off-screen sections to reduce layout cost */
.doc-card__body {
contain: content;
}
/* Optimize scroll performance */
.milkdown-editor,
.doc-card__editor {
overflow-anchor: none;
}
/* Reduce paint area for ghost text */
.copilot-ghost-text {
contain: strict;
}
/* Prefer static rendering for code blocks (avoid re-layout) */
.milkdown .ProseMirror pre {
contain: content;
}
/* ===== End energy-aware CSS ===== */

View File

@@ -1,354 +1,146 @@
import { API_URL, API_KEY, PRO_STREAM_URL, PRO_FRONTEND_TIMEOUT_MS, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL } from './config.js'
import { API_URL } from './config.js'
import { useSettingsStore } from '../stores/settings'
function generateRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function getCancelUrl(apiUrl) {
const normalized = String(apiUrl || '').replace(/\/+$/, '')
if (!normalized) return '/v1/completions/cancel'
if (/\/v1\/pro\/completions\/stream$/i.test(normalized)) {
return normalized.replace(/\/v1\/pro\/completions\/stream$/i, '/v1/completions/cancel')
}
if (normalized.endsWith('/v1/completions')) {
const normalized = String(apiUrl || '').replace(/\/+$/, '')
if (!normalized) return '/v1/completions/cancel'
if (normalized.endsWith('/v1/completions')) {
return `${normalized}/cancel`
}
return `${normalized}/cancel`
}
return `${normalized}/cancel`
}
function getProCancelUrl(apiUrl) {
const normalized = String(apiUrl || '').replace(/\/+$/, '')
if (!normalized) return '/v1/completions/cancel'
if (/\/v1\/pro\/completions$/i.test(normalized)) {
return normalized.replace(/\/v1\/pro\/completions$/i, '/v1/completions/cancel')
}
return normalized.replace(/\/v1\/pro\/completions\/stream$/i, '/v1/completions/cancel')
}
function normalizeAbortReason(reason) {
if (typeof reason === 'string' && reason.trim()) {
return reason.trim().slice(0, 64)
}
return 'abort'
if (typeof reason === 'string' && reason.trim()) {
return reason.trim().slice(0, 64)
}
return 'abort'
}
async function sendCancelRequest(cancelUrl, requestId, reason) {
try {
await fetch(cancelUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
request_id: requestId,
reason,
}),
})
} catch {
// 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
try {
await fetch(cancelUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
request_id: requestId,
reason,
}),
})
} catch {
// Cancel request failed silently
}
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) {
let normalizedLanguageId = 'markdown'
if (typeof languageId === 'string' && languageId.trim()) {
normalizedLanguageId = languageId.trim()
} else if (languageId && typeof languageId === 'object' && 'aborted' in 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 })
let normalizedLanguageId = 'markdown'
if (typeof languageId === 'string' && languageId.trim()) {
normalizedLanguageId = languageId.trim()
} else if (languageId && typeof languageId === 'object' && 'aborted' in languageId) {
signal = languageId
}
}
if (typeof signal === 'string') {
apiUrl = signal
signal = undefined
}
const requestId = generateRequestId()
const cancelUrl = getCancelUrl(apiUrl)
try {
const settings = useSettingsStore()
const headers = {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
'X-API-Key': API_KEY,
const onAbort = () => {
const reason = normalizeAbortReason(signal?.reason)
void sendCancelRequest(cancelUrl, requestId, reason)
}
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) {
signal.removeEventListener('abort', onAbort)
}
}
}
export async function fetchProSuggestionStream(payload, apiUrl = PRO_STREAM_URL) {
const {
prefix = '',
suffix = '',
languageId = 'markdown',
instruction = '',
signal,
timeoutMs = PRO_FRONTEND_TIMEOUT_MS,
onChunk,
onEvent,
} = payload || {}
const settings = useSettingsStore()
const requestId = generateRequestId()
const cancelUrl = getProCancelUrl(apiUrl)
const requestController = new AbortController()
const timeoutId = setTimeout(() => {
requestController.abort('timeout')
}, timeoutMs)
const relayAbort = () => {
requestController.abort(signal?.reason || 'abort')
}
const onAbort = () => {
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(
{
prefix,
suffix,
languageId: String(languageId || 'markdown').trim() || 'markdown',
instruction,
pro_thinking: settings.proThinking || 'medium',
privacy_mode: settings.privacyMode,
user_preferences: {
language: settings.language,
currency: settings.currency,
timezone: settings.detectedTimezone,
},
if (signal.aborted) {
onAbort()
} else {
signal.addEventListener('abort', onAbort, { once: true })
}
),
signal: requestController.signal,
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
if (!res.body) {
throw new Error('PRO 模式流式响应不可用')
}
try {
const settings = useSettingsStore()
const headers = {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let finalContent = ''
const body = {
prefix,
suffix,
languageId: normalizedLanguageId,
model_thinking: settings.modelThinking,
privacy_mode: settings.privacyMode,
user_preferences: {
language: settings.language,
currency: settings.currency,
timezone: settings.detectedTimezone,
},
}
while (true) {
const { done, value } = await reader.read()
if (done) break
const res = await fetch(apiUrl, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal,
})
buffer += decoder.decode(value, { stream: true })
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const chunk = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const reader = res.body?.getReader()
if (!reader) {
throw new Error('No reader available')
}
const parsed = parseSseEvent(chunk)
if (parsed.event && parsed.event !== 'message') {
let eventData = {}
if (parsed.data) {
try {
eventData = JSON.parse(parsed.data)
} catch {
eventData = {}
let text = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += new TextDecoder().decode(value)
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const jsonStr = line.slice(6).trim()
if (!jsonStr) continue
try {
const data = JSON.parse(jsonStr)
if (data.content) {
text += data.content
}
if (data.done || data.error) break
} catch (e) {
// skip invalid lines
}
}
}
onEvent?.(parsed.event, eventData)
}
if (parsed.event === 'chunk' && parsed.data) {
const data = JSON.parse(parsed.data)
const delta = String(data.delta || '')
if (delta) {
finalContent += delta
onChunk?.(delta)
}
return text
} catch (e) {
if (e.name === 'AbortError') {
// ignore abort
} else {
throw e
}
if (parsed.event === 'done' && parsed.data) {
const data = JSON.parse(parsed.data)
return String(data.content || finalContent || '')
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort)
}
if (parsed.event === 'error' && parsed.data) {
const data = JSON.parse(parsed.data)
throw new Error(String(data.error || 'PRO 模式请求失败'))
}
if (parsed.event === 'cancelled') {
throw createAbortError('PRO 模式请求已取消')
}
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()
}

View File

@@ -1,15 +1,7 @@
export const DEBUG = import.meta.env.DEV
const DEFAULT_API_BASE_URL = import.meta.env.DEV ? '' : 'https://api.imageteach.tech:8002'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteach.tech:8002'
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
export const PRO_STREAM_URL = import.meta.env.VITE_PRO_STREAM_URL || `${API_BASE_URL}/v1/pro/completions/stream`
export const PRO_FRONTEND_TIMEOUT_MS = Number(import.meta.env.VITE_PRO_FRONTEND_TIMEOUT_MS || 3660000)
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf'
export const TTS_URL = import.meta.env.VITE_TTS_URL || `${API_BASE_URL}/v1/tts-asr/tts`
export const TTS_STATUS_URL = import.meta.env.VITE_TTS_STATUS_URL || `${API_BASE_URL}/v1/tts-asr/status`
export const TTS_CONFIG_URL = import.meta.env.VITE_TTS_CONFIG_URL || `${API_BASE_URL}/v1/tts-asr/config`
export const API_KEY = import.meta.env.VITE_API_KEY || 'your-secret-key-here'

View File

@@ -23,7 +23,6 @@ export async function convertFileToMarkdown(file) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your-secret-key-here',
},
body: JSON.stringify({
file: base64,

View File

@@ -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)}`
})
}

View File

@@ -1,162 +0,0 @@
export const HIDDEN_TEXT_NODE_TYPE = 'hiddenText'
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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)
}
}

View File

@@ -22,12 +22,6 @@ export const translations = {
mediumDesc: 'Brief analysis before suggesting',
highDesc: 'Deep, step-by-step analysis (Slowest)',
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',
privacyMode: 'Privacy Mode',
privacyDesc: 'Prevent sending IP and preferences to the AI',
@@ -39,85 +33,25 @@ export const translations = {
exportMd: 'Export Markdown',
exportDocx: 'Export DOCX',
exportPdf: 'Export PDF',
upload: 'Upload',
uploadImg: 'Upload Image',
uploadFile: 'Upload File',
uploadDoc: 'Upload Document',
uploadDocTypeWarning: 'Only txt, json, toml, yaml, docx, pptx, pdf formats are supported.',
uploadDocTypeWarning: 'Only txt, docx, pptx, pdf formats are supported.',
uploadDocSizeWarning: 'File size cannot exceed 10MB.',
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
uploadDocError: 'Document conversion failed:',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
uploadBatchLimit: 'Maximum 10 files at once',
uploadSizeLimit: 'File exceeds 50MB limit',
uploading: 'Uploading files...',
enableAI: 'Enable AI',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'Enable AI',
disableAI: 'Disable AI',
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',
insert: 'Insert',
cancel: 'Cancel',
imgTooLarge: 'Image too large',
docTooLarge: 'Document too large, AI disabled',
exportDisabledHint: 'DOCX/PDF export is temporarily unavailable.',
disableAI: 'Disable AI',
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: 'No files yet',
newFile: 'New File',
newFolder: 'New Folder',
untitledFile: 'untitled.md',
untitledFolder: 'New Folder',
rename: 'Rename',
delete: 'Delete',
copy: 'Copy',
cut: 'Cut',
paste: 'Paste',
confirmDelete: 'Confirm Delete',
confirmDeleteDesc: 'Are you sure to delete',
confirmDeleteFolderDesc: 'This will delete all contents in the folder.',
confirmDeleteFileDesc: 'This action cannot be undone.',
rootDir: 'Root',
expandSidebar: 'Expand Sidebar',
collapseSidebar: 'Collapse Sidebar',
fileLimitReached: 'Maximum number of files reached',
folderLimitReached: 'Maximum number of folders reached',
fileSizeLimit: 'File size cannot exceed 50MB',
storageError: 'Insufficient storage space',
selectFileToView: 'Select a file to view content',
folderContains: 'contains',
items: 'items',
unsupportedPreview: 'This file type is not supported for preview',
fileNamePlaceholder: 'filename.md',
folderNamePlaceholder: 'Folder name'
initialMarkdown: '# Welcome to LLM-IN-TEXT\n\nAn instant LLM system\n\nStart your creative work below...'
},
zh: {
settings: '设置',
@@ -142,12 +76,6 @@ export const translations = {
mediumDesc: '简要分析上下文后建议',
highDesc: '深度逐步分析(最慢但质量最高)',
debounceTime: '防抖时间',
proMode: 'PRO模式思考',
proModeThinking: 'PRO 正在思考',
proModel: 'PRO 模型',
proModelPlaceholder: '例如 qwen3:32b',
proModelDesc: '可选。仅在 PRO 模式下使用的更强模型名称。',
proModelEmptyHint: '留空则使用后端默认 PRO 模型。',
privacyPreferences: '隐私与偏好',
privacyMode: '隐私模式',
privacyDesc: '不向 AI 发送 IP 地址和偏好设置',
@@ -159,85 +87,25 @@ export const translations = {
exportMd: '导出 Markdown',
exportDocx: '导出 DOCX',
exportPdf: '导出 PDF',
upload: '上传',
uploadImg: '上传图片',
uploadFile: '上传文件',
uploadDoc: '上传文档',
uploadDocTypeWarning: '仅支持 txt、json、toml、yaml、docx、pptx、pdf 格式的文档',
uploadDocTypeWarning: '仅支持 txt、docx、pptx、pdf 格式的文档',
uploadDocSizeWarning: '文件大小不能超过 10MB',
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
uploadDocError: '文档转换失败:',
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
uploadMdTypeWarning: '仅支持 Markdown.md和图片文件。',
uploadFileError: '文件上传失败',
uploadConvertError: '文件转换失败',
uploadBatchLimit: '一次最多上传10个文件',
uploadSizeLimit: '文件超过50MB限制',
uploading: '正在上传文件...',
enableAI: '启用 AI',
uploadFileError: '文件上传失败',
uploadConvertError: '文件转换失败',
enableAI: '启用 AI',
disableAI: '禁用 AI',
template: '模板',
presetTemplates: '预设模板',
customTemplates: '自定义模板',
newTemplate: '新建模板',
previewTemplate: '预览模板',
applyTemplate: '应用模板',
copyAsTemplate: '复制为自定义模板',
editTemplate: '编辑模板',
saveTemplate: '保存模板',
templateName: '模板名称',
templateContent: '模板内容',
templateNamePlaceholder: '例如:会议纪要',
templateContentPlaceholder: '在这里输入模板内容...',
noTemplates: '暂无自定义模板',
templateNameRequired: '请输入模板名称',
templateContentRequired: '请输入模板内容',
templateNameDuplicate: '模板名称已存在',
templateDeleteConfirm: '确认删除此模板吗?',
templateSaved: '模板已保存',
templateUpdated: '模板已更新',
templateDeleted: '模板已删除',
insertUrl: '通过 URL 插入图片',
insert: '插入',
cancel: '取消',
imgTooLarge: '图片过大',
docTooLarge: '文档过大AI已禁用',
exportDisabledHint: 'DOCX/PDF 导出暂不可用。',
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...',
view: '视图',
editor: '编辑器',
docs: '文档',
docsManagement: '文档管理',
docsEmptyDesc: '文档管理界面开发中...',
files: '文件',
noFiles: '暂无文件',
newFile: '新建文件',
newFolder: '新建文件夹',
untitledFile: '未命名.md',
untitledFolder: '新建文件夹',
rename: '重命名',
delete: '删除',
copy: '复制',
cut: '剪切',
paste: '粘贴',
confirmDelete: '确认删除',
confirmDeleteDesc: '确定要删除',
confirmDeleteFolderDesc: '此操作将删除文件夹内的所有内容。',
confirmDeleteFileDesc: '此操作不可撤销。',
cancel: '取消',
rootDir: '根目录',
expandSidebar: '展开侧边栏',
collapseSidebar: '收起侧边栏',
fileLimitReached: '文件数量已达上限',
folderLimitReached: '文件夹数量已达上限',
fileSizeLimit: '文件大小不能超过 50MB',
storageError: '存储空间不足',
selectFileToView: '选择一个文件以查看内容',
folderContains: '包含',
items: '个项目',
unsupportedPreview: '暂不支持预览此文件类型',
fileNamePlaceholder: '文件名.md',
folderNamePlaceholder: '文件夹名'
initialMarkdown: '# 欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统\n\n在下方开始创作吧...'
},
ja: {
settings: '設定',
@@ -262,12 +130,6 @@ export const translations = {
mediumDesc: '提案前に文脈を簡単に分析',
highDesc: '深く段階的に分析(遅いが最高品質)',
debounceTime: 'デバウンス時間',
proModeThinking: 'PRO 思考中',
proMode: 'PRO モード',
proModel: 'PRO モデル',
proModelPlaceholder: '例: qwen3:32b',
proModelDesc: 'PRO モードのみで使用されるオプションの強力なモデル名。',
proModelEmptyHint: '空白のままにするとバックエンドのデフォルト PRO モデルが使用されます。',
privacyPreferences: 'プライバシーと設定',
privacyMode: 'プライバシーモード',
privacyDesc: 'AIにIPアドレスと設定を送信しない',
@@ -275,47 +137,16 @@ export const translations = {
auto: '自動検出',
currency: '通貨',
about: '私たちについて',
template: 'テンプレート',
presetTemplates: 'プリセットテンプレート',
customTemplates: 'カスタムテンプレート',
newTemplate: '新しいテンプレート',
previewTemplate: 'プレビュー',
applyTemplate: '適用',
copyAsTemplate: 'カスタムテンプレートとしてコピー',
editTemplate: '編集',
saveTemplate: '保存',
templateName: 'テンプレート名',
templateContent: '内容',
templateNamePlaceholder: '例: 議事録',
templateContentPlaceholder: 'テンプレート内容を入力...',
noTemplates: 'カスタムテンプレートはありません',
templateNameRequired: 'テンプレート名を入力してください',
templateContentRequired: '内容を入力してください',
templateNameDuplicate: 'テンプレート名は既に存在します',
templateDeleteConfirm: 'このテンプレートを削除しますか?',
templateSaved: '保存しました',
templateUpdated: '更新しました',
templateDeleted: '削除しました',
importMd: 'Markdownをインポート',
exportMd: 'Markdownをエクスポート',
exportDocx: 'DOCXをエクスポート',
exportPdf: 'PDFをエクスポート',
upload: 'アップロード',
uploadImg: '画像をアップロード',
uploadFile: 'ファイルをアップロード',
uploadDoc: 'ドキュメントをアップロード',
exportDisabledHint: 'DOCX/PDFのエクスポートは一時的に利用できません。',
uploadDocTypeWarning: 'txt、json、toml、yaml、docx、pptx、pdf形式のみサポートされています。',
uploadDocSizeWarning: 'ファイルサイズは10MBを超えることはできません。',
uploadDocInBlockWarning: '既存のドキュメントブロック内に新しいドキュメントを挿入することはできません。',
uploadDocError: 'ドキュメントの変換に失敗しました:',
uploadFileTypeWarning: 'サポートされていないファイルタイプです。doc/docx/ppt/pptx/pdf/zip、画像、txt/jsonをサポートしています。',
uploadMdTypeWarning: 'Markdown.mdファイルと画像ファイルのみサポートされています。',
uploadFileError: 'アップロードに失敗しました。',
uploadConvertError: '変換に失敗しました。',
uploadBatchLimit: '一度にアップロードできるのは10ファイルまでです。',
uploadSizeLimit: '50MBの制限を超えています。',
uploading: 'アップロード中...',
uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'AIを有効化',
disableAI: 'AIを無効化',
insertUrl: 'URLから画像を挿入',
@@ -323,41 +154,7 @@ export const translations = {
cancel: 'キャンセル',
imgTooLarge: '画像が大きすぎます',
docTooLarge: 'ドキュメントが大きすぎます、AI無効',
initialMarkdown: '# LLM-IN-TEXTへようこそ\n\nすぐに使えるLLMシステム\n\n下から創作を始めましょう...',
view: 'ビュー',
editor: 'エディター',
docs: 'ドキュメント',
docsManagement: 'ドキュメント管理',
docsEmptyDesc: 'ドキュメント管理画面は開発中です...',
files: 'ファイル',
noFiles: 'ファイルはありません',
newFile: '新規ファイル',
newFolder: '新規フォルダー',
untitledFile: '無題.md',
untitledFolder: '新しいフォルダー',
rename: '名前を変更',
delete: '削除',
copy: 'コピー',
cut: '切り取り',
paste: '貼り付け',
confirmDelete: '削除の確認',
confirmDeleteDesc: '本当に削除しますか',
confirmDeleteFolderDesc: 'フォルダー内のすべてのコンテンツが削除されます。',
confirmDeleteFileDesc: 'この操作は元に戻せません。',
cancel: 'キャンセル',
rootDir: 'ルートディレクトリ',
expandSidebar: 'サイドバーを展開',
collapseSidebar: 'サイドバーを折りたたむ',
fileLimitReached: 'ファイル数の上限に達しました',
folderLimitReached: 'フォルダー数の上限に達しました',
fileSizeLimit: 'ファイルサイズは50MBを超えられません',
storageError: 'ストレージが不足しています',
selectFileToView: 'ファイルを選択して内容を表示',
folderContains: '含む',
items: '項目',
unsupportedPreview: 'このファイルタイプはプレビューに対応していません',
fileNamePlaceholder: 'ファイル名.md',
folderNamePlaceholder: 'フォルダー名'
initialMarkdown: '# LLM-IN-TEXTへようこそ\n\nすぐに使えるLLMシステム\n\n下から創作を始めましょう...'
},
ko: {
settings: '설정',
@@ -382,13 +179,6 @@ export const translations = {
mediumDesc: '제안 전 문맥 간단 분석',
highDesc: '심층 단계별 분석 (가장 느리지만 최고 품질)',
debounceTime: '디바운스 시간',
proModeThinking: 'PRO 생각 중',
proMode: 'PRO 모드',
proModel: 'PRO 모델',
proModelPlaceholder: '예: qwen3:32b',
proModelDesc: 'PRO 모드에서만 사용되는 선택적 강력한 모델 이름.',
proModelEmptyHint: '비워두면 백엔드 기본 PRO 모델을 사용합니다.',
privacyPreferences: '개인정보 및 환경설정',
privacyMode: '개인정보 모드',
privacyDesc: 'AI에 IP 주소 및 설정 전송 안 함',
@@ -396,48 +186,16 @@ export const translations = {
auto: '자동 감지',
currency: '통화',
about: '회사 소개',
template: '템플릿',
presetTemplates: '사전 정의된 템플릿',
customTemplates: '사용자 정의 템플릿',
newTemplate: '새 템플릿',
previewTemplate: '미리보기',
applyTemplate: '적용',
copyAsTemplate: '사용자 정의 템플릿으로 복사',
editTemplate: '편집',
saveTemplate: '저장',
templateName: '템플릿 이름',
templateContent: '내용',
templateNamePlaceholder: '예: 회의록',
templateContentPlaceholder: '템플릿 내용을 입력...',
noTemplates: '사용자 정의 템플릿이 없습니다',
templateNameRequired: '템플릿 이름을 입력하세요',
templateContentRequired: '내용을 입력하세요',
templateNameDuplicate: '템플릿 이름이 이미 존재합니다',
templateDeleteConfirm: '이 템플릿을 삭제하시겠습니까?',
templateSaved: '저장되었습니다',
templateUpdated: '업데이트되었습니다',
templateDeleted: '삭제되었습니다',
importMd: 'Markdown 가져오기',
exportMd: 'Markdown 내보내기',
exportDocx: 'DOCX 내보내기',
exportPdf: 'PDF 내보내기',
upload: '업로드',
uploadImg: '이미지 업로드',
uploadFile: '파일 업로드',
exportDisabledHint: 'DOCX/PDF 내보내기는 일시적으로 사용할 수 없습니다.',
uploadDoc: '문서 업로드',
uploadDocTypeWarning: 'txt, json, toml, yaml, docx, pptx, pdf 형식만 지원됩니다.',
uploadDocSizeWarning: '파일 크기는 10MB를 초과할 수 없습니다.',
uploadDocInBlockWarning: '기존 문서 블록 내에 새 문서를 삽입할 수 없습니다.',
uploadDocError: '문서 변환 실패:',
uploadFileTypeWarning: '지원되지 않는 파일 유형입니다. doc/docx/ppt/pptx/pdf/zip, 이미지, txt/json을 지원합니다.',
uploadMdTypeWarning: 'Markdown(.md) 파일과 이미지 파일만 지원됩니다.',
uploadFileError: '업로드 실패',
uploadConvertError: '변환 실패',
uploadBatchLimit: '한 번에 최대 10개 파일까지 업로드할 수 있습니다.',
uploadSizeLimit: '50MB 제한을 초과합니다.',
uploading: '업로드 중...',
uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'AI 활성화',
disableAI: 'AI 비활성화',
insertUrl: 'URL로 이미지 삽입',
@@ -445,41 +203,7 @@ export const translations = {
cancel: '취소',
imgTooLarge: '이미지가 너무 큽니다',
docTooLarge: '문서가 너무 큽니다, AI 비활성화됨',
initialMarkdown: '# LLM-IN-TEXT에 오신 것을 환영합니다\n\n즉시 사용할 수 있는 LLM 시스템\n\n아래에서 창작을 시작하세요...',
view: '보기',
editor: '에디터',
docs: '문서',
docsManagement: '문서 관리',
docsEmptyDesc: '문서 관리 화면은 개발 중입니다...',
files: '파일',
noFiles: '파일이 없습니다',
newFile: '새 파일',
newFolder: '새 폴더',
untitledFile: '제목 없음.md',
untitledFolder: '새 폴더',
rename: '이름 변경',
delete: '삭제',
copy: '복사',
cut: '잘라내기',
paste: '붙여넣기',
confirmDelete: '삭제 확인',
confirmDeleteDesc: '정말 삭제하시겠습니까',
confirmDeleteFolderDesc: '폴더의 모든 콘텐츠가 삭제됩니다.',
confirmDeleteFileDesc: '이 작업은 취소할 수 없습니다.',
cancel: '취소',
rootDir: '루트 디렉토리',
expandSidebar: '사이드바 펼치기',
collapseSidebar: '사이드바 접기',
fileLimitReached: '파일 수上限에 도달했습니다',
folderLimitReached: '폴더 수上限에 도달했습니다',
fileSizeLimit: '파일 크기는 50MB를 초과할 수 없습니다',
storageError: '저장 공간이 부족합니다',
selectFileToView: '파일을 선택하여 내용 보기',
folderContains: '포함',
items: '항목',
unsupportedPreview: '이 파일 유형은 미리보기를 지원하지 않습니다',
fileNamePlaceholder: '파일명.md',
folderNamePlaceholder: '폴더명'
initialMarkdown: '# LLM-IN-TEXT에 오신 것을 환영합니다\n\n즉시 사용할 수 있는 LLM 시스템\n\n아래에서 창작을 시작하세요...'
},
de: {
settings: 'Einstellungen',
@@ -504,13 +228,6 @@ export const translations = {
mediumDesc: 'Kurze Analyse vor Vorschlag',
highDesc: 'Tiefe schrittweise Analyse (Langsam, aber höchste Qualität)',
debounceTime: 'Entprellzeit',
proModeThinking: 'PRO denkt nach',
proMode: 'PRO-Modus',
proModel: 'PRO-Modell',
proModelPlaceholder: 'z.B. qwen3:32b',
proModelDesc: 'Optional staerker Modellname, der nur im PRO-Modus verwendet wird.',
proModelEmptyHint: 'Leer lassen, um das Standard-PRO-Modell des Backends zu verwenden.',
privacyPreferences: 'Datenschutz & Einstellungen',
privacyMode: 'Datenschutzmodus',
privacyDesc: 'Sende keine IP und Einstellungen an KI',
@@ -518,47 +235,16 @@ export const translations = {
auto: 'Automatisch',
currency: 'Währung',
about: 'Über uns',
template: 'Vorlage',
presetTemplates: 'Vorgabe-Vorlagen',
customTemplates: 'Benutzerdefinierte Vorlagen',
newTemplate: 'Neue Vorlage',
previewTemplate: 'Vorschau',
applyTemplate: 'Anwenden',
copyAsTemplate: 'Als benutzerdefinierte Vorlage kopieren',
editTemplate: 'Bearbeiten',
saveTemplate: 'Speichern',
templateName: 'Vorlagenname',
templateContent: 'Inhalt',
templateNamePlaceholder: 'z.B. Protokoll',
templateContentPlaceholder: 'Vorlageninhalt eingeben...',
noTemplates: 'Keine benutzerdefinierten Vorlagen',
templateNameRequired: 'Bitte Vorlagennamen eingeben',
templateContentRequired: 'Bitte Inhalt eingeben',
templateNameDuplicate: 'Vorlagenname existiert bereits',
templateDeleteConfirm: 'Diese Vorlage löschen?',
templateSaved: 'Gespeichert',
templateUpdated: 'Aktualisiert',
templateDeleted: 'Gelöscht',
importMd: 'Markdown importieren',
exportMd: 'Markdown exportieren',
exportDocx: 'DOCX exportieren',
exportPdf: 'PDF exportieren',
upload: 'Hochladen',
uploadImg: 'Bild hochladen',
uploadFile: 'Datei hochladen',
uploadDoc: 'Dokument hochladen',
exportDisabledHint: 'DOCX/PDF-Export ist vorübergehend nicht verfügbar.',
uploadDocTypeWarning: 'Nur txt, json, toml, yaml, docx, pptx, pdf Formate werden unterstützt.',
uploadDocSizeWarning: 'Dateigröße darf 10MB nicht überschreiten.',
uploadDocInBlockWarning: 'Kann kein Dokument innerhalb eines bestehenden Dokuments einfügen.',
uploadDocError: 'Dokumentkonversion fehlgeschlagen:',
uploadFileTypeWarning: 'Nicht unterstützter Dateityp. Unterstützt: doc/docx/ppt/pptx/pdf/zip, Bilder, txt/json.',
uploadMdTypeWarning: 'Nur Markdown(.md) und Bilddateien werden unterstützt.',
uploadFileError: 'Hochladen fehlgeschlagen',
uploadConvertError: 'Konversion fehlgeschlagen',
uploadBatchLimit: 'Maximal 10 Dateien auf einmal.',
uploadSizeLimit: 'Datei überschreitet das Limit von 50MB.',
uploading: 'Wird hochgeladen...',
uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'KI aktivieren',
disableAI: 'KI deaktivieren',
insertUrl: 'Bild per URL einfügen',
@@ -566,41 +252,7 @@ export const translations = {
cancel: 'Abbrechen',
imgTooLarge: 'Bild zu groß',
docTooLarge: 'Dokument zu groß, KI deaktiviert',
initialMarkdown: '# Willkommen bei LLM-IN-TEXT\n\nEin sofort verfügbares LLM-System\n\nStarten Sie Ihre kreative Arbeit unten...',
view: 'Ansicht',
editor: 'Editor',
docs: 'Dokumente',
docsManagement: 'Dokumentenverwaltung',
docsEmptyDesc: 'Die Dokumentenverwaltung ist in Entwicklung...',
files: 'Dateien',
noFiles: 'Keine Dateien',
newFile: 'Neue Datei',
newFolder: 'Neuer Ordner',
untitledFile: 'Unbenannt.md',
untitledFolder: 'Neuer Ordner',
rename: 'Umbenennen',
delete: 'Löschen',
copy: 'Kopieren',
cut: 'Ausschneiden',
paste: 'Einfügen',
confirmDelete: 'Löschen bestätigen',
confirmDeleteDesc: 'Möchten Sie wirklich löschen',
confirmDeleteFolderDesc: 'Dies entfernt alle Inhalte im Ordner.',
confirmDeleteFileDesc: 'Diese Aktion kann nicht rückgängig gemacht werden.',
cancel: 'Abbrechen',
rootDir: 'Stammverzeichnis',
expandSidebar: 'Seitenleiste erweitern',
collapseSidebar: 'Seitenleiste einklappen',
fileLimitReached: 'Maximale Dateianzahl erreicht',
folderLimitReached: 'Maximale Ordneranzahl erreicht',
fileSizeLimit: 'Dateigröße darf 50MB nicht überschreiten',
storageError: 'Speicherplatz unzureichend',
selectFileToView: 'Datei auswählen zum Anzeigen',
folderContains: 'Enthält',
items: 'Elemente',
unsupportedPreview: 'Dieser Dateityp wird nicht in der Vorschau unterstützt',
fileNamePlaceholder: 'Dateiname.md',
folderNamePlaceholder: 'Ordnername'
initialMarkdown: '# Willkommen bei LLM-IN-TEXT\n\nEin sofort verfügbares LLM-System\n\nStarten Sie Ihre kreative Arbeit unten...'
},
fr: {
settings: 'Paramètres',
@@ -625,13 +277,6 @@ export const translations = {
mediumDesc: 'Analyse brève avant suggestion',
highDesc: 'Analyse approfondie étape par étape (Le plus lent)',
debounceTime: 'Temps de rebond',
proModeThinking: 'PRO en réflexion',
proMode: 'Mode PRO',
proModel: 'Modèle PRO',
proModelPlaceholder: 'ex: qwen3:32b',
proModelDesc: 'Nom de modèle plus puissant utilisé uniquement par le mode PRO.',
proModelEmptyHint: 'Laissez vide pour utiliser le modèle PRO par défaut du backend.',
privacyPreferences: 'Confidentialité et préférences',
privacyMode: 'Mode confidentialité',
privacyDesc: 'Ne pas envoyer IP et préférences à l\'IA',
@@ -639,47 +284,16 @@ export const translations = {
auto: 'Détection auto',
currency: 'Devise',
about: 'À propos de nous',
template: 'Modèle',
presetTemplates: 'Modèles prédéfinis',
customTemplates: 'Modèles personnalisés',
newTemplate: 'Nouveau modèle',
previewTemplate: 'Aperçu',
applyTemplate: 'Appliquer',
copyAsTemplate: 'Copier comme modèle personnalisé',
editTemplate: 'Modifier',
saveTemplate: 'Enregistrer',
templateName: 'Nom du modèle',
templateContent: 'Contenu',
templateNamePlaceholder: 'ex: Compte-rendu',
templateContentPlaceholder: 'Entrez le contenu du modèle...',
noTemplates: 'Aucun modèle personnalisé',
templateNameRequired: 'Veuillez entrer un nom de modèle',
templateContentRequired: 'Veuillez entrer le contenu',
templateNameDuplicate: 'Le nom du modèle existe déjà',
templateDeleteConfirm: 'Supprimer ce modèle ?',
templateSaved: 'Enregistré',
templateUpdated: 'Mis à jour',
templateDeleted: 'Supprimé',
importMd: 'Importer Markdown',
exportMd: 'Exporter Markdown',
exportDocx: 'Exporter DOCX',
exportPdf: 'Exporter PDF',
upload: 'Télécharger',
uploadImg: 'Télécharger image',
uploadFile: 'Importer un fichier',
uploadDoc: 'Télécharger document',
exportDisabledHint: 'L\'export DOCX/PDF est temporairement indisponible.',
uploadDocTypeWarning: 'Seuls les formats txt, json, toml, yaml, docx, pptx, pdf sont pris en charge.',
uploadDocSizeWarning: 'La taille du fichier ne peut pas dépasser 10 Mo.',
uploadDocInBlockWarning: 'Impossible d\'insérer un document à l\'intérieur d\'un bloc existant.',
uploadDocError: 'Échec de la conversion du document :',
uploadFileTypeWarning: 'Type de fichier non pris en charge. Supporté : doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Seuls les fichiers Markdown(.md) et images sont pris en charge.',
uploadFileError: 'Échec du téléchargement',
uploadConvertError: 'Échec de la conversion',
uploadBatchLimit: 'Maximum 10 fichiers à la fois.',
uploadSizeLimit: 'Fichier dépasse la limite de 50 Mo.',
uploading: 'Téléchargement en cours...',
uploadFile: 'Upload File',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.',
uploadConvertError: 'File conversion failed.',
enableAI: 'Activer IA',
disableAI: 'Désactiver IA',
insertUrl: 'Insérer image via URL',
@@ -687,40 +301,6 @@ export const translations = {
cancel: 'Annuler',
imgTooLarge: 'Image trop grande',
docTooLarge: 'Document trop grand, IA désactivée',
initialMarkdown: '# Bienvenue sur LLM-IN-TEXT\n\nUn système LLM instantané\n\nCommencez votre création ci-dessous...',
view: 'Vue',
editor: 'Éditeur',
docs: 'Documents',
docsManagement: 'Gestion des documents',
docsEmptyDesc: 'L\'interface de gestion des documents est en développement...',
files: 'Fichiers',
noFiles: 'Aucun fichier',
newFile: 'Nouveau fichier',
newFolder: 'Nouveau dossier',
untitledFile: 'Sans titre.md',
untitledFolder: 'Nouveau dossier',
rename: 'Renommer',
delete: 'Supprimer',
copy: 'Copier',
cut: 'Couper',
paste: 'Coller',
confirmDelete: 'Confirmer la suppression',
confirmDeleteDesc: 'Êtes-vous sûr de vouloir supprimer',
confirmDeleteFolderDesc: 'Cela supprimera tout le contenu du dossier.',
confirmDeleteFileDesc: 'Cette action est irréversible.',
cancel: 'Annuler',
rootDir: 'Répertoire racine',
expandSidebar: 'Développer la barre latérale',
collapseSidebar: 'Réduire la barre latérale',
fileLimitReached: 'Nombre maximum de fichiers atteint',
folderLimitReached: 'Nombre maximum de dossiers atteint',
fileSizeLimit: 'La taille du fichier ne peut pas dépasser 50 Mo',
storageError: 'Espace de stockage insuffisant',
selectFileToView: 'Sélectionnez un fichier pour afficher le contenu',
folderContains: 'Contient',
items: 'éléments',
unsupportedPreview: 'Ce type de fichier n\'est pas pris en charge pour l\'aperçu',
fileNamePlaceholder: 'Nom du fichier.md',
folderNamePlaceholder: 'Nom du dossier'
initialMarkdown: '# Bienvenue sur LLM-IN-TEXT\n\nUn système LLM instantané\n\nCommencez votre création ci-dessous...'
}
}

View File

@@ -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_INSTRUCTION_PREFIX = '[PRO]{'
const PRO_INSTRUCTION_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(instruction = '') {
const normalized = normalizeMarkdownText(instruction).trim()
if (!normalized) return PRO_TRIGGER_TEXT
return `${PRO_INSTRUCTION_PREFIX}${escapeProBlockContent(normalized)}${PRO_INSTRUCTION_SUFFIX}`
}
export function parseProBlockSyntax(value = '') {
const text = normalizeMarkdownText(value).trim()
if (!text) return null
if (PRO_TRIGGER_RE.test(text)) {
return {
instruction: '',
autoStart: false,
}
}
const prefix = text.slice(0, PRO_INSTRUCTION_PREFIX.length)
if (prefix.toLowerCase() === PRO_INSTRUCTION_PREFIX.toLowerCase() && text.endsWith(PRO_INSTRUCTION_SUFFIX)) {
return {
instruction: unescapeProBlockContent(
text.slice(PRO_INSTRUCTION_PREFIX.length, text.length - PRO_INSTRUCTION_SUFFIX.length)
).trim(),
autoStart: false,
}
}
return null
}

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -8,7 +8,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/v1': {
target: 'https://api.imageteach.tech:8002',
target: 'http://localhost:8001',
changeOrigin: true
}
}