feat: enhance Milkdown editor and file system functionality

- Normalize line endings in Markdown export for DOCX files.
- Improve selection serialization to Markdown with better handling of empty documents.
- Add a new `updateFile` function to the file system for updating file properties.
- Introduce video transcoding capabilities using FFmpeg, supporting various video formats.
- Update AGENTS.md for clearer plugin structure and responsibilities.
- Add scoped styles for TreeNodeItem component to improve UI consistency.
- Implement cross-origin isolation headers in Vite configuration for enhanced security.
- Remove obsolete test_cross.py file.
This commit is contained in:
2026-05-01 20:55:02 +08:00
parent 52ade88840
commit 70152c61b1
43 changed files with 3911 additions and 1373 deletions

View File

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

BIN
.coverage Normal file

Binary file not shown.

2
.gitignore vendored
View File

@@ -23,6 +23,8 @@ env/
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
api_performance_report.md
# Env files
.env

View File

@@ -1,52 +0,0 @@
# 导出按钮缺失修复计划
## 问题分析
当前 `action-buttons` 区域只有以下按钮可见:
- 上传文件
- 导入 Markdown
- 导出 Markdown
- 上传图片
- AI 切换按钮
**缺失功能**DOCX 和 PDF 导出按钮
## 调查结果
1. ✅ 翻译文件中已存在 `exportDocx``exportPdf` 键名src/utils/i18n.js
2. ❌ 模板中**完全缺失**这两个按钮的 HTML 代码
3. ❓ 导出功能后端已实现,前端只需要添加调用接口的按钮
4. ✅ 相关 CSS 样式已存在,按钮外观无需额外调整
## 实施计划
### 1. 添加 UI 按钮
`src/components/MilkdownEditor.vue:79` 之后添加两个新按钮:
- DOCX 导出按钮
- PDF 导出按钮
按钮位置:
```
导出 Markdown → 导出 DOCX → 导出 PDF → 上传图片
```
### 2. 实现前端导出功能
使用已安装的依赖库:
- `docx` 库:用于 DOCX 导出
- `html2pdf.js` 库:用于 PDF 导出
需要添加的函数:
```javascript
const exportDocx = async () => {
// 使用 docx 库实现导出
}
const exportPdf = async () => {
// 使用 html2pdf.js 实现导出
}
```
### 3. 按钮图标
- DOCX使用文档图标
- PDF使用 PDF 专用图标
### 4. 状态管理
添加加载状态和错误处理,与现有按钮保持一致风格

View File

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

155
AGENTS.md
View File

@@ -1,90 +1,101 @@
# LLM in Text 项目知识库
# LLM in Text 仓库指引
**生成时间:** 2025-04-10
**Commit:** 2fdc996
**Branch:** main
本文件适用于整个仓库。进入更深层目录后,子目录中的 AGENTS.md 优先于本文件。
## 概述
## 项目定位
智能 Markdown 编辑器,集成 LLM 实时补全建议。前端 Vue3 + Vite + Milkdown后端 FastAPI + Python + Ollama。核心功能AI 补全、OCR 图片识别、文转换TTS/ASR 语音功能
- 这是一个智能 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、离线模式说明已经落后于当前代码出现冲突时以实际代码和测试为准。
## 结构
## 先看哪里
```
llm-in-text/
├── backend/ # FastAPI 后端 (Python)
│ ├── main.py # API 入口,路由定义
│ ├── llm.py # Ollama 调用封装
│ ├── prompt.py # Prompt 构建逻辑
│ ├── prompts/ # JSON 格式的提示模板
│ └── tests/ # pytest 测试套件
├── src/ # 前端源码 (Vue3 + Vite)
│ ├── main.js # Vue 入口
│ ├── App.vue # 根组件
│ ├── components/ # Vue 组件
│ ├── plugins/ # Milkdown/Copilot 插件
│ ├── stores/ # Pinia 状态管理
│ ├── views/ # 页面视图
│ └── utils/ # 工具函数
├── public/ # 静态资源
├── milkdown-docs/ # Milkdown 官方文档(只读)
└── index.html # HTML 入口
```
- 项目概览和运行说明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
## 查找指南
## 稳定事实
| 任务 | 位置 | 说明 |
|------|------|------|
| 后端 API 入口 | `backend/main.py` | FastAPI 路由、CORS、启动逻辑 |
| LLM 调用 | `backend/llm.py` | Ollama 异步调用、超时控制 |
| Prompt 构建 | `backend/prompt.py` | 系统提示、上下文准备 |
| AI 补全核心 | `src/plugins/copilotPlugin.ts` | ProseMirror Mark、ghost text |
| 编辑器组件 | `src/components/MilkdownEditor.vue` | Milkdown 编辑器封装 |
| 状态管理 | `src/stores/settings.js` | 用户设置、主题、偏好 |
| API 调用 | `src/utils/api.js` | fetchSuggestion、TTS 接口 |
| 测试运行 | `pytest.ini` + `backend/tests/` | 测试配置与用例 |
- 补全接口当前不是 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 的描述。
## 约定(项目特定)
## 常用命令
- **前端入口**`src/main.js`(非 TypeScript使用 Vue3 + Pinia + Vue Router
- **后端入口**`backend/main.py`,端口 8001uvicorn 启动
- **代理配置**:开发时 `/v1` 代理到远程 API生产需调整
- **文件命名**:全小写+短横线(`my-module.py``my-component.vue`
- **语言**UI 默认中文,响应必须使用中文
- 前端安装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
## 反模式(本项目禁止)
## 代码约定
- ❌ 硬编码 API_KEY必须从环境变量读取
- ❌ 在前端暴露密钥(应通过后端代理)
- `npm run dev` 运行网页(无法看到内容)
- ❌ 修改 `milkdown-docs/` 目录
- ❌ 类型错误使用 `as any` / `@ts-ignore`
- ❌ 空的 catch 块
- 不要把整个仓库当成“全小写+短横线命名”项目。当前实际情况是:
- Vue 组件和视图多为 PascalCase
- 前端工具模块多为小写 .js
- 插件层使用 TypeScript
- Python 使用 snake_case
- 以就地风格为准,不要顺手做全仓格式统一。
- UI 文案和代理回复默认使用中文。
- 不要修改 milkdown-docs/,它是只读参考资料。
- 不要新增硬编码密钥、空 catch/except、as any、@ts-ignore 之类的扩散式技术债。
- 代理在这个仓库里应优先做局部、可验证的修改,不要做无关重构。
## 命令
## 调试路径
```bash
# 前端开发
npm install
npm run dev # 端口 5173
npm run build # 构建到 dist/
- 补全问题:
src/components/MilkdownEditor.vue
-> src/plugins/copilotPlugin.ts
-> src/utils/api.js
-> backend/main.py
-> backend/prompt.py / backend/llm.py
# 后端运行
pip install -r backend/requirements.txt
python backend/main.py # 端口 8001
# 或
uvicorn backend.main:app --reload --port 8001
- OCR 问题:
src/components/MilkdownEditor.vue
-> backend/main.py
-> backend/llm.py
# 测试
pytest # 运行所有测试,覆盖率要求 90%
python backend/tests/run_tests.py unit # 单元测试
python backend/tests/run_tests.py integration # 集成测试
```
- 文档转换问题:
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
- **架构分离**:前端仅渲染和数据回传,后端负责 LLM API 调用和数据解析
- **延迟优先**:代码效率优先,降低延迟放在第一位
- **大小限制**:文档超过 32KB 自动禁用 AI 补全
- **milkdown-docs/**:官方文档参考,不可修改,编辑器相关问题需核对此目录
## 测试和产物
- 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 更适合作为历史背景,不应在与代码冲突时被当成事实来源。
- 修改行为时,优先参考实现代码和对应测试,再决定是否同步普通文档。

View File

@@ -1,40 +1,121 @@
# Backend 模块指南
# Backend 后端指引
## OVERVIEW
FastAPI 后端,处理 AI 补全、OCR、文档转换、TTS/ASR。
本文件适用于 backend/ 下的后端实现。进入 backend/tests/ 后,以子目录 AGENTS.md 为准。
## STRUCTURE
- main.py - API 入口、路由、CORS、启动逻辑
- llm.py - Ollama 异步调用、超时控制、日志
- prompt.py - Prompt 构建、上下文准备、语言处理
- geoip.py - IP 地理位置查询
- tts_asr.py - TTS/ASR 处理、Apple Silicon 优化
- prompts/ - JSON 格式提示模板PromptManager 单例)
- tests/ - pytest 测试套件(见子目录 AGENTS.md
## 后端职责
## WHERE TO LOOK
- 对外提供补全、取消补全、OCR、文档转换和 TTS 相关接口。
- 组织 Prompt上下文清洗调用 Ollama 模型。
- 负责 API Key 校验、日志记录和部分启动预热逻辑。
| 任务 | 文件 | 说明 |
|------|------|------|
| API 路由定义 | main.py | /v1/completions、/v1/ocr、/v1/convert 等 |
| LLM 调用封装 | llm.py | call_ollama、call_vlm_ocr、超时控制 |
| Prompt 构建 | prompt.py | build_completion_prompts、语言处理 |
| 提示模板 | prompts/__init__.py | PromptManager、JSON 模板加载 |
| TTS/ASR | tts_asr.py | 模型预热、设备检测、音频处理 |
| 测试 | tests/ | pytest 测试套件 |
## 先看哪里
## CONVENTIONS
- Python 4 空格缩进
- 函数/变量snake_case
- PascalCase
- 文件名:全小写+短横线
- API 入口和路由main.py
- Ollama 调用封装llm.py
- Prompt 清洗和拼装prompt.py
- 数据模型models.py
- 地理位置geoip.py
- TTS 路由tts_asr.py
- Prompt 模板prompts/
- 后端测试tests/
## ANTI-PATTERNS
- 硬编码 API_KEY必须从环境变量读取
- 空 catch 块
- 类型错误使用 as any / @ts-ignore
## 当前接口面
## 注意事项
- 端口8001
- 启动:`python backend/main.py``uvicorn backend.main:app --reload`
- 依赖:`pip install -r backend/requirements.txt`
- 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,196 +0,0 @@
# API Benchmarking Report (2026-04-06 13:45:31)
**Base URL:** `https://api.imageteach.tech:8002`
## Executive Summary
| Task | Success Rate | Avg TTFB | Avg Latency | P95 Latency | TPS | RPS |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| Completion-Short | 90.0% | 9123.6ms | 9123.8ms | 20222.9ms | 7.9 | 0.08 |
| Completion-Normal | 10.0% | 10559.3ms | 10559.6ms | 10559.6ms | 66.4 | 0.66 |
| Completion-Long | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 8.97 |
| OCR-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 6.75 |
| TTS-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 10.17 |
| ASR-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 13.02 |
| Convert-Concurrent | 0.0% | 0.0ms | 0.0ms | 0.0ms | 0.0 | 5.98 |
## Stability & Context Analysis
Detailed analysis of how context length affects TTFB and overall performance.
### Completion-Short Details
- **Total Samples:** 10
- **Duration:** 123.93s
- **Top Errors:**
- `[0]`
### Completion-Normal Details
- **Total Samples:** 10
- **Duration:** 15.21s
- **Top Errors:**
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
### Completion-Long Details
- **Total Samples:** 10
- **Duration:** 1.11s
- **Top Errors:**
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
### OCR-Concurrent Details
- **Total Samples:** 10
- **Duration:** 1.48s
- **Top Errors:**
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
### TTS-Concurrent Details
- **Total Samples:** 10
- **Duration:** 0.98s
- **Top Errors:**
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</body>
</html>
- `[502]` <html>
<head><title>502 Bad Gateway</title></head>

View File

@@ -1,4 +1,4 @@
import asyncio
import asyncio
import base64
import logging
import os
@@ -9,9 +9,9 @@ import tempfile
import uuid
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security, File, UploadFile
from fastapi import FastAPI, HTTPException, Request, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from fastapi.responses import JSONResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
@@ -133,8 +133,7 @@ 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()
return value
def get_client_ip(request: Request) -> str:

View File

@@ -302,23 +302,24 @@ 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
=== BOUNDARY DECISION GUIDE ===
Step 1: Check PREFIX_ENDS_WITH_NEWLINE
If false, ask: "Does output need to start on a new line?"
- YES if PREFIX ends with: ":", "steps:", "items:", heading text, or complete sentence before heading
- If YES: start output with \\n
- If YES: make the first character of OUTPUT a real newline
Step 2: Check SUFFIX_STARTS_WITH_NEWLINE
If false, ask: "Does output need to end with a newline?"
- YES if SUFFIX starts with: heading (##), new paragraph, or list marker
- If YES: end output with \\n
- If YES: make the last character of OUTPUT a real newline
Step 3: Choose newline type
- Use \\n\\n for: new paragraphs, before headings, starting lists
- Use \\n for: continuing within blocks, list items, table cells
- Exception: inside code fences, use \\n freely
- Use a blank line for: new paragraphs, before headings, starting lists
- Use a single line break for: continuing within blocks, list items, table cells
- Exception: inside code fences, use real newline characters freely
=== CONTEXT NOTES ===
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output

View File

@@ -1,3 +1,3 @@
{
"template": "You are an inline completion engine for a {language_id} editor with ghost-text suggestions.\n\nReturn only the insertion text that should be placed between PREFIX and SUFFIX.\n\nCORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.\n\nPRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)\n- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?\n- CURSOR_FENCE_LANGUAGE: What language is the current fence?\n- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?\n- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?\n- MERMAID_CONTEXT: Is this a Mermaid diagram context?\n\nPRIORITY 2: SPECIALIZED CONTENT RULES\n\n2.1 Code Block Handling:\nIf CURSOR_IN_FENCED_CODE_BLOCK=true:\n- You are inside a code fence\n- Output code lines ONLY (no triple backticks)\n- Use single \\n for code line separation\n\nIf CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:\n- Wrap code in fenced block with language tag:\n```{language}\ncode here\n```\n- Never use inline backticks for code snippets\n\n2.2 Math Formatting (KaTeX):\n- Inline math: wrap with $...$\n- Block math: wrap with $$...$$\n- Never output bare formulas\n- Exception: inside latex/tex/katex fence, output raw LaTeX\n\n2.3 Mermaid Diagrams:\nIf CURSOR_FENCE_LANGUAGE=mermaid:\n- Output Mermaid syntax ONLY\n- No backticks, no explanations\n\nIf MERMAID_CONTEXT=true and outside fence:\n- Output complete fenced block:\n```mermaid\ndiagram syntax\n```\n\nPRIORITY 3: MARKDOWN STRUCTURE\n\n3.1 Newline Semantics:\n- Single \\n: soft break (same paragraph, renders as space or <br>)\n- Double \\n\\n: hard break (new paragraph/block)\n- Use \\n\\n for: new paragraphs, before headings, starting lists/tables\n- Use \\n for: continuation within blocks (list items, table cells)\n- Exception: inside code blocks, use \\n freely for code lines\n\n3.2 Boundary Management:\nCheck PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:\n- If PREFIX lacks needed newline: start OUTPUT with \\n\n- If SUFFIX lacks needed newline: end OUTPUT with \\n\n- Common cases requiring leading \\n:\n* Starting a list after \"Steps:\"\n* Creating new paragraph after text\n* Adding heading after paragraph\n- Common cases requiring trailing \\n:\n* Before new heading\n* End of section\n\n3.3 Context Stitching:\n- Never repeat text from SUFFIX beginning\n- Match PREFIX tone, style, indentation\n- Continue structures: lists, tables, quotes, headings\n\nPRIORITY 4: HIDDEN CONTEXT\n- OCR metadata like <OCR:...> is hidden context\n- Never copy OCR tags to output\n- Use OCR content as semantic hint only"
"template": "You are an inline completion engine for a {language_id} editor with ghost-text suggestions.\n\nReturn only the insertion text that should be placed between PREFIX and SUFFIX.\n\nCORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.\n\nPRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)\n- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?\n- CURSOR_FENCE_LANGUAGE: What language is the current fence?\n- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?\n- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?\n- MERMAID_CONTEXT: Is this a Mermaid diagram context?\n\nPRIORITY 2: SPECIALIZED CONTENT RULES\n\n2.1 Code Block Handling:\nIf CURSOR_IN_FENCED_CODE_BLOCK=true:\n- You are inside a code fence\n- Output code lines ONLY (no triple backticks)\n- Separate code lines with actual newline characters\n\nIf CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:\n- Wrap code in fenced block with language tag:\n```{language}\ncode here\n```\n- Never use inline backticks for code snippets\n\n2.2 Math Formatting (KaTeX):\n- Inline math: wrap with $...$\n- Block math: wrap with $$...$$\n- Never output bare formulas\n- Exception: inside latex/tex/katex fence, output raw LaTeX\n\n2.3 Mermaid Diagrams:\nIf CURSOR_FENCE_LANGUAGE=mermaid:\n- Output Mermaid syntax ONLY\n- No backticks, no explanations\n\nIf MERMAID_CONTEXT=true and outside fence:\n- Output complete fenced block:\n```mermaid\ndiagram syntax\n```\n\nPRIORITY 3: MARKDOWN STRUCTURE\n\n3.1 Newline Semantics:\n- Use actual line breaks in output, not spelled-out escape sequences, unless the surrounding content explicitly needs that text\n- A single line break usually continues the current block\n- A blank line starts a new paragraph or block\n- Use blank lines for: new paragraphs, before headings, starting lists/tables\n- Use single line breaks for: continuation within blocks (list items, table cells)\n- Exception: inside code blocks, use actual newline characters freely for code lines\n\n3.2 Boundary Management:\nCheck PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:\n- If PREFIX lacks needed newline: start OUTPUT on a new line\n\n- If SUFFIX lacks needed newline: end OUTPUT with a trailing line break\n\n- Common cases requiring a leading line break:\n* Starting a list after \"Steps:\"\n* Creating new paragraph after text\n* Adding heading after paragraph\n- Common cases requiring a trailing line break:\n* Before new heading\n* End of section\n\n3.3 Context Stitching:\n- Never repeat text from SUFFIX beginning\n- Match PREFIX tone, style, indentation\n- Continue structures: lists, tables, quotes, headings\n\nPRIORITY 4: HIDDEN CONTEXT\n- OCR metadata like <OCR:...> is hidden context\n- Never copy OCR tags to output\n- Use OCR content as semantic hint only"
}

View File

@@ -1,295 +0,0 @@
import asyncio
import base64
import json
import logging
import time
import argparse
import uuid
import sys
import statistics
import os
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple
import httpx
from pydantic import BaseModel
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("api_benchmarker")
# Constants
DEFAULT_BASE_URL = "http://localhost:8001"
DEFAULT_API_KEY = "your-secret-key-here"
CHARS_PER_TOKEN = 4
# Data Generators
def get_dummy_base64_image():
return "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
def get_dummy_base64_audio():
# A bit longer dummy audio to pass validation (44 bytes header + some data)
return "UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQAAAAAAA" + "A" * 100 + "=="
def generate_context_text(tokens: int) -> str:
"""Generate synthetic text of approximately 'tokens' tokens."""
base_phrase = "The quick brown fox jumps over the lazy dog. "
repeat_count = (tokens * CHARS_PER_TOKEN) // len(base_phrase) + 1
return (base_phrase * repeat_count)[:tokens * CHARS_PER_TOKEN]
# Metric Models
class RequestMetric(BaseModel):
task_name: str
endpoint: str
status_code: int
ttfb_ms: float
total_ms: float
success: bool
tokens: int
error: Optional[str] = None
class BenchStats:
def __init__(self, name: str):
self.name = name
self.metrics: List[RequestMetric] = []
self.start_time = 0.0
self.end_time = 0.0
def add(self, m: RequestMetric):
self.metrics.append(m)
def get_summary(self) -> Dict[str, Any]:
if not self.metrics:
return {}
total = len(self.metrics)
successes = [m for m in self.metrics if m.success]
success_count = len(successes)
fail_count = total - success_count
total_latencies = [m.total_ms for m in successes] if successes else [0]
ttfb_latencies = [m.ttfb_ms for m in successes] if successes else [0]
duration = self.end_time - self.start_time
total_tokens = sum(m.tokens for m in successes)
return {
"name": self.name,
"total_requests": total,
"success_rate": (success_count / total) * 100 if total > 0 else 0,
"avg_latency": statistics.mean(total_latencies),
"p50_latency": statistics.median(total_latencies),
"p95_latency": sorted(total_latencies)[int(len(total_latencies)*0.95)] if total_latencies else 0,
"avg_ttfb": statistics.mean(ttfb_latencies),
"tps": total_tokens / duration if duration > 0 else 0,
"rps": total / duration if duration > 0 else 0,
"duration": duration
}
# Benchmarking Engine
class ApiBenchmarker:
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url
self.api_key = api_key
self.headers = {"X-API-Key": api_key}
self.semaphores = {
"completions": asyncio.Semaphore(5),
"ocr": asyncio.Semaphore(2),
"convert": asyncio.Semaphore(2),
"tts-asr": asyncio.Semaphore(3)
}
self.results: Dict[str, BenchStats] = {}
async def _execute_request(self, client: httpx.AsyncClient, name: str, method: str, path: str, **kwargs) -> RequestMetric:
url = f"{self.base_url}{path}"
start = time.perf_counter()
ttfb = 0.0
tokens_count = 0
# Estimate input + output tokens (mock for output)
if "json" in kwargs:
input_text = str(kwargs["json"].get("prefix", "")) + str(kwargs["json"].get("text", ""))
tokens_count += len(input_text) // CHARS_PER_TOKEN
try:
async with client.stream(method, url, **kwargs) as response:
ttfb = (time.perf_counter() - start) * 1000
body = await response.aread()
total_ms = (time.perf_counter() - start) * 1000
success = 200 <= response.status_code < 300
error_msg = None
if not success:
error_msg = body.decode(errors="ignore")[:200]
else:
# Estimate output tokens from response content
try:
resp_json = json.loads(body)
content = resp_json.get("content", "") or resp_json.get("text", "") or resp_json.get("markdown", "")
tokens_count += len(content) // CHARS_PER_TOKEN
except:
pass
return RequestMetric(
task_name=name,
endpoint=path,
status_code=response.status_code,
ttfb_ms=ttfb,
total_ms=total_ms,
success=success,
tokens=tokens_count,
error=error_msg
)
except Exception as e:
total_ms = (time.perf_counter() - start) * 1000
return RequestMetric(
task_name=name,
endpoint=path,
status_code=0,
ttfb_ms=ttfb or total_ms,
total_ms=total_ms,
success=False,
tokens=tokens_count,
error=str(e)
)
async def run_task(self, client: httpx.AsyncClient, task_type: str, name: str, iterations: int):
if name not in self.results:
self.results[name] = BenchStats(name)
stats = self.results[name]
stats.start_time = time.perf_counter()
sem = self.semaphores.get(task_type, self.semaphores["completions"])
async def worker():
async with sem:
if task_type == "completions":
# Stability Test Variation
prefix_len = 100
if "Normal" in name: prefix_len = 1000
if "Long" in name: prefix_len = 4000
metric = await self._execute_request(client, name, "POST", "/v1/completions", json={
"prefix": generate_context_text(prefix_len),
"suffix": "End of document.",
"model_thinking": "low"
})
elif task_type == "ocr":
metric = await self._execute_request(client, name, "POST", "/v1/ocr", json={
"image": get_dummy_base64_image(),
"filename": "bench.png"
})
elif task_type == "convert":
metric = await self._execute_request(client, name, "POST", "/v1/convert", json={
"file": base64.b64encode(b"Performance test data").decode(),
"filename": "bench.txt"
})
elif task_type == "tts":
metric = await self._execute_request(client, name, "POST", "/v1/tts-asr/tts", json={
"text": "This is a performance benchmark for the text to speech engine.",
"voice": "v2/en_speaker_6",
"format": "wav"
})
elif task_type == "asr":
metric = await self._execute_request(client, name, "POST", "/v1/tts-asr/asr", json={
"audio_base64": get_dummy_base64_audio(),
"language": "en"
})
else:
metric = await self._execute_request(client, name, "GET", "/v1/tts-asr/status")
stats.add(metric)
tasks = [worker() for _ in range(iterations)]
await asyncio.gather(*tasks)
stats.end_time = time.perf_counter()
def generate_report(self, output_file: str):
report = []
report.append(f"# API Benchmarking Report ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})")
report.append(f"\n**Base URL:** `{self.base_url}`")
# Summary Table
report.append("\n## Executive Summary")
report.append("| Task | Success Rate | Avg TTFB | Avg Latency | P95 Latency | TPS | RPS |")
report.append("| :--- | :--- | :--- | :--- | :--- | :--- | :--- |")
for name, stats in self.results.items():
s = stats.get_summary()
if not s: continue
report.append(f"| {s['name']} | {s['success_rate']:.1f}% | {s['avg_ttfb']:.1f}ms | {s['avg_latency']:.1f}ms | {s['p95_latency']:.1f}ms | {s['tps']:.1f} | {s['rps']:.2f} |")
# Stability Analysis
report.append("\n## Stability & Context Analysis")
report.append("Detailed analysis of how context length affects TTFB and overall performance.")
# Details per category
for name, stats in self.results.items():
s = stats.get_summary()
if not s: continue
report.append(f"\n### {name} Details")
report.append(f"- **Total Samples:** {s['total_requests']}")
report.append(f"- **Duration:** {s['duration']:.2f}s")
failures = [m for m in stats.metrics if not m.success]
if failures:
report.append(f"- **Top Errors:**")
for f in failures[:3]:
report.append(f" - `[{f.status_code}]` {f.error}")
with open(output_file, "w", encoding="utf-8") as f:
f.write("\n".join(report))
logger.info(f"Report generated: {output_file}")
async def main():
parser = argparse.ArgumentParser(description="Advanced LLM API Benchmarker")
parser.add_argument("--url", default=DEFAULT_BASE_URL, help="Base URL")
parser.add_argument("--key", default=DEFAULT_API_KEY, help="API Key")
parser.add_argument("--c-comp", type=int, default=5, help="Completion Concurrency")
parser.add_argument("--c-ocr", type=int, default=2, help="OCR Concurrency")
parser.add_argument("--c-audio", type=int, default=2, help="TTS/ASR Concurrency")
parser.add_argument("--iters", type=int, default=10, help="Iterations per test suite")
parser.add_argument("--output", default="api_performance_report.md", help="Output report file")
args = parser.parse_args()
bench = ApiBenchmarker(args.url, args.key)
bench.semaphores["completions"] = asyncio.Semaphore(args.c_comp)
bench.semaphores["ocr"] = asyncio.Semaphore(args.c_ocr)
bench.semaphores["tts-asr"] = asyncio.Semaphore(args.c_audio)
async with httpx.AsyncClient(headers=bench.headers, timeout=120.0) as client:
logger.info("Starting Benchmark Suites...")
# Suite 1: Stability - Completion Contexts
logger.info("Running Stability Suite (Short Context)...")
await bench.run_task(client, "completions", "Completion-Short", args.iters)
logger.info("Running Stability Suite (Normal Context)...")
await bench.run_task(client, "completions", "Completion-Normal", args.iters)
logger.info("Running Stability Suite (Long Context)...")
await bench.run_task(client, "completions", "Completion-Long", args.iters)
# Suite 2: Functional Concurrency
logger.info("Running OCR Concurrency Suite...")
await bench.run_task(client, "ocr", "OCR-Concurrent", args.iters)
logger.info("Running TTS Concurrency Suite...")
await bench.run_task(client, "tts", "TTS-Concurrent", args.iters)
logger.info("Running ASR Concurrency Suite...")
await bench.run_task(client, "asr", "ASR-Concurrent", args.iters)
logger.info("Running File Transformation Suite...")
await bench.run_task(client, "convert", "Convert-Concurrent", args.iters)
bench.generate_report(args.output)
print(f"\nBenchmark Complete! View the report at: {args.output}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,80 +0,0 @@
"""
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,45 +1,62 @@
OVERVIEW: pytest 测试套件,覆盖率要求 90%
STRUCTURE
- test_*.py - 各模块测试
- run_tests.py - 测试执行脚本unit/integration/all
- simulate_macos.py - macOS 环境模拟
- TESTING_GUIDE.md - 测试指南文档
# Backend Tests 测试指引
WHERE TO LOOK
表格
本文件适用于 backend/tests/ 下的测试和测试脚本。
| Area | Path |
|---|---|
| 单元测试 | backend/tests/ |
| 集成测试 | backend/tests/ |
| 测试执行脚本 | backend/tests/run_tests.py |
| macOS 模拟 | backend/tests/simulate_macos.py |
| 测试指南 | backend/tests/TESTING_GUIDE.md |
## 测试入口
运行命令:
- pytest - 运行所有测试
- python backend/tests/run_tests.py unit - 单元测试
- python backend/tests/run_tests.py integration - 集成测试
- pytest.ini 指定默认测试目录为 backend/tests并设置后端覆盖率门槛为 90%。
- run_tests.py 提供 unit、integration、simulate、all 几种快捷入口。
- 默认优先使用 pytest 跑窄测试;只有在需要脚本封装参数时再用 run_tests.py。
测试命名约定test_*.py、Test* 类、test_* 函数
## 测试分布
ANTI-PATTERNS删除测试以通过覆盖率
- 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人工验证或辅助脚本
验证
- 保证测试覆盖率≥90% 时,报告合格
- 使用 CI 运行 pytest确保通过率
## 常用命令
注意事项
- 不要重复父目录内容
- 不要超过 60 行
- 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
测试应尽量独立,不要依赖全局状态
- 运行单元测试时应使用 unit 标签
- 运行集成测试时应使用 integration 标签
## 测试原则
区分环境
- unit 测试尽量快速、稳定
- integration 测试应覆盖接口和数据库交互
- 优先跑与改动直接对应的窄测试,不要动不动全量跑。
- 单元测试尽量 mock 掉外部依赖,不要直连真实 Ollama。
- 涉及 main.py 时,优先用 monkeypatch 或 fake 对象替代:
- call_ollama
- call_vlm_ocr
- MarkItDown
- GeoIP 查询
- TTS 模型加载
- 测试要保持确定性,不依赖全局状态、环境顺序或人工输入。
维护
- 如扩展新模块,优先增加 test_*.py 文件并在其中添加对应的测试类和方法
## 容易误判的点
- 覆盖率门槛是针对多个 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,37 +0,0 @@
import asyncio
import base64
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from backend.tts_asr import _tts_sync_with_retry
async def play_audio():
print("生成测试音频中,请稍候...")
test_text = "这是一段用以测试新语音模型音质的中文合成音频。"
try:
audio_bytes, sr = await _tts_sync_with_retry(test_text, rate=1.0)
# 保存到本地文件
wav_path = os.path.join(os.path.dirname(__file__), "test_audio.wav")
with open(wav_path, "wb") as f:
f.write(audio_bytes)
print(f"音频已生成并保存到: {wav_path}")
print("正在尝试在 macOS 上播放...")
# Mac OS 的播放命令
os.system(f"afplay '{wav_path}'")
print("播放完成。")
except Exception as e:
import traceback
traceback.print_exc()
print(f"音频生成失败: {str(e)}")
if __name__ == "__main__":
asyncio.run(play_audio())

View File

@@ -12,7 +12,6 @@ macOS环境模拟测试工具
"""
import argparse
import importlib
import os
import platform
import sys

Binary file not shown.

View File

@@ -1,5 +1,4 @@
import sys
import os
import types
import pathlib
import pytest

View File

@@ -1,7 +1,7 @@
import os
import sys
import base64
import asyncio
import types
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
@@ -11,6 +11,10 @@ BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if BACKEND_DIR not in sys.path:
sys.path.insert(0, BACKEND_DIR)
fake_tts_asr = types.ModuleType("tts_asr")
fake_tts_asr.register_tts_asr_routes = lambda app: None
sys.modules.setdefault("tts_asr", fake_tts_asr)
import main # type: ignore
API_KEY = main.API_KEY
@@ -61,13 +65,13 @@ 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_preserves_extra_newlines():
assert main._sanitize_converted_markdown("a\n\n\nb\n\n\n\nc") == "a\n\n\nb\n\n\n\nc"
def test_sanitize_markdown_normalize_crlf():
result = main._sanitize_converted_markdown("line1\r\nline2\r\n")
assert "line1\nline2" in result
assert result == "line1\nline2\n"
assert "\r" not in result

View File

@@ -28,6 +28,13 @@ def test_prompt_builds_system_and_user():
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 "start OUTPUT on a new line" in system_prompt
assert "Use real line breaks instead of spelled-out escape sequences" in user_prompt
assert "make the first character of OUTPUT a real newline" in user_prompt
assert "make the last character of OUTPUT a real newline" in user_prompt
assert "start output with \\n" not in user_prompt
assert "Use single \\n" not in system_prompt
def test_cursor_in_fence_detection():

View File

@@ -1,5 +1,4 @@
import sys
import os
import re
from pathlib import Path

View File

@@ -15,9 +15,7 @@ TTS/ASR模块集成测试
python backend/tests/test_tts_asr_integration.py --test config
"""
import asyncio
import base64
import json
import os
import sys
import time

View File

@@ -12,8 +12,7 @@ TTS/ASR模块单元测试
import os
import sys
import unittest
from unittest.mock import Mock, MagicMock, patch
import tempfile
from unittest.mock import patch
import numpy as np
# 确保可以导入backend和tts_asr模块

View File

@@ -1,56 +0,0 @@
import asyncio
import base64
import os
import sys
# 确保能找到backend模块
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from backend.tts_asr import _tts_sync_with_retry, _load_asr_pipeline_with_retry, _get_asr_pipeline
async def verify_tts_asr_cross():
print("====================================")
print(" 开始严格交叉验证: TTS 生成 -> ASR 解析")
print("====================================")
test_text = "苹果设备支持离线大模型运算"
print(f"\n[1] 正在调用 TTS 引擎 (suno/bark-small)...")
print(f"目标文本: '{test_text}'")
try:
# TTS生成
audio_bytes, sr = await _tts_sync_with_retry(test_text, rate=1.0)
print(f"-> TTS 成功生成音频数据,大小: {len(audio_bytes)} Bytes, 采样率: {sr}Hz")
except Exception as e:
print(f"-> TTS 失败: {str(e)}")
sys.exit(1)
print("\n[2] 正在调用 ASR 引擎 (Whisper)...")
try:
loaded = await _load_asr_pipeline_with_retry()
if not loaded:
print("-> ASR 核心加载失败!")
sys.exit(1)
print("-> ASR 加载成功,开始解析音频...")
# 将生成的wav bytes传递给ASR进行语音识别
asr_pipeline = _get_asr_pipeline()
result = asr_pipeline(audio_bytes, generate_kwargs={"task": "transcribe"})
recognized_text = result.get('text', '')
print(f"-> ASR 识别结果: '{recognized_text.strip()}'")
if len(recognized_text.strip()) > 0:
print("\n结论: ✅ 验证成功TTS和ASR模块功能链路闭环完成。")
else:
print("\n结论: ❌ ASR输出为空字符闭环失败。")
sys.exit(1)
except Exception as e:
import traceback
traceback.print_exc()
print(f"-> ASR 分析阶段失败: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(verify_tts_asr_cross())

1780
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,10 @@
},
"dependencies": {
"@blocknote/xl-docx-exporter": "^0.47.3",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/core-mt": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@milkdown/core": "^7.18.0",
"@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0",

BIN
sample-video.mp4 Normal file

Binary file not shown.

View File

@@ -1,42 +1,134 @@
# Src 前端模块指南
# Src 前端指引
## OVERVIEW
Vue3 前端核心Milkdown 编辑器AI 补全插件。
本文件适用于 src/ 下的前端代码。进入 src/plugins/ 后,以子目录 AGENTS.md 为准。
## STRUCTURE
- main.js - Vue 入口Pinia + Router 挂载
- App.vue - 根组件,主题/背景控制
- plugins/ - Milkdown/Copilot 插件(见子目录 AGENTS.md
- components/ - Vue 组件MilkdownEditor、SettingsPanel 等)
- stores/ - Pinia 状态管理settings.js
- views/ - 页面视图EditorView、DocsView
- utils/ - 工具函数api.js、config.js
- router/ - 路由定义
## 前端职责
## WHERE TO LOOK
- 启动 Vue 应用并挂载路由和 Pinia。
- 提供编辑器界面、上传导出、OCR 触发、TTS 菜单、设置面板和多语言 UI。
- 把编辑器状态、设置和网络请求接到后端接口。
| 任务 | 文件 | 说明 |
|------|------|------|
| Vue 入口 | main.js | createApp、Pinia、Router 挂载 |
| 根组件 | App.vue | 主题切换、背景设置 |
| 编辑器组件 | components/MilkdownEditor.vue | Milkdown 编辑器封装 |
| AI 补全核心 | plugins/copilotPlugin.ts | ghost text、防抖、交互 |
| 状态管理 | stores/settings.js | 用户设置、主题、偏好 |
| API 调用 | utils/api.js | fetchSuggestion、TTS 接口 |
## 先看哪里
## CONVENTIONS
- JS/TS 2 空格缩进
- 变量/函数camelCase
- Vue 组件PascalCase
-件名:全小写+短横线
- 应用入口main.js
- 根组件App.vue
- 路由router/index.js
- 编辑页views/EditorView.vue
-档页views/DocsView.vue
- Univer 页views/UniverView.vue
- 编辑器主组件components/MilkdownEditor.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
## ANTI-PATTERNS
- 在前端暴露密钥
- 空 catch 块
- 类型错误使用 as any
## 路由事实
## 注意事项
- 端口5173
- 启动:`npm run dev`
- 构建:`npm run build`
- UI 默认中文
- / -> EditorView
- /docs -> DocsView
- /univer -> UniverView
## 核心行为
### 编辑器主控
- 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,216 +0,0 @@
<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: 8px 0;
border-radius: 10px;
overflow: hidden;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(59, 130, 246, 0.15);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
}
:root[data-theme='dark'] .doc-block {
background: rgba(26, 30, 39, 0.8);
border-color: rgba(96, 165, 250, 0.15);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
}
.doc-block.collapsed .doc-content {
display: none;
}
.doc-header {
display: flex;
align-items: center;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(59, 130, 246, 0.12);
gap: 8px;
}
:root[data-theme='dark'] .doc-header {
background: rgba(26, 30, 39, 0.8);
border-bottom-color: rgba(96, 165, 250, 0.15);
}
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
}
.doc-name {
flex: 1;
font-size: 13px;
font-weight: 500;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:root[data-theme='dark'] .doc-name {
color: #e5e7eb;
}
.doc-actions {
display: flex;
align-items: center;
gap: 2px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
border-radius: 6px;
opacity: 0.75;
}
.action-btn:hover {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
opacity: 1;
}
.doc-content {
padding: 8px 10px;
background: rgba(248, 250, 252, 0.8);
max-height: 240px;
overflow-y: auto;
}
:root[data-theme='dark'] .doc-content {
background: rgba(18, 22, 30, 0.8);
}
.doc-content pre {
margin: 0;
padding: 0;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
font-size: 12px;
line-height: 1.5;
color: #334155;
white-space: pre-wrap;
word-break: break-word;
}
:root[data-theme='dark'] .doc-content pre {
color: #cbd5e1;
}
</style>

View File

@@ -2,6 +2,8 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
import { canPlayVideoNatively, editVideoBlob, isVideoFile, transcodeVideoToMp4 } from '../utils/ffmpeg'
import ImageEditorComponent from './ImageEditorComponent.vue'
import UniverPreview from './UniverPreview.vue'
const props = defineProps({
@@ -9,10 +11,12 @@ const props = defineProps({
breadcrumb: { type: Array, default: () => [] },
rootNodes: { type: Array, default: () => [] },
getFileIcon: { type: Function, default: () => 'file' },
getFileBlob: { type: Function, default: () => null }
getFileBlob: { type: Function, default: () => null },
updateFile: { type: Function, default: null },
showSidebarToggle: { type: Boolean, default: false }
})
const emit = defineEmits(['navigate'])
const emit = defineEmits(['navigate', 'toggle-sidebar'])
const md = new MarkdownIt({
html: false,
@@ -20,7 +24,27 @@ const md = new MarkdownIt({
linkify: true
})
const objectUrl = ref('')
const basePreviewUrl = ref('')
const editedVideoUrl = ref('')
const editedVideoBlob = ref(null)
const imageEditorRef = ref(null)
const imageEditorError = ref('')
const isEditingImage = ref(false)
const isSavingImage = ref(false)
const isTranscodingVideo = ref(false)
const transcodeProgress = ref(0)
const videoPreviewError = ref('')
const isUsingTranscodedVideo = ref(false)
const isApplyingVideoEdit = ref(false)
const videoEditProgress = ref(0)
const videoEditError = ref('')
const videoElement = ref(null)
const videoDurationSeconds = ref(0)
const videoCurrentTime = ref(0)
const clipStartSeconds = ref(0)
const clipEndSeconds = ref(0)
const muteVideoAudio = ref(false)
let previewTaskId = 0
const isRoot = computed(() => !props.node)
const isFolder = computed(() => props.node?.type === 'folder')
@@ -38,6 +62,7 @@ const isPdf = computed(() => {
const mime = String(props.node?.mimeType || '')
return mime === 'application/pdf' || fileExt.value === 'pdf'
})
const isVideo = computed(() => isVideoFile(props.node))
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)
@@ -68,18 +93,161 @@ const locLabel = computed(() => {
const count = lineCount.value
return count ? `${count}` : '二进制文件'
})
const objectUrl = computed(() => editedVideoUrl.value || basePreviewUrl.value)
const canEditImage = computed(() => isImage.value && typeof props.updateFile === 'function')
const hasEditedVideo = computed(() => editedVideoBlob.value instanceof Blob)
const normalizedClipStart = computed(() => clampNumber(clipStartSeconds.value, 0, videoDurationSeconds.value || 0))
const normalizedClipEnd = computed(() => resolveClipEnd(clipEndSeconds.value, normalizedClipStart.value, videoDurationSeconds.value || 0))
const selectedClipDuration = computed(() => Math.max(0, normalizedClipEnd.value - normalizedClipStart.value))
const hasPendingVideoEdit = computed(() => {
if (!isVideo.value || !videoDurationSeconds.value) return false
const duration = videoDurationSeconds.value
return normalizedClipStart.value > 0.05 || Math.abs(normalizedClipEnd.value - duration) > 0.05 || muteVideoAudio.value
})
function clampNumber(value, min, max) {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return min
return Math.min(Math.max(numeric, min), max)
}
function roundSeconds(value = 0) {
return Math.round((Number(value) || 0) * 10) / 10
}
function resolveClipEnd(value, start, duration) {
if (!duration) return 0
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) return duration
return clampNumber(numeric, start, duration)
}
function stripExtension(name = '') {
return String(name).replace(/\.[^.]+$/, '')
}
function buildEditedVideoName(name = 'video.mp4') {
return `${stripExtension(name)}-edited.mp4`
}
function revokeUrl(url = '') {
if (!url) return
URL.revokeObjectURL(url)
}
function clearEditedVideoResult() {
revokeUrl(editedVideoUrl.value)
editedVideoUrl.value = ''
editedVideoBlob.value = null
}
function clearBasePreview() {
revokeUrl(basePreviewUrl.value)
basePreviewUrl.value = ''
}
function resetVideoPreviewState() {
isTranscodingVideo.value = false
transcodeProgress.value = 0
videoPreviewError.value = ''
isUsingTranscodedVideo.value = false
}
function resetVideoEditState() {
isApplyingVideoEdit.value = false
videoEditProgress.value = 0
videoEditError.value = ''
videoDurationSeconds.value = 0
videoCurrentTime.value = 0
clipStartSeconds.value = 0
clipEndSeconds.value = 0
muteVideoAudio.value = false
clearEditedVideoResult()
}
function normalizeVideoEditRange() {
if (!videoDurationSeconds.value) {
clipStartSeconds.value = 0
clipEndSeconds.value = 0
return
}
clipStartSeconds.value = roundSeconds(normalizedClipStart.value)
clipEndSeconds.value = roundSeconds(normalizedClipEnd.value)
}
function assignBasePreviewUrl(url = '') {
clearBasePreview()
basePreviewUrl.value = url
}
function setVideoRangeFromDuration(duration = 0) {
videoDurationSeconds.value = duration
videoCurrentTime.value = 0
clipStartSeconds.value = 0
clipEndSeconds.value = roundSeconds(duration)
}
watch(
fileBlob,
(blob) => {
if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
objectUrl.value = blob instanceof Blob ? URL.createObjectURL(blob) : ''
[fileBlob, () => props.node?.id],
async ([blob]) => {
const currentTaskId = ++previewTaskId
clearBasePreview()
resetVideoPreviewState()
resetVideoEditState()
if (!(blob instanceof Blob)) return
if (!isVideo.value) {
assignBasePreviewUrl(URL.createObjectURL(blob))
return
}
if (canPlayVideoNatively(props.node)) {
assignBasePreviewUrl(URL.createObjectURL(blob))
return
}
isTranscodingVideo.value = true
try {
const transcodedBlob = await transcodeVideoToMp4(blob, props.node?.name || 'video', {
onProgress(progress) {
if (currentTaskId !== previewTaskId) return
transcodeProgress.value = Math.max(transcodeProgress.value, Math.round(progress * 100))
}
})
if (currentTaskId !== previewTaskId) return
assignBasePreviewUrl(URL.createObjectURL(transcodedBlob))
isUsingTranscodedVideo.value = true
} catch (error) {
if (currentTaskId !== previewTaskId) return
videoPreviewError.value = error instanceof Error && error.message
? error.message
: '当前视频暂时无法在浏览器内转换预览'
} finally {
if (currentTaskId === previewTaskId) {
isTranscodingVideo.value = false
}
}
},
{ immediate: true }
)
watch(
() => props.node?.id,
() => {
isEditingImage.value = false
isSavingImage.value = false
imageEditorError.value = ''
}
)
onBeforeUnmount(() => {
if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
previewTaskId += 1
clearBasePreview()
clearEditedVideoResult()
})
function formatBytes(bytes = 0) {
@@ -103,6 +271,166 @@ 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 formatDuration(seconds = 0) {
const total = Math.max(0, Number(seconds) || 0)
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60
const paddedSeconds = secs >= 10 ? secs.toFixed(1).padStart(4, '0') : secs.toFixed(1).padStart(3, '0')
if (hours > 0) {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
}
return `${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
}
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 handleVideoLoadedMetadata(event) {
const duration = Number(event.target?.duration || 0)
if (!Number.isFinite(duration)) return
setVideoRangeFromDuration(duration)
}
function handleVideoTimeUpdate(event) {
const currentTime = Number(event.target?.currentTime || 0)
if (!Number.isFinite(currentTime)) return
videoCurrentTime.value = currentTime
}
function setClipStartFromCurrentTime() {
if (!videoElement.value) return
clipStartSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
if (clipEndSeconds.value < clipStartSeconds.value) {
clipEndSeconds.value = roundSeconds(videoDurationSeconds.value || clipStartSeconds.value)
}
normalizeVideoEditRange()
}
function setClipEndFromCurrentTime() {
if (!videoElement.value) return
clipEndSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
if (clipEndSeconds.value < clipStartSeconds.value) {
clipStartSeconds.value = roundSeconds(Math.max(0, clipEndSeconds.value - 0.1))
}
normalizeVideoEditRange()
}
async function applyVideoEdits() {
if (!isVideo.value) return
normalizeVideoEditRange()
const sourceBlob = hasEditedVideo.value ? editedVideoBlob.value : fileBlob.value
if (!(sourceBlob instanceof Blob) || !videoDurationSeconds.value || !hasPendingVideoEdit.value) return
isApplyingVideoEdit.value = true
videoEditProgress.value = 0
videoEditError.value = ''
try {
const editedBlob = await editVideoBlob(sourceBlob, hasEditedVideo.value ? buildEditedVideoName(props.node?.name || 'video.mp4') : props.node?.name || 'video.mp4', {
startTime: normalizedClipStart.value,
endTime: normalizedClipEnd.value,
muteAudio: muteVideoAudio.value,
onProgress(progress) {
videoEditProgress.value = Math.max(videoEditProgress.value, Math.round(progress * 100))
}
})
clearEditedVideoResult()
editedVideoBlob.value = editedBlob
editedVideoUrl.value = URL.createObjectURL(editedBlob)
} catch (error) {
videoEditError.value = error instanceof Error && error.message
? error.message
: '视频编辑失败'
} finally {
isApplyingVideoEdit.value = false
}
}
function restoreOriginalVideo() {
clearEditedVideoResult()
videoEditError.value = ''
videoEditProgress.value = 0
muteVideoAudio.value = false
}
function downloadEditedVideo() {
if (!(editedVideoBlob.value instanceof Blob)) return
downloadBlob(editedVideoBlob.value, buildEditedVideoName(props.node?.name || 'video.mp4'))
}
async function copyText() {
if (!previewText.value) return
try {
@@ -120,13 +448,7 @@ function openRaw() {
function downloadFile() {
const blob = fileBlob.value
if (!blob) return
const url = objectUrl.value || URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = props.node?.name || 'download'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
downloadBlob(blob, props.node?.name || 'download')
}
</script>
@@ -182,35 +504,58 @@ function downloadFile() {
<template v-else-if="node">
<div class="github-file-header">
<div class="file-path-bar">
<div class="file-path">
<span v-for="(item, index) in breadcrumb" :key="item.id || item.name" class="file-path-item">
<button
v-if="index < breadcrumb.length - 1"
type="button"
class="breadcrumb-link"
@click="navigateTo(item.id)"
>
{{ item.name }}
</button>
<span v-else class="breadcrumb-current">{{ item.name }}</span>
<span v-if="index < breadcrumb.length - 1" class="path-sep">/</span>
</span>
</div>
<button class="top-link" type="button">Top</button>
</div>
<div class="file-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-meta-row">
<div class="meta-tabs">
<button class="meta-tab active" type="button">{{ isImage || isPdf ? 'Preview' : 'Code' }}</button>
<button class="meta-tab" type="button" disabled>Blame</button>
<span class="file-meta">{{ locLabel }}</span>
<span class="file-meta">{{ fileSizeLabel }}</span>
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</span>
<div 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="isTranscodingVideo" class="truncated-pill">视频转换中 {{ transcodeProgress }}%</span>
<span v-else-if="isApplyingVideoEdit" class="truncated-pill">编辑处理中 {{ videoEditProgress }}%</span>
<span v-else-if="isUsingTranscodedVideo" class="truncated-pill">已转为 MP4 预览</span>
</div>
</div>
<div class="file-actions">
<button class="action-btn" type="button" @click="openRaw" :disabled="!objectUrl">Raw</button>
<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>
@@ -231,11 +576,101 @@ function downloadFile() {
</div>
<div v-else-if="isImage && objectUrl" class="content-preview">
<div class="preview-surface image-surface">
<div class="preview-surface image-surface" :class="{ 'image-editor-active': isEditingImage }">
<div v-if="imageEditorError" class="image-inline-error">{{ imageEditorError }}</div>
<ImageEditorComponent
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="isTranscodingVideo" class="video-state-card">
<h3>正在准备视频预览</h3>
<p>当前格式需要先转换为浏览器兼容的 MP4转换完成后会自动开始播放</p>
<div class="video-progress-track" role="progressbar" :aria-valuenow="transcodeProgress" aria-valuemin="0" aria-valuemax="100">
<span class="video-progress-bar" :style="{ width: `${transcodeProgress}%` }"></span>
</div>
<strong>{{ transcodeProgress }}%</strong>
</div>
<div v-else-if="videoPreviewError" class="video-state-card video-state-error">
<h3>视频预览暂时不可用</h3>
<p>{{ videoPreviewError }}</p>
<p>原始文件仍保存在浏览器本地你可以继续下载后使用本地播放器打开</p>
</div>
<video
v-else-if="objectUrl"
ref="videoElement"
class="video-player"
controls
playsinline
:src="objectUrl"
@loadedmetadata="handleVideoLoadedMetadata"
@timeupdate="handleVideoTimeUpdate"
></video>
<section v-if="objectUrl" class="video-edit-panel">
<div class="video-edit-header">
<div>
<h3>基础编辑</h3>
<p>裁剪片段静音导出并把结果另存为新的 MP4 文件</p>
</div>
<div class="video-edit-meta">
<span>总时长 {{ formatDuration(videoDurationSeconds) }}</span>
<span>当前播放 {{ formatDuration(videoCurrentTime) }}</span>
<span>片段时长 {{ formatDuration(selectedClipDuration) }}</span>
</div>
</div>
<div class="video-edit-grid">
<label class="video-field">
<span>开始时间</span>
<input v-model.number="clipStartSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
</label>
<label class="video-field">
<span>结束时间</span>
<input v-model.number="clipEndSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
</label>
</div>
<div class="video-quick-actions">
<button class="ghost-btn" type="button" @click="setClipStartFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为开始</button>
<button class="ghost-btn" type="button" @click="setClipEndFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为结束</button>
</div>
<label class="video-checkbox">
<input v-model="muteVideoAudio" type="checkbox" />
<span>静音导出移除音轨</span>
</label>
<div v-if="videoEditError" class="video-inline-error">{{ videoEditError }}</div>
<div v-if="isApplyingVideoEdit" class="video-edit-progress-shell">
<div class="video-progress-track" role="progressbar" :aria-valuenow="videoEditProgress" aria-valuemin="0" aria-valuemax="100">
<span class="video-progress-bar" :style="{ width: `${videoEditProgress}%` }"></span>
</div>
<strong>{{ videoEditProgress }}%</strong>
</div>
<div class="video-edit-actions">
<button class="action-btn primary-btn" type="button" @click="applyVideoEdits" :disabled="isApplyingVideoEdit || !hasPendingVideoEdit">生成编辑预览</button>
<button class="action-btn" type="button" @click="downloadEditedVideo" :disabled="!hasEditedVideo">下载编辑结果</button>
<button class="action-btn" type="button" @click="restoreOriginalVideo" :disabled="!hasEditedVideo">恢复原片</button>
</div>
</section>
</div>
</div>
<div v-else-if="isPdf && objectUrl" class="content-preview">
<iframe class="pdf-frame" :src="objectUrl" :title="node.name"></iframe>
</div>
@@ -411,17 +846,39 @@ function downloadFile() {
background: var(--github-bg);
}
.file-path-bar,
.file-meta-row {
.file-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
flex-wrap: wrap;
}
.file-path-bar {
border-bottom: 1px solid var(--github-border);
.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 {
@@ -432,12 +889,6 @@ function downloadFile() {
flex-wrap: wrap;
}
.file-path-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.breadcrumb-link {
border: none;
background: transparent;
@@ -458,41 +909,18 @@ function downloadFile() {
font-size: 13px;
}
.top-link {
border: none;
background: transparent;
color: var(--github-text-secondary);
font-weight: 600;
}
.meta-tabs {
.file-header-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.meta-tab {
height: 32px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 8px;
background: var(--github-hover);
color: var(--github-text);
font-weight: 600;
}
.meta-tab[disabled] {
opacity: 0.56;
}
.meta-tab.active {
background: var(--github-bg);
}
.file-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}
.action-btn {
@@ -611,6 +1039,243 @@ function downloadFile() {
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);
}
.video-edit-panel {
width: min(1100px, 100%);
margin: 0 auto;
padding: 20px;
border: 1px solid var(--github-border);
border-radius: 16px;
background: color-mix(in srgb, var(--github-bg) 94%, white 6%);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}
.video-edit-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.video-edit-header h3 {
margin: 0;
}
.video-edit-header p {
margin: 6px 0 0;
color: var(--github-text-secondary);
line-height: 1.7;
}
.video-edit-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.video-edit-meta span {
display: inline-flex;
align-items: center;
min-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;
font-weight: 600;
}
.video-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.video-field {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--github-text-secondary);
font-size: 13px;
font-weight: 600;
}
.video-field input {
height: 40px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 10px;
background: var(--github-bg);
color: var(--github-text);
}
.video-quick-actions,
.video-edit-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.video-quick-actions {
margin-top: 14px;
}
.ghost-btn {
height: 34px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 999px;
background: transparent;
color: var(--github-text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.ghost-btn:hover:enabled {
background: var(--github-hover);
color: var(--github-text);
}
.ghost-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.video-checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 14px;
color: var(--github-text);
font-size: 13px;
font-weight: 600;
}
.video-inline-error {
margin-top: 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-edit-progress-shell {
margin-top: 16px;
}
.video-edit-progress-shell strong {
display: inline-block;
margin-top: 8px;
}
.video-edit-actions {
margin-top: 18px;
}
.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%);
}
.video-progress-track {
position: relative;
height: 10px;
margin-top: 18px;
overflow: hidden;
border-radius: 999px;
background: var(--github-hover);
}
.video-progress-bar {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #0969da, #2da44e);
transition: width 180ms ease;
}
.pdf-frame {
width: 100%;
min-height: 78vh;
@@ -694,6 +1359,7 @@ function downloadFile() {
.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; }
@@ -701,13 +1367,21 @@ function downloadFile() {
.icon-text::before { content: 'TXT'; font-size: 8px; font-weight: 700; color: #6e7781; }
@media (max-width: 960px) {
.file-path-bar,
.file-meta-row,
.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;

View File

@@ -354,21 +354,18 @@ function forwardDragOver(event, id) {
.branch-actions {
display: flex;
gap: 6px;
gap: 4px;
padding: 2px;
border: 1px solid var(--github-border);
border-radius: 10px;
background: var(--github-bg);
}
.header-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--github-border);
border-radius: 8px;
background: var(--github-bg);
color: var(--github-text-secondary);
cursor: pointer;
width: 28px;
height: 28px;
border: 1px solid transparent;
background: transparent;
}
.header-icon-btn:hover {
@@ -513,21 +510,11 @@ function forwardDragOver(event, id) {
.chevron,
.chevron-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.chevron {
border: none;
background: transparent;
color: var(--github-text-secondary);
cursor: pointer;
}
.icon-folder,
.icon-file {
display: inline-flex;
@@ -592,7 +579,8 @@ function forwardDragOver(event, id) {
.node-actions {
display: flex;
gap: 2px;
gap: 4px;
margin-left: 4px;
opacity: 0;
transition: opacity 0.15s ease;
}
@@ -602,25 +590,56 @@ function forwardDragOver(event, id) {
opacity: 1;
}
.action-btn {
.header-icon-btn,
.action-btn,
.chevron {
appearance: none;
-webkit-appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--github-text-secondary);
cursor: pointer;
flex-shrink: 0;
line-height: 0;
}
.action-btn:hover {
.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;
}

View File

@@ -0,0 +1,317 @@
<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

@@ -280,8 +280,9 @@ const createExportName = () => {
const buildDocxBlob = async (markdown) => {
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
const children = []
const normalizedMarkdown = String(markdown || '').replace(/\r\n?/g, '\n')
for (const line of markdown.split('\n')) {
for (const line of normalizedMarkdown.split('\n')) {
if (line.startsWith('# ')) {
children.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
continue
@@ -457,11 +458,11 @@ const serializeSelectionToMarkdown = (view, from, to) => {
const state = view.state
const slice = state.doc.slice(from, to)
const doc = state.schema.topNodeType.createAndFill(undefined, slice.content)
if (!doc) return state.doc.textBetween(from, to, '\n', '\n')
if (!doc) return state.doc.textBetween(from, to, '\n\n', '\n')
return crepe?.editor?.action((ctx) => {
const serializer = ctx.get(serializerCtx)
return serializer(doc)
}) || state.doc.textBetween(from, to, '\n', '\n')
}) || state.doc.textBetween(from, to, '\n\n', '\n')
}
const selectionIncludesDocBlock = (state) => {
@@ -515,13 +516,24 @@ const SKIP_TTS_TYPES = new Set([
const extractSelectionText = (view, from, to) => {
const { doc } = view.state
const parts = []
const pushBlockSeparator = () => {
if (parts.length === 0) return
const lastPart = parts[parts.length - 1]
if (typeof lastPart === 'string' && lastPart.endsWith('\n')) return
parts.push('\n')
}
doc.nodesBetween(from, to, (node, pos) => {
if (SKIP_TTS_TYPES.has(node.type.name)) {
return false
}
if (node.isTextblock && pos >= from) {
pushBlockSeparator()
}
if (node.type.name === DOC_BLOCK_NODE_TYPE) {
pushBlockSeparator()
if (node.attrs.content) {
parts.push(node.attrs.content)
}
@@ -544,7 +556,7 @@ const extractSelectionText = (view, from, to) => {
return true
})
return parts.join('\n').trim()
return parts.join('').trim()
}
const showTTSMenu = (event) => {

View File

@@ -180,3 +180,164 @@ function forwardDragOver(event, id) {
/>
</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

@@ -98,8 +98,12 @@ function isBinaryExtension(ext) {
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',
@@ -126,11 +130,17 @@ function inferMimeType(name, fallback = '') {
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'
pdf: 'application/pdf',
ogg: 'video/ogg',
ogv: 'video/ogg',
webm: 'video/webm',
wmv: 'video/x-ms-wmv'
}
return fallback || map[ext] || 'application/octet-stream'
}
@@ -398,6 +408,43 @@ export function useFileSystem() {
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}`
@@ -594,8 +641,12 @@ export function useFileSystem() {
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',
@@ -617,13 +668,19 @@ export function useFileSystem() {
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'
@@ -681,6 +738,7 @@ export function useFileSystem() {
stats,
load,
createFile,
updateFile,
createFolder,
rename,
remove,

View File

@@ -1,42 +1,71 @@
# Plugins 模块指南
# Plugins 插件层指引
## OVERVIEW
Milkdown/ProseMirror 插件AI 补全核心逻辑。
本文件适用于 src/plugins/ 下的插件代码。
## STRUCTURE
- copilotPlugin.ts - ProseMirror Mark 系统、ghost text、防抖请求
- docBlockPlugin.ts - 文档块插件
- mermaidPlugin.ts - Mermaid 图表渲染
- index.ts - 插件导出
- types.ts - 类型定义
## 插件层职责
## WHERE TO LOOK
- 承接 Milkdown/ProseMirror 与业务逻辑之间的粘合层。
- 负责 AI 补全 ghost text、文档块节点、Mermaid 预览等编辑器级行为。
- 这里的代码状态性很强,误改后通常会出现“看起来还能编译,但交互细节坏掉”的问题。
| 任务 | 文件 | 说明 |
|------|------|------|
| AI 补全核心 | copilotPlugin.ts | ProseMirror Mark、ghost text |
| 文档块处理 | docBlockPlugin.ts | 文档块解析与渲染 |
| Mermaid 图表 | mermaidPlugin.ts | 图表渲染集成 |
| 插件导出 | index.ts | 统一导出入口 |
| 类型定义 | types.ts | 公共类型 |
## 文件分工
## 关键函数
- scheduleFetch - 防抖触发补全请求
- insertGhostText - 插入 ghost text
- acceptSuggestion - Tab 接受建议
- rejectSuggestion - Esc 取消建议
- copilotPlugin.tsAI 补全主逻辑,包含 ghost text、请求调度、取消、语言识别、隐藏上下文注入和大小限制。
- docBlockPlugin.ts文档块节点、remark 适配和视图逻辑。
- mermaidPlugin.tsMermaid 代码块预览和渲染。
- index.ts导出入口。
- types.ts公共类型。
## CONVENTIONS
- TypeScript 2 空格缩进
- 函数camelCase
- 接口/类型PascalCase
## copilotPlugin 的真实职责
## ANTI-PATTERNS
- 空 catch 块
- 类型错误使用 as any
- 硬编码超时值
- 维护 ghost suggestion 的 plugin state。
- 用 mark + decoration 表示 ghost text而不是简单的纯字符串缓存。
- 为每个 EditorView 维护 runtime
- enabled
- debounceTimer
- abortController
- ctx
- requestSeq
- docVersion
- 推断当前光标语言,处理 fenced code、latex、mermaid 等上下文。
- 提取 OCR 缓存和文档块内容,拼到隐藏上下文里。
- 控制请求失效和中止,避免旧请求把新文档状态覆盖掉。
- 在结果插入后恢复合理的光标位置和选择状态。
## 注意事项
- 防抖时间1000ms可配置
- 文档大小限制32KB 自动禁用
- Tab/Esc 快捷键交互
## 先看哪些函数
- 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,6 +4,7 @@ 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'
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
@@ -11,6 +12,8 @@ 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
@@ -181,10 +184,6 @@ function normalizeSuggestionText(raw: string): string {
}
}
if (!text.includes('\n') && text.includes('\\n')) {
text = text.replace(/\\n/g, '\n')
}
return text
}
@@ -307,7 +306,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, '\n', '\n')
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
}
function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
@@ -328,32 +327,7 @@ function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
})
if (lines.length === 0) return ''
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')}`
return lines.join('\n')
}
function doFetchSuggestion(
@@ -413,26 +387,26 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
try {
prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, serializer)
if (!prefixMarkdown) {
prefixMarkdown = doc.textBetween(0, pos, '\n', '\n')
prefixMarkdown = doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
}
suffixMarkdown = serializeRangeToMarkdown(doc, pos, doc.content.size, schema, serializer)
if (!suffixMarkdown) {
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
suffixMarkdown = doc.textBetween(pos, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
}
} catch {
prefixMarkdown = doc.textBetween(0, pos, '\n', '\n')
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
prefixMarkdown = doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
suffixMarkdown = doc.textBetween(pos, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
}
// 构建上下文OCR内容 + 上传文档内容
const ocrContext = buildOcrContextForRequest(doc, pos)
// 从markdown中提取文档块内容用于AI补全上下文
const docContext = extractDocBlocksFromMarkdown(prefixMarkdown + suffixMarkdown)
const docContext = extractDocBlockContextFromMarkdown(prefixMarkdown + suffixMarkdown, 500)
// 组合所有上下文到prefix前面
const fullPrefixWithContext = `${ocrContext}${docContext}\n\n${prefixMarkdown}`
const fullPrefixWithContext = [ocrContext, docContext, prefixMarkdown].filter(Boolean).join('\n\n')
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length

View File

@@ -16,6 +16,9 @@ import {
stripDocBlockMarkdown,
} from '../utils/docBlock.js'
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
const FALLBACK_LEAF_TEXT = '\n'
function serializeRangeToMarkdown(
doc: ProseNode,
from: number,
@@ -27,7 +30,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, '\n', '\n')
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
}
function buildDocContext(doc: ProseNode, excludePos?: number) {

View File

@@ -5,6 +5,17 @@ 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()
@@ -54,12 +65,9 @@ export function isSupportedDocFile(file) {
}
export function sanitizeDocContent(markdown = '') {
return String(markdown || '')
.replace(/\r\n?/g, '\n')
return normalizeMarkdownText(markdown)
.replace(IMAGE_MD_RE, '')
.replace(IMAGE_HTML_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function quoteMeta(value = '') {
@@ -102,7 +110,7 @@ export function buildDocBlockValue(attrs = {}) {
}
export function parseDocBlockValue(raw = '') {
const normalized = String(raw || '').replace(/\r\n?/g, '\n')
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
@@ -150,7 +158,8 @@ export function buildLegacyDocBlock(attrs = {}) {
}
export function parseLegacyDocBlock(raw = '') {
const match = String(raw || '').match(/^<doc_type="([^"]+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)"(?:\s+collapsed="([^"]+)")?>\n?([\s\S]*?)\n?<\/doc_end>$/)
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]),
@@ -161,33 +170,57 @@ export function parseLegacyDocBlock(raw = '') {
}
}
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 pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
const replacedFence = String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
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(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
return replacedFence.replace(LEGACY_DOC_BLOCK_RE, (full) => {
const attrs = parseLegacyDocBlock(full)
return attrs ? buildDocContextFence(attrs) : full
})
}
export function stripDocBlockMarkdown(markdown = '') {
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g
return String(markdown || '').replace(pattern, '$1').replace(/\n{3,}/g, '\n\n').trim()
return normalizeMarkdownText(markdown).replace(FENCED_DOC_BLOCK_RE, '$1')
}
export function transformLegacyDocBlocksForExport(markdown = '') {
return String(markdown || '').replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
return normalizeMarkdownText(markdown).replace(LEGACY_DOC_BLOCK_RE, (full) => {
const attrs = parseLegacyDocBlock(full)
return attrs ? buildDocBlockMarkdown(attrs) : full
})
}
export function transformSpecialDocBlocksToLegacy(markdown = '') {
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
return String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
return normalizeMarkdownText(markdown).replace(FENCED_DOC_BLOCK_RE, (full, prefix, _fence, value) => {
const attrs = parseDocBlockValue(value)
return `${prefix}${buildLegacyDocBlock(attrs)}`
})

230
src/utils/ffmpeg.js Normal file
View File

@@ -0,0 +1,230 @@
import coreURL from '@ffmpeg/core?url'
import wasmURL from '@ffmpeg/core/wasm?url'
import classWorkerURL from '@ffmpeg/ffmpeg/worker?url'
const VIDEO_EXTENSIONS = new Set([
'mp4',
'webm',
'ogv',
'ogg',
'mov',
'm4v',
'avi',
'mkv',
'flv',
'wmv',
'3gp',
'm2ts',
'mts',
'ts'
])
const FORCED_TRANSCODE_EXTENSIONS = new Set([
'3gp',
'avi',
'flv',
'm2ts',
'mkv',
'mts',
'ts',
'wmv'
])
const VIDEO_MIME_BY_EXTENSION = {
m4v: 'video/x-m4v',
mov: 'video/quicktime',
mp4: 'video/mp4',
ogg: 'video/ogg',
ogv: 'video/ogg',
webm: 'video/webm'
}
let ffmpegInstancePromise = null
let progressHandler = null
let transcodeQueue = Promise.resolve()
function getFileExtension(name = '') {
const parts = String(name).split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
}
function getNodeMimeType(node) {
return String(node?.mimeType || '').toLowerCase()
}
function formatFfmpegTime(seconds = 0) {
const totalSeconds = Math.max(0, Number(seconds) || 0)
return totalSeconds.toFixed(3)
}
function createVideoProbe() {
if (typeof document === 'undefined') return null
return document.createElement('video')
}
function toAbsoluteURL(url) {
if (!url || typeof location === 'undefined') {
return url
}
return new URL(url, location.href).href
}
function getLoadConfigs() {
return [{
classWorkerURL: toAbsoluteURL(classWorkerURL),
coreURL: toAbsoluteURL(coreURL),
wasmURL: toAbsoluteURL(wasmURL)
}]
}
async function loadFFmpegRuntime(FFmpeg, fetchFile) {
let lastError = null
for (const loadConfig of getLoadConfigs()) {
const ffmpeg = new FFmpeg()
ffmpeg.on('progress', ({ progress }) => {
if (typeof progressHandler === 'function') {
progressHandler(Math.max(0, Math.min(1, progress || 0)))
}
})
try {
await ffmpeg.load(loadConfig)
return { ffmpeg, fetchFile }
} catch (error) {
lastError = error
}
}
throw lastError || new Error('FFmpeg 初始化失败')
}
async function getFFmpegInstance() {
if (!ffmpegInstancePromise) {
ffmpegInstancePromise = Promise.all([
import('@ffmpeg/ffmpeg'),
import('@ffmpeg/util')
]).then(([{ FFmpeg }, { fetchFile }]) => loadFFmpegRuntime(FFmpeg, fetchFile))
}
return ffmpegInstancePromise
}
function buildMp4Command(inputName, outputName, options = {}) {
const startTime = Math.max(0, Number(options.startTime) || 0)
const endTime = Number.isFinite(options.endTime) ? Number(options.endTime) : null
const duration = endTime === null ? null : Math.max(0.05, endTime - startTime)
const args = []
if (startTime > 0) {
args.push('-ss', formatFfmpegTime(startTime))
}
args.push('-i', inputName)
if (duration !== null) {
args.push('-t', formatFfmpegTime(duration))
}
if (options.preferEncoding !== false) {
args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p')
if (options.muteAudio) {
args.push('-an')
} else {
args.push('-c:a', 'aac')
}
} else if (options.muteAudio) {
args.push('-an')
}
args.push('-movflags', '+faststart', '-y', outputName)
return args
}
async function executePreferredMp4Command(ffmpeg, inputName, outputName, options = {}) {
try {
await ffmpeg.exec(buildMp4Command(inputName, outputName, {
...options,
preferEncoding: true
}))
} catch {
await ffmpeg.exec(buildMp4Command(inputName, outputName, {
...options,
preferEncoding: false
}))
}
}
async function runMp4Job(blob, fileName, options = {}) {
const { ffmpeg, fetchFile } = await getFFmpegInstance()
const ext = getFileExtension(fileName) || 'bin'
const inputName = `input-${Date.now()}.${ext}`
const outputName = `output-${Date.now()}.mp4`
progressHandler = typeof options.onProgress === 'function' ? options.onProgress : null
try {
await ffmpeg.writeFile(inputName, await fetchFile(blob))
await executePreferredMp4Command(ffmpeg, inputName, outputName, options)
const data = await ffmpeg.readFile(outputName)
return new Blob([data.buffer], { type: 'video/mp4' })
} finally {
progressHandler = null
await ffmpeg.deleteFile?.(inputName)
await ffmpeg.deleteFile?.(outputName)
}
}
export function isVideoFile(node) {
const ext = getFileExtension(node?.name)
const mime = getNodeMimeType(node)
return mime.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)
}
export function canPlayVideoNatively(node) {
if (!isVideoFile(node)) return false
const ext = getFileExtension(node?.name)
if (FORCED_TRANSCODE_EXTENSIONS.has(ext)) return false
const mime = getNodeMimeType(node) || VIDEO_MIME_BY_EXTENSION[ext] || ''
if (!mime) return ['mp4', 'ogv', 'ogg', 'webm'].includes(ext)
const video = createVideoProbe()
if (!video) return ['mp4', 'ogv', 'ogg', 'webm'].includes(ext)
return video.canPlayType(mime) !== ''
}
export async function transcodeVideoToMp4(blob, fileName, options = {}) {
const run = async () => {
try {
return await runMp4Job(blob, fileName, options)
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: '浏览器内视频转换失败'
throw new Error(message)
}
}
const result = transcodeQueue.then(run, run)
transcodeQueue = result.then(() => undefined, () => undefined)
return result
}
export async function editVideoBlob(blob, fileName, options = {}) {
const run = async () => {
try {
return await runMp4Job(blob, fileName, options)
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: '浏览器内视频编辑失败'
throw new Error(message)
}
}
const result = transcodeQueue.then(run, run)
transcodeQueue = result.then(() => undefined, () => undefined)
return result
}

View File

@@ -120,7 +120,7 @@ function closeConfirm() {
</aside>
<section class="docs-main">
<header class="docs-toolbar">
<header v-if="selectedNode?.type !== 'file'" class="docs-toolbar">
<div class="toolbar-left">
<button
class="sidebar-toggle"
@@ -166,7 +166,10 @@ function closeConfirm() {
: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>

View File

@@ -1,20 +0,0 @@
import asyncio
from backend.tts_asr import _tts_sync_with_retry, _load_asr_pipeline_with_retry, _asr_pipeline
import base64
async def main():
text = "早上好"
print(f"Testing TTS with text: {text}")
audio_bytes, sr = await _tts_sync_with_retry(text, rate=1.0)
print(f"TTS generated {len(audio_bytes)} bytes of audio.")
print("Testing ASR...")
await _load_asr_pipeline_with_retry()
asr = _asr_pipeline
# Needs to process audio_bytes. ASR expects float32 numpy array or bytes?
# the pipeline takes bytes or dict with raw array
result = asr(audio_bytes)
print("ASR output:", result)
asyncio.run(main())

View File

@@ -1,11 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const crossOriginIsolationHeaders = {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin'
}
export default defineConfig({
plugins: [vue()],
server: {
host: true,
port: 5173,
headers: crossOriginIsolationHeaders,
proxy: {
'/v1': {
target: 'https://api.imageteach.tech:8002',
@@ -13,6 +19,9 @@ export default defineConfig({
}
}
},
preview: {
headers: crossOriginIsolationHeaders
},
build: {
rollupOptions: {
output: {