Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9904b9bd78 | |||
| 7ed199aaf1 | |||
| 9ff51ac2f3 | |||
| be4000b774 |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"shortcuts": [
|
||||
{
|
||||
"label": "Run",
|
||||
"command": "npm run dev",
|
||||
"icon": "play"
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
-3
@@ -1,3 +1,4 @@
|
||||
VITE_API_BASE_URL=http://149.104.29.239:8001
|
||||
VITE_API_URL=http://149.104.29.239:8001/v1/completions
|
||||
VITE_OCR_URL=http://149.104.29.239:8001/v1/ocr
|
||||
VITE_API_BASE_URL=
|
||||
VITE_API_URL=
|
||||
VITE_OCR_URL=
|
||||
VITE_CONVERT_URL=
|
||||
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
# Logs
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -39,3 +39,9 @@ env/
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
|
||||
# IDE directories
|
||||
.kilocode/
|
||||
.kilo/
|
||||
.codex/
|
||||
@@ -0,0 +1,52 @@
|
||||
# 导出按钮缺失修复计划
|
||||
|
||||
## 问题分析
|
||||
当前 `action-buttons` 区域只有以下按钮可见:
|
||||
- 上传文件
|
||||
- 导入 Markdown
|
||||
- 导出 Markdown
|
||||
- 上传图片
|
||||
- AI 切换按钮
|
||||
|
||||
**缺失功能**:DOCX 和 PDF 导出按钮
|
||||
|
||||
## 调查结果
|
||||
1. ✅ 翻译文件中已存在 `exportDocx` 和 `exportPdf` 键名(src/utils/i18n.js)
|
||||
2. ❌ 模板中**完全缺失**这两个按钮的 HTML 代码
|
||||
3. ❓ 导出功能后端已实现,前端只需要添加调用接口的按钮
|
||||
4. ✅ 相关 CSS 样式已存在,按钮外观无需额外调整
|
||||
|
||||
## 实施计划
|
||||
|
||||
### 1. 添加 UI 按钮
|
||||
在 `src/components/MilkdownEditor.vue:79` 之后添加两个新按钮:
|
||||
- DOCX 导出按钮
|
||||
- PDF 导出按钮
|
||||
|
||||
按钮位置:
|
||||
```
|
||||
导出 Markdown → 导出 DOCX → 导出 PDF → 上传图片
|
||||
```
|
||||
|
||||
### 2. 实现前端导出功能
|
||||
使用已安装的依赖库:
|
||||
- `docx` 库:用于 DOCX 导出
|
||||
- `html2pdf.js` 库:用于 PDF 导出
|
||||
|
||||
需要添加的函数:
|
||||
```javascript
|
||||
const exportDocx = async () => {
|
||||
// 使用 docx 库实现导出
|
||||
}
|
||||
|
||||
const exportPdf = async () => {
|
||||
// 使用 html2pdf.js 实现导出
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 按钮图标
|
||||
- DOCX:使用文档图标
|
||||
- PDF:使用 PDF 专用图标
|
||||
|
||||
### 4. 状态管理
|
||||
添加加载状态和错误处理,与现有按钮保持一致风格
|
||||
@@ -0,0 +1,65 @@
|
||||
# rules.md
|
||||
|
||||
在构建这个LLM应用网页时,你需要基于VUE3开发。我需要前端只运行渲染和数据回传,后端负责llm api调用,类似copilet的auto inline suggustions实现和数据解析。
|
||||
|
||||
# **重要** : 在回复用户消息时,一定要使用中文
|
||||
|
||||
## 指导原则
|
||||
|
||||
- 不要擅自用npm或者yarn运行网页,你既看不到网页的内容,也无法阻止命令暂停。但是,你可以用npm run build检查代码。
|
||||
- 应该保证代码效率,不多定义变量,不写冗余注释,把降低延迟放在第一位。
|
||||
- 每次完成任务前都要反复阅读检查代码,确保代码准确无误。
|
||||
- 尽量不要搜索关键字,而是了解代码结构后查询整个问题代码明确问题所在。
|
||||
- @/milkdown-docs/ 代表milkdown的最新官方文档,不要修改,涉及到前端编辑器的指令时要核对官方文档。
|
||||
|
||||
|
||||
# 仓库指南
|
||||
|
||||
## 语言约定
|
||||
项目文档、日志、错误提示以及对外返回的文字信息统一使用 **中文**。前端 UI 默认展示中文,若需多语言支持请在相应模块实现。
|
||||
|
||||
## 项目结构 \& 模块组织
|
||||
```
|
||||
backend/ # FastAPI 后端(Python)
|
||||
├─ main.py # API 入口
|
||||
├─ llm.py # LLM 包装工具
|
||||
├─ prompt.py # Prompt 构建辅助
|
||||
└─ tests/ # pytest 测试套件
|
||||
public/ # 前端静态资源
|
||||
src/ # 前端源码(Vite + React)
|
||||
dist/ # 构建产出(生成文件)
|
||||
```
|
||||
生产代码主要位于 `backend/`(Python)和 `src/`(JS/TS)。测试文件与被测模块并置。
|
||||
|
||||
## 构建、测试、开发命令
|
||||
| 命令 | 说明 |
|
||||
|----------------------------------------------|--------------------------------------------------|
|
||||
| `npm install` | 安装前端依赖 |
|
||||
| `npm run dev` | 启动 Vite 开发服务器 |
|
||||
| `uvicorn backend.main:app --reload` | 本地运行 FastAPI 服务 |
|
||||
| `pytest` | 运行 Python 测试套件 |
|
||||
| `npm run build` | 生成生产环境构建产物至 `dist/` |
|
||||
|
||||
## 编码风格 \& 命名约定
|
||||
- **Python**:使用 4 空格缩进,`snake_case` 命名函数/变量,`PascalCase` 命名类。提交前请使用 `ruff`/`black` 格式化。
|
||||
- **JavaScript/TypeScript**:使用 2 空格缩进,`camelCase` 命名变量/函数,`PascalCase` 命名 React 组件。使用 `eslint` 与 `prettier` 检查。
|
||||
- 文件名采用全小写加短横线,例如 `my-module.py`、`my-component.tsx`。
|
||||
|
||||
## 测试指南
|
||||
- 后端使用 **pytest**,测试文件放在对应模块目录下,命名为 `test_<module>.py`。
|
||||
- 目标覆盖率 ≥ 80%(`pytest --cov=backend`)。
|
||||
- 在虚拟环境中运行:`pip install -r backend/requirements.txt && pytest`。
|
||||
|
||||
## 提交 \& Pull Request 规范
|
||||
- 提交信息遵循 **Conventional Commits**:`feat:` 新功能、`fix:` 修复、`docs:` 文档、`refactor:` 重构等。
|
||||
- PR 必须包含:
|
||||
- 与提交信息匹配的标题。
|
||||
- 关联的 Issue(如 `Fixes #123`)。
|
||||
- UI 变更或 API 示例的截图/示例。
|
||||
- 所有 CI 检查(代码检查、测试、类型检查)均通过。
|
||||
|
||||
## 安全 \& 配置建议
|
||||
- 敏感信息请放入 `.env` 并确保已在 `.gitignore` 中。
|
||||
- 按照 `backend/main.py` 中的实现,对上传文件的大小和类型进行校验,防止滥用。
|
||||
- 定期审计依赖安全(`npm audit`、`pip-audit`)。
|
||||
|
||||
@@ -1,264 +1,93 @@
|
||||
# LLM in Text - 智能写作助手
|
||||
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验。
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### Markdown 编辑器
|
||||
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
||||
- 支持完整 Markdown 语法和 LaTeX 公式
|
||||
- 支持 Markdown 语法和 LaTeX 公式
|
||||
- 支持 Mermaid 图表渲染
|
||||
- 导入/导出 Markdown 文件
|
||||
- 导出 DOCX 和 PDF 格式
|
||||
|
||||
### AI 智能补全
|
||||
- 实时生成文本补全建议(灰色显示)
|
||||
- 流式响应,低延迟体验
|
||||
- 多种交互方式:
|
||||
- **Tab 键**:接受建议
|
||||
- **Esc 键**:拒绝建议
|
||||
- **点击灰色文本**:接受建议
|
||||
- **继续输入**:自动拒绝建议
|
||||
- 多种交互方式:Tab接受、Esc拒绝、点击接受
|
||||
|
||||
### AI 开关控制
|
||||
- 右下角 AI 开关按钮
|
||||
- 白色 = AI 启用,黑色 = AI 禁用
|
||||
- 禁用时自动清除灰色文本并停止 API 调用
|
||||
### 文档处理
|
||||
- OCR 图片识别:上传图片自动识别文字
|
||||
- 文档转换:PDF、DOCX、PPTX、TXT 转 Markdown
|
||||
- 文档块嵌入:可折叠的文档预览块
|
||||
- 智能大小限制:32KB自动禁用AI
|
||||
|
||||
### 设置面板
|
||||
- 外观主题:亮色/暗色/跟随系统
|
||||
- 背景模式:默认/暖色/阅读灯/自定义图片
|
||||
- 模型智能:低/中/高思考级别
|
||||
- 隐私控制:隐私模式防止发送IP
|
||||
- 多语言界面:中英日韩德法
|
||||
|
||||
### 语音功能
|
||||
- TTS文字转语音(macOS)
|
||||
- STT语音转文字
|
||||
|
||||
## 技术架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Frontend["前端 (Vue3 + Vite)"]
|
||||
A[App.vue] --> B[MilkdownEditor.vue]
|
||||
B --> C[Crepe Editor]
|
||||
C --> D[ProseMirror]
|
||||
D --> E[copilotPlugin.ts]
|
||||
E --> F[copilotGhostMark]
|
||||
E --> G[api.js]
|
||||
end
|
||||
|
||||
subgraph Backend["后端 (FastAPI + Python)"]
|
||||
H[main.py<br/>FastAPI Server] --> I[prompt.py<br/>Prompt 构建]
|
||||
H --> J[llm.py<br/>Ollama 调用]
|
||||
J --> K[Ollama API]
|
||||
end
|
||||
|
||||
G -->|POST /v1/completions<br/>SSE 流式响应| H
|
||||
K -->|LLM 响应| J
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
llm-in-text/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ └── MilkdownEditor.vue # 主编辑器组件
|
||||
│ ├── plugins/
|
||||
│ │ ├── copilotPlugin.ts # ProseMirror AI 补全插件
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ └── index.ts # 插件导出
|
||||
│ ├── utils/
|
||||
│ │ ├── api.js # API 调用封装
|
||||
│ │ ├── config.js # 配置文件
|
||||
│ │ └── ocrCache.js # OCR 缓存管理
|
||||
│ ├── App.vue
|
||||
│ └── main.js
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI 服务器
|
||||
│ ├── llm.py # LLM API 调用
|
||||
│ ├── prompt.py # Prompt 构建
|
||||
│ └── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
前端: Vue3 + Vite + Milkdown + ProseMirror
|
||||
后端: FastAPI + Python + Ollama
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- Python 3.8+
|
||||
- Ollama 服务(或其他兼容 OpenAI API 的服务)
|
||||
环境: Node.js 18+、Python 3.8+、Ollama
|
||||
|
||||
### 安装
|
||||
安装:
|
||||
- 前端: npm install
|
||||
- 后端: pip install -r backend/requirements.txt
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
启动:
|
||||
- 后端: python backend/main.py (端口8001)
|
||||
- 前端: npm run dev (端口5173)
|
||||
|
||||
# 后端
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
## API接口
|
||||
|
||||
### 配置
|
||||
|
||||
在 `backend/.env` 中配置:
|
||||
|
||||
```env
|
||||
OLLAMA_MODEL=gpt-oss:20b
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
```
|
||||
|
||||
### 启动
|
||||
|
||||
```bash
|
||||
# 后端(端口 8000)
|
||||
cd backend
|
||||
python main.py
|
||||
|
||||
# 前端(端口 5173)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:5173
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /v1/completions
|
||||
|
||||
流式获取补全建议
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"prefix": "# Title\n\nContent ",
|
||||
"suffix": "",
|
||||
"languageId": "markdown"
|
||||
}
|
||||
```
|
||||
|
||||
**响应(SSE):**
|
||||
```
|
||||
data: {"content": "here"}
|
||||
data: {"content": "here is"}
|
||||
data: {"done": true}
|
||||
```
|
||||
- POST /v1/completions 流式补全建议
|
||||
- POST /v1/ocr 图片文字识别
|
||||
- POST /v1/convert 文档转换
|
||||
- POST /v1/completions/cancel 取消请求
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 后端设计
|
||||
### 后端
|
||||
- main.py: FastAPI服务器、SSE流式响应
|
||||
- llm.py: 异步Ollama调用、超时控制
|
||||
- prompt.py: 7条Prompt规则
|
||||
- tts_asr.py: macOS 语音处理
|
||||
|
||||
#### main.py - FastAPI 服务器
|
||||
- 定义 `/v1/completions` 端点
|
||||
- 使用 `StreamingResponse` 返回 SSE 流式响应
|
||||
- CORS 配置允许跨域请求
|
||||
|
||||
#### llm.py - LLM 调用封装
|
||||
- 使用 `ollama.AsyncClient` 异步调用
|
||||
- 支持 `think='high'` 思考模式
|
||||
- 返回 `content` 和 `thinking` 字段
|
||||
|
||||
#### prompt.py - Prompt 工程
|
||||
精心设计的 Prompt 模板,包含 7 条核心规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| RULE #1 | 无缝连接 - 不重复 suffix 内容,避免"复读机"错误 |
|
||||
| RULE #2 | 空白处理 - 避免双空格,正确对接标点 |
|
||||
| RULE #3 | 缩进对齐 - 匹配当前缩进级别和类型 |
|
||||
| RULE #4 | 列表维护 - 识别并继续任务列表、有序列表、无序列表 |
|
||||
| RULE #5 | 语法闭合 - 自动闭合未完成的 Markdown 语法 |
|
||||
| RULE #6 | 输出格式 - 仅输出续写文本,无解释无注释 |
|
||||
| RULE #7 | 必须输出 - 始终提供有用的续写建议 |
|
||||
|
||||
### 前端设计
|
||||
|
||||
#### ProseMirror Mark 系统
|
||||
|
||||
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
|
||||
|
||||
```typescript
|
||||
// 定义 ghost mark
|
||||
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||
excludes: '_',
|
||||
inclusive: true,
|
||||
toDOM: () => ['span', {
|
||||
'data-copilot-ghost': '',
|
||||
class: 'copilot-ghost-text'
|
||||
}, 0]
|
||||
}))
|
||||
|
||||
// CSS 样式
|
||||
.copilot-ghost-text {
|
||||
color: #999;
|
||||
opacity: 0.6;
|
||||
}
|
||||
```
|
||||
|
||||
#### copilotPlugin 核心逻辑
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[用户输入] --> B{文档变化?}
|
||||
B -->|是| C[清除旧建议]
|
||||
C --> D[防抖 1000ms]
|
||||
D --> E[发送 API 请求]
|
||||
E --> F[收到建议]
|
||||
F --> G[插入 Ghost Text]
|
||||
|
||||
G --> H{用户操作}
|
||||
H -->|Tab| I[接受建议<br/>移除 mark]
|
||||
H -->|Esc| J[拒绝建议<br/>删除文本]
|
||||
H -->|点击 Ghost| I
|
||||
H -->|继续输入| J
|
||||
```
|
||||
|
||||
#### 关键函数
|
||||
|
||||
| 函数 | 作用 |
|
||||
|------|------|
|
||||
| `scheduleFetch` | 防抖调度 API 请求 |
|
||||
| `insertGhostText` | 插入带 mark 的建议文本 |
|
||||
| `acceptSuggestion` | Tab 接受建议 |
|
||||
| `rejectSuggestion` | Esc 拒绝建议 |
|
||||
| `clearGhostText` | 清除当前建议 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant E as Editor (ProseMirror)
|
||||
participant P as copilotPlugin
|
||||
participant A as api.js
|
||||
participant B as Backend
|
||||
participant L as LLM
|
||||
|
||||
U->>E: 输入文本
|
||||
E->>P: view.update()
|
||||
P->>P: 清除旧建议
|
||||
P->>P: 防抖 1000ms
|
||||
P->>A: fetchSuggestion(prefix, suffix)
|
||||
A->>B: POST /v1/completions
|
||||
B->>B: build_prompt()
|
||||
B->>L: ollama.chat()
|
||||
L-->>B: {content, thinking}
|
||||
B-->>A: SSE stream
|
||||
A-->>P: suggestion text
|
||||
P->>E: insertGhostText()
|
||||
E-->>U: 显示灰色建议
|
||||
|
||||
alt Tab 键
|
||||
U->>P: Tab
|
||||
P->>E: acceptSuggestion()
|
||||
E-->>U: 建议变为正常文本
|
||||
else Esc 键
|
||||
U->>P: Esc
|
||||
P->>E: rejectSuggestion()
|
||||
E-->>U: 建议消失
|
||||
else 继续输入
|
||||
U->>E: 输入其他字符
|
||||
E->>P: handleKeyDown()
|
||||
P->>E: clearGhostText()
|
||||
end
|
||||
```
|
||||
### 前端
|
||||
- copilotPlugin.ts: ProseMirror Mark系统
|
||||
- 关键函数: scheduleFetch、insertGhostText
|
||||
- Pinia Store状态管理
|
||||
|
||||
## 设计亮点
|
||||
|
||||
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
|
||||
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求
|
||||
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
|
||||
4. **多种交互方式**:Tab/Esc/点击/输入,用户体验友好
|
||||
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能
|
||||
1. 前后端分离
|
||||
2. 低延迟优化:防抖+SSE+AbortController
|
||||
3. ProseMirror Mark系统
|
||||
4. 多种交互方式
|
||||
5. 智能大小限制
|
||||
6. 隐私保护
|
||||
7. 多语言支持
|
||||
8. 主题定制
|
||||
9. 文档处理
|
||||
10. 语音功能
|
||||
|
||||
## 开发指南
|
||||
|
||||
代码风格: Python(4空格,snake_case) JS/TS(2空格,camelCase)
|
||||
测试: pytest
|
||||
构建: npm run build
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const path = require('path')
|
||||
const { convert } = require('docx2pdf-converter')
|
||||
|
||||
function main() {
|
||||
const inputPath = process.argv[2]
|
||||
const outputPath = process.argv[3]
|
||||
|
||||
if (!inputPath || !outputPath) {
|
||||
throw new Error('缺少 DOCX 或 PDF 路径')
|
||||
}
|
||||
|
||||
convert(path.resolve(inputPath), path.resolve(outputPath))
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
+13
-5
@@ -9,9 +9,14 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
|
||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
||||
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
||||
|
||||
# Timeouts in seconds
|
||||
COMPLETION_TIMEOUT = 30
|
||||
OCR_TIMEOUT = 60
|
||||
CONVERT_TIMEOUT = 30
|
||||
|
||||
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
||||
logger = logging.getLogger("llm")
|
||||
|
||||
@@ -58,10 +63,10 @@ def _extract_message(response) -> tuple[str, str]:
|
||||
async def call_ollama(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str = None,
|
||||
system_prompt: str | None = None,
|
||||
tag: str = "default",
|
||||
temperature: float = 0.7,
|
||||
thinking: str = None,
|
||||
thinking: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
调用 Ollama API 并返回 content 和 thinking。
|
||||
@@ -97,7 +102,7 @@ async def call_ollama(
|
||||
if thinking:
|
||||
kwargs["think"] = thinking
|
||||
|
||||
response = await client.chat(**kwargs)
|
||||
response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
|
||||
except asyncio.CancelledError:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
@@ -156,7 +161,8 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.chat(
|
||||
response = await asyncio.wait_for(
|
||||
client.chat(
|
||||
model=VLM_MODEL,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
@@ -165,6 +171,8 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||
}],
|
||||
stream=False,
|
||||
options={'temperature': 0.3}
|
||||
),
|
||||
timeout=OCR_TIMEOUT
|
||||
)
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
+109
-25
@@ -1,15 +1,17 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Security
|
||||
from fastapi import FastAPI, HTTPException, Request, Security, File, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.security import APIKeyHeader
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -81,6 +83,32 @@ class ConvertRequest(BaseModel):
|
||||
filename: str = "document.pdf"
|
||||
|
||||
|
||||
ALLOWED_CONVERT_EXTENSIONS = {".txt", ".docx", ".pptx", ".pdf"}
|
||||
IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*]\([^)]+\)")
|
||||
IMAGE_HTML_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _convert_docx_to_pdf(input_path: str, output_path: str) -> None:
|
||||
node_executable = shutil.which("node")
|
||||
if not node_executable:
|
||||
raise RuntimeError("未找到 Node.js,无法转换 DOCX 为 PDF")
|
||||
|
||||
bridge_path = os.path.join(os.path.dirname(__file__), "docx2pdf_bridge.cjs")
|
||||
if not os.path.exists(bridge_path):
|
||||
raise RuntimeError("缺少 DOCX 转 PDF 桥接脚本")
|
||||
|
||||
result = subprocess.run(
|
||||
[node_executable, bridge_path, input_path, output_path],
|
||||
cwd=os.path.dirname(os.path.dirname(__file__)),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_text = (result.stderr or result.stdout or "DOCX 转 PDF 失败").strip()
|
||||
raise RuntimeError(error_text)
|
||||
|
||||
|
||||
def _preview(text: str, limit: int = 80) -> str:
|
||||
value = (text or "").replace("\n", "\\n")
|
||||
if len(value) <= limit:
|
||||
@@ -88,8 +116,12 @@ def _preview(text: str, limit: int = 80) -> str:
|
||||
return value[:limit] + "..."
|
||||
|
||||
|
||||
def _sse_payload(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
def _sanitize_converted_markdown(text: str) -> str:
|
||||
value = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
value = IMAGE_MARKDOWN_RE.sub("", value)
|
||||
value = IMAGE_HTML_RE.sub("", value)
|
||||
value = re.sub(r"\n{3,}", "\n\n", value)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
@@ -149,7 +181,6 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
||||
)
|
||||
)
|
||||
|
||||
async with ACTIVE_COMPLETIONS_LOCK:
|
||||
existing = ACTIVE_COMPLETIONS.get(request_id)
|
||||
if existing and not existing.done():
|
||||
existing.cancel()
|
||||
@@ -167,23 +198,14 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
||||
_preview(content, 120),
|
||||
)
|
||||
|
||||
async def generate():
|
||||
yield _sse_payload({"content": content})
|
||||
yield _sse_payload({"done": True})
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
return JSONResponse(content={"content": content, "request_id": request_id})
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[%s] /v1/completions cancelled request_id=%s", request_tag, request_id)
|
||||
|
||||
async def cancelled():
|
||||
yield _sse_payload({"cancelled": True, "request_id": request_id, "done": True})
|
||||
|
||||
return StreamingResponse(cancelled(), media_type="text/event-stream")
|
||||
return JSONResponse(content={"cancelled": True, "request_id": request_id}, status_code=499)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] /v1/completions failed request_id=%s: %s", request_tag, request_id, e)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
finally:
|
||||
async with ACTIVE_COMPLETIONS_LOCK:
|
||||
active = ACTIVE_COMPLETIONS.get(request_id)
|
||||
if active is not None and active is inference_task:
|
||||
ACTIVE_COMPLETIONS.pop(request_id, None)
|
||||
@@ -253,7 +275,7 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
||||
|
||||
@app.post("/v1/convert")
|
||||
async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(get_api_key)):
|
||||
"""将文件转换为Markdown格式"""
|
||||
"""Convert file to markdown"""
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
|
||||
try:
|
||||
@@ -264,23 +286,33 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
len(request.file or ""),
|
||||
)
|
||||
|
||||
# 解码Base64文件内容
|
||||
# Decode base64
|
||||
file_bytes = base64.b64decode(request.file)
|
||||
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
|
||||
|
||||
# 获取文件扩展名
|
||||
# Get file extension
|
||||
ext = os.path.splitext(request.filename)[1].lower()
|
||||
|
||||
# 创建临时文件
|
||||
if ext not in ALLOWED_CONVERT_EXTENSIONS:
|
||||
raise ValueError("仅支持 txt、docx、pptx、pdf 格式")
|
||||
|
||||
if ext == ".txt":
|
||||
markdown_text = _sanitize_converted_markdown(file_bytes.decode("utf-8", errors="ignore"))
|
||||
return {
|
||||
"markdown": markdown_text,
|
||||
"filename": request.filename
|
||||
}
|
||||
|
||||
# Create temporary file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
||||
tmp.write(file_bytes)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# 使用MarkItDown转换为Markdown
|
||||
# Convert using MarkItDown
|
||||
md = markitdown.MarkItDown()
|
||||
result = md.convert(tmp_path)
|
||||
markdown_text = result.text_content
|
||||
markdown_text = _sanitize_converted_markdown(result.text_content)
|
||||
|
||||
logger.info(
|
||||
"[%s] /v1/convert success text_chars=%d text_preview='%s'",
|
||||
@@ -294,7 +326,7 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
"filename": request.filename
|
||||
}
|
||||
finally:
|
||||
# 清理临时文件
|
||||
# Clean up temporary file
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@@ -303,7 +335,59 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post("/v1/export/pdf")
|
||||
async def export_pdf(file: UploadFile = File(...), api_key: str = Security(get_api_key)):
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
original_name = file.filename or "document.docx"
|
||||
base_name = os.path.splitext(original_name)[0] or "document"
|
||||
|
||||
try:
|
||||
file_bytes = await file.read()
|
||||
logger.info(
|
||||
"[%s] /v1/export/pdf filename=%s file_bytes=%d",
|
||||
request_id,
|
||||
original_name,
|
||||
len(file_bytes),
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
input_path = os.path.join(temp_dir, f"{base_name}.docx")
|
||||
output_path = os.path.join(temp_dir, f"{base_name}.pdf")
|
||||
|
||||
with open(input_path, "wb") as tmp_file:
|
||||
tmp_file.write(file_bytes)
|
||||
|
||||
await asyncio.to_thread(_convert_docx_to_pdf, input_path, output_path)
|
||||
|
||||
if not os.path.exists(output_path):
|
||||
raise RuntimeError("PDF 转换后未生成输出文件")
|
||||
|
||||
with open(output_path, "rb") as pdf_file:
|
||||
pdf_bytes = pdf_file.read()
|
||||
|
||||
logger.info("[%s] /v1/export/pdf success pdf_bytes=%d", request_id, len(pdf_bytes))
|
||||
headers = {
|
||||
"Content-Disposition": f'attachment; filename="{base_name}.pdf"',
|
||||
}
|
||||
return Response(content=pdf_bytes, media_type="application/pdf", headers=headers)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] /v1/export/pdf failed: %s", request_id, e)
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
|
||||
# TTS and ASR routes (lazy loaded to avoid heavy import on startup)
|
||||
def _register_tts_asr_routes():
|
||||
from tts_asr import register_tts_asr_routes
|
||||
register_tts_asr_routes(app)
|
||||
|
||||
_register_tts_asr_routes()
|
||||
|
||||
|
||||
+258
-199
@@ -1,6 +1,13 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import re
|
||||
from typing import Tuple
|
||||
from typing import Protocol, Tuple, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UserPreferences(Protocol):
|
||||
language: str
|
||||
currency: str
|
||||
timezone: str
|
||||
|
||||
|
||||
def _get_current_datetime(timezone_pref: str = "auto") -> str:
|
||||
@@ -214,113 +221,107 @@ def _canonical_language_id(language_id: str) -> str:
|
||||
return LANGUAGE_SYNONYMS.get(safe, safe)
|
||||
|
||||
|
||||
def _language_guidance(language_id: str) -> str:
|
||||
canonical = _canonical_language_id(language_id)
|
||||
if canonical == "markdown":
|
||||
return ""
|
||||
if canonical == "mermaid":
|
||||
return """
|
||||
_JS_LANGS = {"javascript", "typescript"}
|
||||
_CODE_LANGS = {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}
|
||||
|
||||
_LANG_GUIDANCE = {
|
||||
"mermaid": """
|
||||
Language-specific guidance (mermaid):
|
||||
- Output valid Mermaid syntax only.
|
||||
- Prefer concise, syntactically correct diagram statements.
|
||||
- Avoid prose unless the user prompt explicitly requires it."""
|
||||
if canonical == "latex":
|
||||
return """
|
||||
- Avoid prose unless the user prompt explicitly requires it.""",
|
||||
"latex": """
|
||||
Language-specific guidance (latex):
|
||||
- Output LaTeX math content only when completing LaTeX.
|
||||
- If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex:
|
||||
- Output raw LaTeX lines only.
|
||||
- Do not wrap with $ or $$."""
|
||||
if canonical == "json":
|
||||
return """
|
||||
- Do not wrap with $ or $$.""",
|
||||
"json": """
|
||||
Language-specific guidance (json):
|
||||
- Output strict JSON only (no comments, no trailing commas).
|
||||
- Ensure valid quotes and braces."""
|
||||
if canonical == "yaml":
|
||||
return """
|
||||
- Ensure valid quotes and braces.""",
|
||||
"yaml": """
|
||||
Language-specific guidance (yaml):
|
||||
- Output valid YAML only.
|
||||
- Use consistent indentation and avoid tabs."""
|
||||
if canonical == "toml":
|
||||
return """
|
||||
- Use consistent indentation and avoid tabs.""",
|
||||
"toml": """
|
||||
Language-specific guidance (toml):
|
||||
- Output valid TOML only.
|
||||
- Keep key types consistent."""
|
||||
if canonical == "ini":
|
||||
return """
|
||||
- Keep key types consistent.""",
|
||||
"ini": """
|
||||
Language-specific guidance (ini):
|
||||
- Output valid INI only.
|
||||
- Keep section headers and key=value pairs consistent."""
|
||||
if canonical == "sql":
|
||||
return """
|
||||
- Keep section headers and key=value pairs consistent.""",
|
||||
"sql": """
|
||||
Language-specific guidance (sql):
|
||||
- Output a single, valid SQL statement unless context requires multiple.
|
||||
- Prefer ANSI SQL when dialect is unclear."""
|
||||
if canonical == "bash":
|
||||
return """
|
||||
- Prefer ANSI SQL when dialect is unclear.""",
|
||||
"bash": """
|
||||
Language-specific guidance (bash):
|
||||
- Output POSIX-compatible shell when possible.
|
||||
- Avoid interactive prompts or destructive commands unless requested."""
|
||||
if canonical == "powershell":
|
||||
return """
|
||||
- Avoid interactive prompts or destructive commands unless requested.""",
|
||||
"powershell": """
|
||||
Language-specific guidance (powershell):
|
||||
- Output valid PowerShell commands.
|
||||
- Avoid destructive commands unless explicitly requested."""
|
||||
if canonical == "html":
|
||||
return """
|
||||
- Avoid destructive commands unless explicitly requested.""",
|
||||
"html": """
|
||||
Language-specific guidance (html):
|
||||
- Output valid HTML only.
|
||||
- Keep markup minimal and well-formed."""
|
||||
if canonical == "css":
|
||||
return """
|
||||
- Keep markup minimal and well-formed.""",
|
||||
"css": """
|
||||
Language-specific guidance (css):
|
||||
- Output valid CSS only.
|
||||
- Use concise, readable selectors."""
|
||||
if canonical == "diff":
|
||||
return """
|
||||
- Use concise, readable selectors.""",
|
||||
"diff": """
|
||||
Language-specific guidance (diff):
|
||||
- Output a unified diff only.
|
||||
- Ensure @@ hunk headers and +/- lines are consistent."""
|
||||
if canonical == "regex":
|
||||
return """
|
||||
- Ensure @@ hunk headers and +/- lines are consistent.""",
|
||||
"regex": """
|
||||
Language-specific guidance (regex):
|
||||
- Output the regex pattern only.
|
||||
- Avoid delimiters unless explicitly requested."""
|
||||
if canonical in {"javascript", "typescript"}:
|
||||
return f"""
|
||||
Language-specific guidance ({canonical}):
|
||||
- Output valid {canonical} code.
|
||||
- Prefer modern syntax and avoid prose unless comments are needed."""
|
||||
if canonical in {"python", "go", "rust", "java", "kotlin", "swift", "ruby", "php", "lua", "c", "cpp", "csharp", "r", "matlab", "dart"}:
|
||||
return f"""
|
||||
Language-specific guidance ({canonical}):
|
||||
- Output valid {canonical} code.
|
||||
- Avoid prose unless context clearly expects comments or docstrings."""
|
||||
if canonical == "text":
|
||||
return """
|
||||
- Avoid delimiters unless explicitly requested.""",
|
||||
"text": """
|
||||
Language-specific guidance (text):
|
||||
- Output plain text only.
|
||||
- Avoid markdown formatting unless explicitly asked."""
|
||||
if canonical == "xml":
|
||||
return """
|
||||
- Avoid markdown formatting unless explicitly asked.""",
|
||||
"xml": """
|
||||
Language-specific guidance (xml):
|
||||
- Output well-formed XML only.
|
||||
- Ensure matching tags and proper escaping."""
|
||||
if canonical == "dockerfile":
|
||||
return """
|
||||
- Ensure matching tags and proper escaping.""",
|
||||
"dockerfile": """
|
||||
Language-specific guidance (dockerfile):
|
||||
- Output valid Dockerfile instructions only.
|
||||
- Keep layers minimal and ordered logically."""
|
||||
if canonical == "makefile":
|
||||
return """
|
||||
- Keep layers minimal and ordered logically.""",
|
||||
"makefile": """
|
||||
Language-specific guidance (makefile):
|
||||
- Output valid Makefile syntax only.
|
||||
- Use tabs for recipe lines."""
|
||||
return f"""
|
||||
Language-specific guidance ({canonical}):
|
||||
- Output valid {canonical} code.
|
||||
- Use tabs for recipe lines.""",
|
||||
}
|
||||
|
||||
_GENERIC_CODE = """
|
||||
Language-specific guidance ({lang}):
|
||||
- Output valid {lang} code.
|
||||
- Avoid prose unless context clearly expects comments or docstrings."""
|
||||
|
||||
_JS_CODE = """
|
||||
Language-specific guidance ({lang}):
|
||||
- Output valid {lang} code.
|
||||
- Prefer modern syntax and avoid prose unless comments are needed."""
|
||||
|
||||
|
||||
def _language_guidance(language_id: str) -> str:
|
||||
canonical = _canonical_language_id(language_id)
|
||||
if canonical == "markdown":
|
||||
return ""
|
||||
guidance = _LANG_GUIDANCE.get(canonical)
|
||||
if guidance:
|
||||
return guidance
|
||||
if canonical in _JS_LANGS:
|
||||
return _JS_CODE.format(lang=canonical)
|
||||
if canonical in _CODE_LANGS:
|
||||
return _GENERIC_CODE.format(lang=canonical)
|
||||
return _GENERIC_CODE.format(lang=canonical)
|
||||
|
||||
|
||||
def build_inline_system_prompt(language_id: str = "markdown") -> str:
|
||||
safe_language_id = _canonical_language_id(language_id)
|
||||
@@ -330,82 +331,103 @@ def build_inline_system_prompt(language_id: str = "markdown") -> str:
|
||||
|
||||
Return only the insertion text that should be placed between PREFIX and SUFFIX.
|
||||
|
||||
Hard constraints you must follow:
|
||||
1) Output-only contract:
|
||||
- Output insertion text only.
|
||||
- No explanations, no meta labels, no wrapper quotes around the whole answer.
|
||||
CORE PRINCIPLE: Output insertion text only. No explanations, no meta labels, no wrapper quotes.
|
||||
|
||||
2) Strict math formatting (KaTeX):
|
||||
- If you output any math expression, it must be strict KaTeX-compatible math.
|
||||
- Every formula must be wrapped with either $...$ (inline) or $$...$$ (block).
|
||||
- Never output bare formulas without $ or $$ wrappers.
|
||||
- Exception: If CURSOR_IN_FENCED_CODE_BLOCK=true and CURSOR_FENCE_LANGUAGE is latex/tex/katex,
|
||||
output raw LaTeX without $ or $$ wrappers.
|
||||
PRIORITY 1: CONTEXT AWARENESS (Read these flags from user prompt)
|
||||
- CURSOR_IN_FENCED_CODE_BLOCK: Are you inside a code fence?
|
||||
- CURSOR_FENCE_LANGUAGE: What language is the current fence?
|
||||
- PREFIX_ENDS_WITH_NEWLINE: Does prefix end with newline?
|
||||
- SUFFIX_STARTS_WITH_NEWLINE: Does suffix start with newline?
|
||||
- MERMAID_CONTEXT: Is this a Mermaid diagram context?
|
||||
|
||||
3) Strict code formatting:
|
||||
- Read CURSOR_IN_FENCED_CODE_BLOCK from the user prompt.
|
||||
- If CURSOR_IN_FENCED_CODE_BLOCK=true:
|
||||
- You are already inside a fenced code block.
|
||||
- Never output triple backticks.
|
||||
- Output code lines only.
|
||||
- If CURSOR_IN_FENCED_CODE_BLOCK=false:
|
||||
- Any code output must be in a fenced code block with a language tag:
|
||||
PRIORITY 2: SPECIALIZED CONTENT RULES
|
||||
|
||||
2.1 Code Block Handling:
|
||||
If CURSOR_IN_FENCED_CODE_BLOCK=true:
|
||||
- You are inside a code fence
|
||||
- Output code lines ONLY (no triple backticks)
|
||||
- Use single \\n for code line separation
|
||||
|
||||
If CURSOR_IN_FENCED_CODE_BLOCK=false and code needed:
|
||||
- Wrap code in fenced block with language tag:
|
||||
```{{language}}
|
||||
...
|
||||
code here
|
||||
```
|
||||
- Do not output code snippets as inline backticks.
|
||||
- Choose the language tag from context (no default fallback tag instruction).
|
||||
- Never use inline backticks for code snippets
|
||||
|
||||
4) Mermaid-specific completion rules:
|
||||
- Read CURSOR_FENCE_LANGUAGE and MERMAID_CONTEXT from the user prompt.
|
||||
- If CURSOR_FENCE_LANGUAGE=mermaid:
|
||||
- Output Mermaid statements only.
|
||||
- Never output triple backticks.
|
||||
- Never output prose explanations.
|
||||
- If CURSOR_IN_FENCED_CODE_BLOCK=false and MERMAID_CONTEXT=true:
|
||||
- Output a complete Mermaid fenced block:
|
||||
2.2 Math Formatting (KaTeX):
|
||||
- Inline math: wrap with $...$
|
||||
- Block math: wrap with $$...$$
|
||||
- Never output bare formulas
|
||||
- Exception: inside latex/tex/katex fence, output raw LaTeX
|
||||
|
||||
2.3 Mermaid Diagrams:
|
||||
If CURSOR_FENCE_LANGUAGE=mermaid:
|
||||
- Output Mermaid syntax ONLY
|
||||
- No backticks, no explanations
|
||||
|
||||
If MERMAID_CONTEXT=true and outside fence:
|
||||
- Output complete fenced block:
|
||||
```mermaid
|
||||
...
|
||||
diagram syntax
|
||||
```
|
||||
- Keep Mermaid syntax valid and concise.
|
||||
- Never mix Mermaid code and explanatory narration in one output.
|
||||
|
||||
5) Boundary newline repair:
|
||||
- Read PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE from the user prompt.
|
||||
- Carefully reason about whether OUTPUT should start or end with a newline.
|
||||
- If PREFIX lacks a required boundary newline, add it at OUTPUT start.
|
||||
- If SUFFIX lacks a required boundary newline, add it at OUTPUT end.
|
||||
- Ensure PREFIX + OUTPUT + SUFFIX is structurally natural.
|
||||
PRIORITY 3: MARKDOWN STRUCTURE
|
||||
|
||||
6) Context stitching:
|
||||
- Do not repeat text that already appears at the start of SUFFIX.
|
||||
- Preserve nearby language, tone, punctuation, indentation, and markdown structure.
|
||||
- Continue existing structures naturally (lists, tables, block quotes, headings).
|
||||
3.1 Newline Semantics:
|
||||
- Single \\n: soft break (same paragraph, renders as space or <br>)
|
||||
- Double \\n\\n: hard break (new paragraph/block)
|
||||
- Use \\n\\n for: new paragraphs, before headings, starting lists/tables
|
||||
- Use \\n for: continuation within blocks (list items, table cells)
|
||||
- Exception: inside code blocks, use \\n freely for code lines
|
||||
|
||||
7) OCR safety:
|
||||
- PREFIX may include hidden OCR metadata tags like <OCR:...>.
|
||||
- Never output any OCR tag.
|
||||
- Never output OCR tag fragments such as <OCR:...>."""
|
||||
3.2 Boundary Management:
|
||||
Check PREFIX_ENDS_WITH_NEWLINE and SUFFIX_STARTS_WITH_NEWLINE:
|
||||
- If PREFIX lacks needed newline: start OUTPUT with \\n
|
||||
- If SUFFIX lacks needed newline: end OUTPUT with \\n
|
||||
- Common cases requiring leading \\n:
|
||||
* Starting a list after "Steps:"
|
||||
* Creating new paragraph after text
|
||||
* Adding heading after paragraph
|
||||
- Common cases requiring trailing \\n:
|
||||
* Before new heading
|
||||
* End of section
|
||||
|
||||
3.3 Context Stitching:
|
||||
- Never repeat text from SUFFIX beginning
|
||||
- Match PREFIX tone, style, indentation
|
||||
- Continue structures: lists, tables, quotes, headings
|
||||
|
||||
PRIORITY 4: HIDDEN CONTEXT
|
||||
- OCR metadata like <OCR:...> is hidden context
|
||||
- Never copy OCR tags to output
|
||||
- Use OCR content as semantic hint only
|
||||
"""
|
||||
|
||||
if language_guidance:
|
||||
system_prompt = f"{system_prompt.rstrip()}\n{language_guidance.strip()}"
|
||||
system_prompt = f"{system_prompt.rstrip()}\\n{language_guidance.strip()}"
|
||||
|
||||
return system_prompt.strip()
|
||||
|
||||
|
||||
INLINE_EXAMPLES = """[EX01] Prose continuation
|
||||
INLINE_EXAMPLES = """=== CATEGORY A: PROSE CONTINUATION ===
|
||||
|
||||
[EX01] Simple prose continuation
|
||||
<PREFIX>The quick brown fox </PREFIX>
|
||||
<SUFFIX>jumps over the lazy dog.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
moved quietly and then
|
||||
|
||||
[EX02] Avoid repeating suffix beginning
|
||||
[EX02] Avoid repeating suffix
|
||||
<PREFIX>Our launch plan starts with </PREFIX>
|
||||
<SUFFIX>phase one, followed by phase two.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
careful internal testing before
|
||||
WRONG: phase one starts with (repeats suffix)
|
||||
|
||||
[EX03] Continue markdown checklist
|
||||
=== CATEGORY B: MARKDOWN STRUCTURES ===
|
||||
|
||||
[EX03] Continue checklist
|
||||
<PREFIX>## TODO
|
||||
- [ ] Buy milk
|
||||
- [ ] </PREFIX>
|
||||
@@ -413,41 +435,7 @@ careful internal testing before
|
||||
Expected OUTPUT:
|
||||
Write release notes and share draft with team
|
||||
|
||||
[EX04] Cursor outside code block, code must use fenced block
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
||||
<PREFIX>Parse this JSON payload in Python:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
```python
|
||||
import json
|
||||
data = json.loads(payload)
|
||||
```
|
||||
|
||||
[EX05] Cursor inside fenced code block, do not output fences
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=true
|
||||
<PREFIX>```python
|
||||
def add(a, b):
|
||||
return </PREFIX>
|
||||
<SUFFIX>
|
||||
```</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
a + b
|
||||
|
||||
[EX06] Inline math must use $...$
|
||||
<PREFIX>The derivative of x^2 is </PREFIX>
|
||||
<SUFFIX>.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$2x$
|
||||
|
||||
[EX07] Block math must use $$...$$
|
||||
<PREFIX>We can write the Gaussian integral as:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$$
|
||||
\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}
|
||||
$$
|
||||
|
||||
[EX08] Prefix misses boundary newline; add newline at output start
|
||||
[EX04] Start list after header (PREFIX lacks newline)
|
||||
PREFIX_ENDS_WITH_NEWLINE=false
|
||||
<PREFIX>Deployment steps:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
@@ -456,21 +444,7 @@ Expected OUTPUT:
|
||||
- Build artifact
|
||||
- Deploy service
|
||||
|
||||
[EX09] Suffix misses boundary newline; add newline at output end
|
||||
SUFFIX_STARTS_WITH_NEWLINE=false
|
||||
<PREFIX>Summary paragraph complete.</PREFIX>
|
||||
<SUFFIX>## Next Section</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
|
||||
|
||||
[EX10] OCR metadata exists but must never be emitted
|
||||
<PREFIX> <OCR:equation y = mx + b>
|
||||
The relationship is </PREFIX>
|
||||
<SUFFIX>.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$y = mx + b$
|
||||
|
||||
[EX11] Continue markdown table with correct row shape
|
||||
[EX05] Continue table row
|
||||
<PREFIX>| Name | Score |
|
||||
| --- | --- |
|
||||
| Alice | 92 |
|
||||
@@ -479,30 +453,91 @@ $y = mx + b$
|
||||
Expected OUTPUT:
|
||||
88 |
|
||||
|
||||
[EX12] Mixed text + math + code in one insertion
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
||||
<PREFIX>Use the area formula and provide a tiny JS helper.</PREFIX>
|
||||
[EX06] Start new paragraph
|
||||
<PREFIX>First paragraph ends.</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
The area is $A = \\pi r^2$.
|
||||
|
||||
```javascript
|
||||
const area = (r) => Math.PI * r * r;
|
||||
Second paragraph starts.
|
||||
WRONG: Second paragraph starts. (missing leading \\n\\n)
|
||||
|
||||
[EX07] Add newline before heading
|
||||
PREFIX_ENDS_WITH_NEWLINE=false
|
||||
<PREFIX>End of previous section.</PREFIX>
|
||||
<SUFFIX>## Next Heading</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
|
||||
WRONG: (would join with heading without separation)
|
||||
|
||||
=== CATEGORY C: CODE BLOCKS ===
|
||||
|
||||
[EX08] Outside fence: wrap code in fence
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
||||
<PREFIX>Parse this JSON payload in Python:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
```python
|
||||
import json
|
||||
data = json.loads(payload)
|
||||
```
|
||||
WRONG: import json\\ndata = json.loads(payload) (no fence)
|
||||
|
||||
[EX13] Cursor inside mermaid fence: no backticks, mermaid lines only
|
||||
[EX09] Inside fence: output code only
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=true
|
||||
<PREFIX>```python
|
||||
def add(a, b):
|
||||
return </PREFIX>
|
||||
<SUFFIX>
|
||||
```</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
a + b
|
||||
WRONG: ```python\\nreturn a + b\\n``` (duplicate fences)
|
||||
|
||||
[EX10] Code inside fence uses single newline
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=true
|
||||
<PREFIX>```python
|
||||
def hello():</PREFIX>
|
||||
<SUFFIX>
|
||||
```</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
print("Hello")
|
||||
return True
|
||||
(Note: single \\n between code lines, no markdown rules)
|
||||
|
||||
=== CATEGORY D: MATH ===
|
||||
|
||||
[EX11] Inline math
|
||||
<PREFIX>The derivative of x^2 is </PREFIX>
|
||||
<SUFFIX>.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$2x$
|
||||
WRONG: 2x (bare formula)
|
||||
|
||||
[EX12] Block math
|
||||
<PREFIX>We can write the Gaussian integral as:</PREFIX>
|
||||
<SUFFIX></SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$$
|
||||
\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}
|
||||
$$
|
||||
WRONG: \\int... (bare formula without $$)
|
||||
|
||||
=== CATEGORY E: MERMAID ===
|
||||
|
||||
[EX13] Inside mermaid fence
|
||||
CURSOR_FENCE_LANGUAGE=mermaid
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=true
|
||||
<PREFIX>```mermaid
|
||||
flowchart TD
|
||||
A[Start] --> </PREFIX>
|
||||
A[Start] --> </PREFIX>
|
||||
<SUFFIX>
|
||||
```</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
B{Valid?}
|
||||
B -->|Yes| C[Done]
|
||||
WRONG: ```mermaid\\nB{Valid?}... (duplicate fence)
|
||||
|
||||
[EX14] Mermaid context outside fence: return full mermaid block
|
||||
[EX14] Outside fence with mermaid context
|
||||
CURSOR_IN_FENCED_CODE_BLOCK=false
|
||||
MERMAID_CONTEXT=true
|
||||
<PREFIX>Please provide a simple release pipeline diagram.</PREFIX>
|
||||
@@ -510,8 +545,18 @@ MERMAID_CONTEXT=true
|
||||
Expected OUTPUT:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Build --> Test --> Deploy
|
||||
```"""
|
||||
Build --> Test --> Deploy
|
||||
```
|
||||
|
||||
=== CATEGORY F: OCR METADATA ===
|
||||
|
||||
[EX15] Use OCR as context, never output
|
||||
<PREFIX> <OCR:equation y = mx + b>
|
||||
The relationship is </PREFIX>
|
||||
<SUFFIX>.</SUFFIX>
|
||||
Expected OUTPUT:
|
||||
$y = mx + b$
|
||||
WRONG: <OCR:equation y = mx + b> (OCR tag in output)"""
|
||||
|
||||
|
||||
def build_completion_prompts(
|
||||
@@ -520,7 +565,7 @@ def build_completion_prompts(
|
||||
language_id: str = "markdown",
|
||||
location: str = "",
|
||||
thinking_level: str = "low",
|
||||
preferences: object = None,
|
||||
preferences: UserPreferences | None = None,
|
||||
) -> Tuple[str, str]:
|
||||
safe_language_id = _canonical_language_id(language_id)
|
||||
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
|
||||
@@ -551,35 +596,49 @@ def build_completion_prompts(
|
||||
preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}"
|
||||
|
||||
user_prompt = f"""Current time: {current_time}{location_info}{preferences_instruction}
|
||||
Reasoning hint: {thinking_level}
|
||||
Editor language id: {safe_language_id}
|
||||
Reasoning level: {thinking_level}
|
||||
Editor language: {safe_language_id}
|
||||
|
||||
Completion state flags:
|
||||
=== STATE FLAGS ===
|
||||
- CURSOR_IN_FENCED_CODE_BLOCK: {"true" if cursor_in_fenced_code_block else "false"}
|
||||
- CURSOR_FENCE_LANGUAGE: {cursor_fence_language}
|
||||
- MERMAID_CONTEXT: {"true" if mermaid_context else "false"}
|
||||
- PREFIX_ENDS_WITH_NEWLINE: {"true" if prefix_ends_with_newline else "false"}
|
||||
- SUFFIX_STARTS_WITH_NEWLINE: {"true" if suffix_starts_with_newline else "false"}
|
||||
|
||||
Task:
|
||||
- Produce the best insertion text at the cursor between PREFIX and SUFFIX.
|
||||
- Keep insertion meaningful and non-empty.
|
||||
- Keep insertion concise unless structure requires more content.
|
||||
=== TASK ===
|
||||
Produce the best insertion text between PREFIX and SUFFIX.
|
||||
Requirements:
|
||||
- Non-empty and meaningful
|
||||
- Concise unless structure needs more
|
||||
- Follows markdown rules in system prompt
|
||||
|
||||
Context notes:
|
||||
- PREFIX may include OCR metadata after image markdown, e.g.  <OCR:description>.
|
||||
- OCR metadata is hidden context and must never be copied into output.
|
||||
- Preserve local style and formatting.
|
||||
=== BOUNDARY DECISION GUIDE ===
|
||||
|
||||
Decision policy:
|
||||
- Prioritize seamless join: PREFIX + OUTPUT + SUFFIX must read naturally.
|
||||
- Do not repeat SUFFIX-leading text.
|
||||
- If uncertain, prefer a complete short phrase/sentence with clear meaning.
|
||||
Step 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
|
||||
|
||||
Comprehensive examples:
|
||||
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
|
||||
|
||||
Step 3: Choose newline type
|
||||
- Use \\n\\n for: new paragraphs, before headings, starting lists
|
||||
- Use \\n for: continuing within blocks, list items, table cells
|
||||
- Exception: inside code fences, use \\n freely
|
||||
|
||||
=== CONTEXT NOTES ===
|
||||
- OCR metadata (e.g., <OCR:description>) is hidden context, never copy to output
|
||||
- Match PREFIX tone, style, and indentation
|
||||
- Do not repeat text from SUFFIX beginning
|
||||
|
||||
=== EXAMPLES BY CATEGORY ===
|
||||
{INLINE_EXAMPLES}
|
||||
|
||||
Now produce the insertion.
|
||||
=== NOW COMPLETE THE TASK ===
|
||||
|
||||
<PREFIX>
|
||||
{recent_prefix}
|
||||
@@ -601,7 +660,7 @@ def build_prompt(
|
||||
language_id: str = "markdown",
|
||||
location: str = "",
|
||||
thinking_level: str = "low",
|
||||
preferences: object = None,
|
||||
preferences: UserPreferences | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Backward-compatible helper. Returns only the user prompt body.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
fastapi
|
||||
fastapi
|
||||
uvicorn
|
||||
ollama
|
||||
pydantic
|
||||
@@ -10,3 +10,10 @@ python-docx
|
||||
python-pptx
|
||||
openpyxl
|
||||
pypdf
|
||||
|
||||
# TTS and ASR dependencies
|
||||
torch
|
||||
transformers
|
||||
soundfile
|
||||
numpy
|
||||
accelerate
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# TTS and ASR API for macOS Silicon with HuggingFace transformers
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
from pydantic import BaseModel
|
||||
import numpy as np
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("tts_asr")
|
||||
|
||||
_tts_pipeline = None
|
||||
_asr_pipeline = None
|
||||
_device = None
|
||||
|
||||
|
||||
def _get_device():
|
||||
global _device
|
||||
if _device is not None:
|
||||
return _device
|
||||
|
||||
import torch
|
||||
|
||||
if platform.system() == "Darwin" and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
_device = "mps"
|
||||
logger.info("[Device] 使用 MPS 加速")
|
||||
elif torch.cuda.is_available():
|
||||
_device = "cuda"
|
||||
logger.info("[Device] 使用 CUDA 加速")
|
||||
else:
|
||||
_device = "cpu"
|
||||
logger.info("[Device] 使用 CPU")
|
||||
return _device
|
||||
|
||||
|
||||
def _device_arg():
|
||||
device = _get_device()
|
||||
if device == "cuda":
|
||||
return "cuda:0"
|
||||
return device
|
||||
|
||||
|
||||
def _get_tts_pipeline():
|
||||
global _tts_pipeline
|
||||
if _tts_pipeline is not None:
|
||||
return _tts_pipeline
|
||||
|
||||
import torch
|
||||
from transformers import pipeline
|
||||
|
||||
logger.info("[TTS] 加载 Kokoro-82M 模型...")
|
||||
_tts_pipeline = pipeline(
|
||||
"text-to-speech",
|
||||
model="hexgrad/Kokoro-82M",
|
||||
trust_remote_code=True,
|
||||
device=_device_arg(),
|
||||
torch_dtype=torch.float16 if _get_device() != "cpu" else torch.float32,
|
||||
)
|
||||
logger.info("[TTS] Kokoro-82M 模型加载完成")
|
||||
return _tts_pipeline
|
||||
|
||||
|
||||
def _get_asr_pipeline():
|
||||
global _asr_pipeline
|
||||
if _asr_pipeline is not None:
|
||||
return _asr_pipeline
|
||||
|
||||
import torch
|
||||
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
||||
|
||||
logger.info("[ASR] 加载 Whisper large-v3-turbo 模型...")
|
||||
model_id = "openai/whisper-large-v3-turbo"
|
||||
model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
||||
model_id,
|
||||
torch_dtype=torch.float16 if _get_device() != "cpu" else torch.float32,
|
||||
low_cpu_mem_usage=True,
|
||||
use_safetensors=True,
|
||||
)
|
||||
processor = AutoProcessor.from_pretrained(model_id)
|
||||
_asr_pipeline = pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=model,
|
||||
tokenizer=processor.tokenizer,
|
||||
feature_extractor=processor.feature_extractor,
|
||||
torch_dtype=torch.float16 if _get_device() != "cpu" else torch.float32,
|
||||
device=_device_arg(),
|
||||
)
|
||||
logger.info("[ASR] Whisper large-v3-turbo 模型加载完成")
|
||||
return _asr_pipeline
|
||||
|
||||
|
||||
def _save_audio_to_wav(audio_data: bytes, sample_rate: int = 16000) -> str:
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False, mode="wb") as tmp:
|
||||
with wave.open(tmp.name, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(audio_data)
|
||||
return tmp.name
|
||||
|
||||
|
||||
def _tts_sync(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[bytes, int]:
|
||||
tts = _get_tts_pipeline()
|
||||
result = tts(text, voice=voice)
|
||||
audio = None
|
||||
sample_rate = 24000
|
||||
if isinstance(result, dict):
|
||||
audio = result.get("audio")
|
||||
sample_rate = int(result.get("sampling_rate", sample_rate))
|
||||
elif isinstance(result, (list, tuple)) and result:
|
||||
audio = result[0]
|
||||
|
||||
if audio is None:
|
||||
raise RuntimeError("Kokoro 未返回音频数据")
|
||||
|
||||
if hasattr(audio, "cpu"):
|
||||
audio = audio.cpu().numpy()
|
||||
|
||||
duration_ms = int(len(audio) * 1000 / sample_rate)
|
||||
|
||||
if audio.dtype != np.int16:
|
||||
audio = (audio * 32767).astype(np.int16)
|
||||
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
output_path = tmp.name
|
||||
try:
|
||||
with wave.open(output_path, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(audio.tobytes())
|
||||
with open(output_path, "rb") as f:
|
||||
return f.read(), duration_ms
|
||||
finally:
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
|
||||
|
||||
async def _text_to_speech(text: str, voice: str = "af_bella", rate: float = 1.0) -> tuple[bytes, int]:
|
||||
return await asyncio.to_thread(_tts_sync, text, voice, rate)
|
||||
|
||||
|
||||
def _asr_sync(audio_data: bytes, language: str = "zh") -> str:
|
||||
import soundfile as sf
|
||||
|
||||
asr = _get_asr_pipeline()
|
||||
audio_path = _save_audio_to_wav(audio_data)
|
||||
try:
|
||||
audio_array, sample_rate = sf.read(audio_path)
|
||||
result = asr(
|
||||
audio_array,
|
||||
sampling_rate=sample_rate,
|
||||
generate_kwargs={"language": language, "task": "transcribe"},
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
return result.get("text", "").strip()
|
||||
return str(result).strip()
|
||||
finally:
|
||||
if os.path.exists(audio_path):
|
||||
os.unlink(audio_path)
|
||||
|
||||
|
||||
async def _speech_to_text(audio_data: bytes, language: str = "zh") -> str:
|
||||
return await asyncio.to_thread(_asr_sync, audio_data, language)
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
voice: str = "af_bella"
|
||||
rate: float = 1.0
|
||||
format: str = "wav"
|
||||
|
||||
|
||||
class TTSResponse(BaseModel):
|
||||
audio_base64: str
|
||||
format: str
|
||||
duration_ms: int
|
||||
|
||||
|
||||
class ASRRequest(BaseModel):
|
||||
audio_base64: str
|
||||
language: str = "zh-CN"
|
||||
|
||||
|
||||
class ASRResponse(BaseModel):
|
||||
text: str
|
||||
language: str
|
||||
|
||||
|
||||
def get_api_key(api_key: str):
|
||||
import main
|
||||
|
||||
API_KEY = main.API_KEY
|
||||
if api_key != API_KEY:
|
||||
raise HTTPException(status_code=403, detail="API Key 无效")
|
||||
return api_key
|
||||
|
||||
|
||||
@router.post("/tts", response_model=TTSResponse)
|
||||
async def text_to_speech(req: TTSRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = str(hash(req.text))[:8]
|
||||
try:
|
||||
logger.info("[TTS][%s] text_chars=%d voice=%s format=%s", request_id, len(req.text), req.voice, req.format)
|
||||
audio_data, duration_ms = await _text_to_speech(req.text, req.voice, req.rate)
|
||||
if req.format.lower() == "mp3":
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_in:
|
||||
tmp_in.write(audio_data)
|
||||
input_path = tmp_in.name
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp_out:
|
||||
output_path = tmp_out.name
|
||||
try:
|
||||
cmd = ["ffmpeg", "-i", input_path, "-acodec", "libmp3lame", "-ab", "128k", output_path]
|
||||
result = await asyncio.to_thread(lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=30))
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"MP3 转换失败: {result.stderr}")
|
||||
with open(output_path, "rb") as f:
|
||||
audio_data = f.read()
|
||||
finally:
|
||||
for path in [input_path, output_path]:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
logger.info("[TTS][%s] success duration_ms=%d", request_id, duration_ms)
|
||||
return TTSResponse(audio_base64=base64.b64encode(audio_data).decode(), format=req.format, duration_ms=duration_ms)
|
||||
except Exception as e:
|
||||
logger.exception("[TTS] failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/asr", response_model=ASRResponse)
|
||||
async def speech_to_text(req: ASRRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = str(hash(req.audio_base64))[:8]
|
||||
try:
|
||||
logger.info("[ASR][%s] audio_base64_chars=%d language=%s", request_id, len(req.audio_base64), req.language)
|
||||
audio_data = base64.b64decode(req.audio_base64)
|
||||
text = await _speech_to_text(audio_data, req.language[:2])
|
||||
logger.info("[ASR][%s] success text_chars=%d", request_id, len(text))
|
||||
return ASRResponse(text=text, language=req.language)
|
||||
except Exception as e:
|
||||
logger.exception("[ASR] failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def register_tts_asr_routes(app):
|
||||
app.include_router(router, prefix="/v1/tts-asr")
|
||||
Generated
+2570
-872
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -6,16 +6,22 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "echo 'No tests configured yet'",
|
||||
"check": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/xl-docx-exporter": "^0.47.3",
|
||||
"@milkdown/core": "^7.18.0",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
"@milkdown/theme-nord": "^7.18.0",
|
||||
"@milkdown/vue": "^7.18.0",
|
||||
"docx": "^9.6.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"docx2pdf-converter": "^2.1.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"katex": "^0.16.9",
|
||||
"markdown-it": "^13.0.0",
|
||||
"markdown-it-math": "^3.0.2",
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<section class="doc-card" :class="{ 'is-collapsed': collapsedState }">
|
||||
<header class="doc-card__header">
|
||||
<div class="doc-card__badge">{{ typeLabel }}</div>
|
||||
<div class="doc-card__meta">
|
||||
<div class="doc-card__name">{{ docName }}</div>
|
||||
<div class="doc-card__time">{{ displayTime }}</div>
|
||||
</div>
|
||||
<div class="doc-card__actions">
|
||||
<button type="button" class="doc-card__btn" :title="collapsedState ? '展开文件' : '折叠文件'" @click="toggleCollapse">
|
||||
<svg v-if="collapsedState" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="doc-card__btn doc-card__btn--danger" title="删除文件" @click="props.onDelete?.()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M8 6V4h8v2"/>
|
||||
<path d="M19 6l-1 14H6L5 6"/>
|
||||
<path d="M10 11v6"/>
|
||||
<path d="M14 11v6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div v-show="!collapsedState" class="doc-card__body">
|
||||
<div ref="editorRoot" class="doc-card__editor"></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { replaceAll } from '@milkdown/kit/utils'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
|
||||
const props = defineProps({
|
||||
docType: { type: String, default: 'txt' },
|
||||
docName: { type: String, default: 'document.txt' },
|
||||
uploadTime: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
resolveSuggestionRequest: { type: Function, default: null },
|
||||
onUpdateContent: { type: Function, default: null },
|
||||
onUpdateCollapsed: { type: Function, default: null },
|
||||
onDelete: { type: Function, default: null },
|
||||
})
|
||||
|
||||
const editorRoot = ref(null)
|
||||
const collapsedState = ref(Boolean(props.collapsed))
|
||||
const currentContent = ref(props.content || '')
|
||||
let crepe = null
|
||||
let syncTimer = null
|
||||
let syncingExternal = false
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
if (props.docType === 'docx') return 'DOCX'
|
||||
if (props.docType === 'pptx') return 'PPTX'
|
||||
if (props.docType === 'pdf') return 'PDF'
|
||||
return 'TXT'
|
||||
})
|
||||
|
||||
const displayTime = computed(() => {
|
||||
if (!props.uploadTime) return '刚上传'
|
||||
const date = new Date(props.uploadTime)
|
||||
if (Number.isNaN(date.getTime())) return '刚上传'
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
})
|
||||
|
||||
const toggleCollapse = () => {
|
||||
collapsedState.value = !collapsedState.value
|
||||
props.onUpdateCollapsed?.(collapsedState.value)
|
||||
}
|
||||
|
||||
const syncContent = () => {
|
||||
if (!crepe) return
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
syncTimer = setTimeout(async () => {
|
||||
if (!crepe || syncingExternal) return
|
||||
const markdown = await crepe.getMarkdown()
|
||||
currentContent.value = markdown
|
||||
props.onUpdateContent?.(markdown)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const syncExternalContent = async (nextValue) => {
|
||||
const value = nextValue || ''
|
||||
if (!crepe) {
|
||||
currentContent.value = value
|
||||
return
|
||||
}
|
||||
if (value === currentContent.value) return
|
||||
syncingExternal = true
|
||||
try {
|
||||
crepe.editor.action(replaceAll(value))
|
||||
currentContent.value = value
|
||||
} finally {
|
||||
syncingExternal = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.content, (nextValue) => {
|
||||
void syncExternalContent(nextValue)
|
||||
})
|
||||
|
||||
watch(() => props.collapsed, (nextValue) => {
|
||||
collapsedState.value = Boolean(nextValue)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!editorRoot.value) return
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
defaultValue: props.content || '',
|
||||
features: {
|
||||
[Crepe.Feature.Latex]: true,
|
||||
[Crepe.Feature.ImageBlock]: true,
|
||||
[Crepe.Feature.Table]: true,
|
||||
[Crepe.Feature.ListCheck]: true,
|
||||
},
|
||||
config: {
|
||||
showLineNumber: false,
|
||||
},
|
||||
})
|
||||
|
||||
crepe.editor.config((ctx) => {
|
||||
ctx.set(copilotConfigCtx.key, {
|
||||
fetchSuggestion: async (prefix, suffix, languageId, signal) => {
|
||||
const payload = props.resolveSuggestionRequest
|
||||
? await props.resolveSuggestionRequest({ prefix, suffix, languageId })
|
||||
: { prefix, suffix, languageId, blocked: false }
|
||||
if (payload?.blocked) return ''
|
||||
return fetchSuggestion(payload?.prefix ?? prefix, payload?.suffix ?? suffix, payload?.languageId ?? languageId, signal)
|
||||
},
|
||||
debounceMs: 900,
|
||||
})
|
||||
})
|
||||
|
||||
crepe.editor.use(copilotConfigCtx)
|
||||
crepe.editor.use(copilotGhostMark)
|
||||
crepe.editor.use(copilotPlugin)
|
||||
await crepe.create()
|
||||
|
||||
crepe.on((listener) => {
|
||||
listener.updated(() => {
|
||||
syncContent()
|
||||
})
|
||||
})
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
setCopilotEnabled(view, true)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (syncTimer) {
|
||||
clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
}
|
||||
if (crepe) {
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearGhostSuggestion(view)
|
||||
})
|
||||
crepe.destroy()
|
||||
crepe = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 8px 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.12);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.doc-card__header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.doc-card__badge {
|
||||
min-width: 48px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.doc-card__meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-card__name {
|
||||
color: #1e293b;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.doc-card__time {
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.doc-card__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.doc-card__btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.doc-card__btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.doc-card__btn--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.doc-card__body {
|
||||
padding: 8px 10px;
|
||||
background: rgba(248, 250, 252, 0.5);
|
||||
}
|
||||
|
||||
.doc-card__editor {
|
||||
min-height: 48px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.08);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.milkdown) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.milkdown__main),
|
||||
.doc-card__editor :deep(.milkdown__editor) {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.ProseMirror) {
|
||||
min-height: 80px;
|
||||
padding: 10px 12px 12px !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.ProseMirror > *:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.ProseMirror p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.doc-card__editor :deep(.milkdown__toolbar),
|
||||
.doc-card__editor :deep(.milkdown__menu),
|
||||
.doc-card__editor :deep(.milkdown__statusbar),
|
||||
.doc-card__editor :deep(.milkdown-slate-toolbar),
|
||||
.doc-card__editor :deep(.milkdown-bubble-menu) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<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.72);
|
||||
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);
|
||||
}
|
||||
|
||||
.doc-block.collapsed .doc-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-bottom: 1px solid rgba(59, 130, 246, 0.12);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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.6);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div ref="root" class="milkdown-editor"></div>
|
||||
|
||||
@@ -35,8 +35,9 @@
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:class="{ 'force-disabled': isDocUploadDisabled }"
|
||||
:aria-label="t('uploadFile')"
|
||||
:title="t('uploadFile')"
|
||||
:title="docUploadButtonTitle"
|
||||
@click="triggerFileUpload"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -45,7 +46,7 @@
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ t('uploadFile') }}</span>
|
||||
</button>
|
||||
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept="image/*,.doc,.docx,.ppt,.pptx,.pdf,.zip,.txt,.json" style="display:none">
|
||||
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept=".txt,.json,.toml,.yaml,.yml,.docx,.pptx,.pdf,text/plain,application/json,text/yaml,text/x-yaml,application/x-yaml,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation" multiple style="display:none">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -63,21 +64,29 @@
|
||||
</button>
|
||||
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md,text/markdown,text/x-markdown" style="display:none">
|
||||
|
||||
<div class="export-btn-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:aria-label="t('exportMd')"
|
||||
:title="t('exportMd')"
|
||||
@click="exportMarkdown"
|
||||
@click="toggleExportDropdown"
|
||||
@contextmenu.prevent="toggleExportDropdown"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
<path d="m19 9-4 4-4-4"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ t('exportMd') }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="showExportDropdown" class="export-dropdown">
|
||||
<button type="button" @click="() => { exportMarkdown(); showExportDropdown = false; }">{{ t('exportMd') }}</button>
|
||||
<button type="button" @click="() => { exportDocx(); showExportDropdown = false; }">{{ t('exportDocx') }}</button>
|
||||
<button type="button" @click="() => { exportPdf(); showExportDropdown = false; }">{{ t('exportPdf') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-btn-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
@@ -123,8 +132,33 @@
|
||||
<span class="btn-tooltip">{{ aiButtonLabel }}</span>
|
||||
</button>
|
||||
|
||||
<div class="size-indicator" :class="{ 'over-limit': isOverLimit }" aria-live="polite">
|
||||
<div
|
||||
class="size-indicator"
|
||||
:class="{ 'over-limit': isOverLimit }"
|
||||
@mouseenter="showSizeTooltip = true"
|
||||
@mouseleave="showSizeTooltip = false"
|
||||
>
|
||||
<svg
|
||||
class="warning-icon"
|
||||
:class="{ 'warning-icon--visible': isOverLimit }"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
{{ sizeInKB }} KB
|
||||
<Transition name="tooltip-fade">
|
||||
<div v-if="showSizeTooltip && isOverLimit" class="size-tooltip">
|
||||
<strong>文档超过32KB限制</strong>
|
||||
<span>AI补全功能已暂停,建议精简内容或分段处理</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,6 +178,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadProgress" class="upload-progress-overlay">
|
||||
<div class="upload-progress-dialog">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ t('uploading') || '正在上传文件' }}</p>
|
||||
<p class="progress-text">
|
||||
{{ uploadProgress.current }} / {{ uploadProgress.total }}
|
||||
</p>
|
||||
<p class="filename">{{ uploadProgress.filename }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -154,12 +199,14 @@ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { Selection } from '@milkdown/prose/state'
|
||||
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { docBlockNode, docBlockRemark, docBlockView } from '../plugins/docBlockPlugin'
|
||||
import { mermaidRenderPreview, codeBlockConfig } from '../plugins/mermaidPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { OCR_URL } from '../utils/config.js'
|
||||
import { OCR_URL, EXPORT_PDF_URL } from '../utils/config.js'
|
||||
import { convertFileToMarkdown } from '../utils/convert.js'
|
||||
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
|
||||
import { DOC_BLOCK_NODE_TYPE, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js'
|
||||
|
||||
const emit = defineEmits(['update:markdown'])
|
||||
const settings = useSettingsStore()
|
||||
@@ -174,15 +221,20 @@ const cameraInputRef = ref(null)
|
||||
const aiEnabled = ref(true)
|
||||
const contentSize = ref(0)
|
||||
const showImageDropdown = ref(false)
|
||||
const showExportDropdown = ref(false)
|
||||
const showUrlDialog = ref(false)
|
||||
const showSizeTooltip = ref(false)
|
||||
const imageUrl = ref('')
|
||||
const canUndo = ref(false)
|
||||
const canRedo = ref(false)
|
||||
const isDocUploadDisabled = ref(false)
|
||||
const uploadProgress = ref(null)
|
||||
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
|
||||
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
|
||||
const undoLabel = computed(() => t('undo') || 'Undo')
|
||||
const redoLabel = computed(() => t('redo') || 'Redo')
|
||||
const cameraUploadLabel = computed(() => t('cameraUpload') || 'Use Camera')
|
||||
const API_KEY = 'your-secret-key-here'
|
||||
const supportsCameraCapture = computed(() => {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
const ua = navigator.userAgent || ''
|
||||
@@ -192,47 +244,111 @@ const aiButtonLabel = computed(() => {
|
||||
if (isOverLimit.value) return t('docTooLarge')
|
||||
return aiEnabled.value ? t('disableAI') : t('enableAI')
|
||||
})
|
||||
const docUploadButtonTitle = computed(() => {
|
||||
if (isDocUploadDisabled.value) return t('uploadDocInBlockWarning') || '当前光标位置不能插入文件'
|
||||
return t('uploadFile')
|
||||
})
|
||||
|
||||
let crepe = null
|
||||
let markdownSyncTimer = null
|
||||
let rootResizeObserver = null
|
||||
let editorCopyHandler = null
|
||||
const objectUrls = new Set()
|
||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
||||
const MARKDOWN_EXT_RE = /\.md$/i
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
|
||||
const CONVERT_EXT_RE = /\.(docx?|pptx?|pdf|zip)$/i
|
||||
const TEXT_EXT_RE = /\.(txt|json)$/i
|
||||
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json'])
|
||||
const CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i
|
||||
const TEXT_EXT_RE = /\.(txt|json|toml|ya?ml)$/i
|
||||
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json', 'text/yaml', 'text/x-yaml', 'application/x-yaml'])
|
||||
const CONVERT_MIME_TYPES = new Set([
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/pdf',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
])
|
||||
let lastInitialMarkdown = initialMarkdown.value
|
||||
let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)
|
||||
|
||||
const normalizeTrailingWhitespace = (value) => (value || '').replace(/\s+$/, '')
|
||||
|
||||
const padTimePart = (value) => String(value).padStart(2, '0')
|
||||
|
||||
const createExportName = () => {
|
||||
const now = new Date()
|
||||
const datePart = `${now.getFullYear()}${padTimePart(now.getMonth() + 1)}${padTimePart(now.getDate())}`
|
||||
const timePart = `${padTimePart(now.getHours())}${padTimePart(now.getMinutes())}${padTimePart(now.getSeconds())}`
|
||||
return `save${datePart}${timePart}`
|
||||
}
|
||||
|
||||
const buildDocxBlob = async (markdown) => {
|
||||
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
|
||||
const children = []
|
||||
|
||||
for (const line of markdown.split('\n')) {
|
||||
if (line.startsWith('# ')) {
|
||||
children.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
children.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('### ')) {
|
||||
children.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('---')) {
|
||||
children.push(new Paragraph({ text: '----------' }))
|
||||
continue
|
||||
}
|
||||
children.push(line.trim() === '' ? new Paragraph({}) : new Paragraph({ text: line }))
|
||||
}
|
||||
|
||||
return Packer.toBlob(new Document({ sections: [{ properties: {}, children }] }))
|
||||
}
|
||||
|
||||
const downloadBlob = (blob, filename) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
const getExportMarkdown = async () => {
|
||||
if (!crepe) {
|
||||
throw new Error('编辑器未初始化,请稍后重试')
|
||||
}
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearCurrentSuggestion(view)
|
||||
})
|
||||
|
||||
const markdown = await crepe.getMarkdown()
|
||||
return transformLegacyDocBlocksForExport(markdown)
|
||||
}
|
||||
|
||||
const syncInitialMarkdown = async (nextValue) => {
|
||||
if (!crepe) {
|
||||
lastInitialMarkdown = nextValue
|
||||
lastInitialMarkdown = transformSpecialDocBlocksToLegacy(nextValue)
|
||||
return
|
||||
}
|
||||
const normalizedNextValue = transformSpecialDocBlocksToLegacy(nextValue)
|
||||
|
||||
try {
|
||||
const current = await crepe.getMarkdown()
|
||||
const normalizedCurrent = normalizeTrailingWhitespace(current)
|
||||
const normalizedLast = normalizeTrailingWhitespace(lastInitialMarkdown)
|
||||
if (!normalizedCurrent || normalizedCurrent === normalizedLast) {
|
||||
crepe.editor.action(replaceAll(nextValue))
|
||||
crepe.editor.action(replaceAll(normalizedNextValue))
|
||||
}
|
||||
} catch {
|
||||
// Ignore sync errors
|
||||
} finally {
|
||||
lastInitialMarkdown = nextValue
|
||||
lastInitialMarkdown = normalizedNextValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +454,58 @@ const updateHistoryState = (view) => {
|
||||
canRedo.value = redoDepth(view.state) > 0
|
||||
}
|
||||
|
||||
const serializeSelectionToMarkdown = (view, from, to) => {
|
||||
const state = view.state
|
||||
const slice = state.doc.slice(from, to)
|
||||
const doc = state.schema.topNodeType.createAndFill(undefined, slice.content)
|
||||
if (!doc) return state.doc.textBetween(from, to, '\n', '\n')
|
||||
return crepe?.editor?.action((ctx) => {
|
||||
const serializer = ctx.get(serializerCtx)
|
||||
return serializer(doc)
|
||||
}) || state.doc.textBetween(from, to, '\n', '\n')
|
||||
}
|
||||
|
||||
const selectionIncludesDocBlock = (state) => {
|
||||
const { from, to } = state.selection
|
||||
let hasDocBlock = false
|
||||
state.doc.nodesBetween(from, to, (node) => {
|
||||
if (node.type?.name === DOC_BLOCK_NODE_TYPE) {
|
||||
hasDocBlock = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return hasDocBlock
|
||||
}
|
||||
|
||||
const getCursorContext = (view) => {
|
||||
const { $from } = view.state.selection
|
||||
let inDocBlock = false
|
||||
let fenceLanguage = ''
|
||||
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||
const node = $from.node(depth)
|
||||
const typeName = node.type?.name || ''
|
||||
if (typeName === DOC_BLOCK_NODE_TYPE) {
|
||||
inDocBlock = true
|
||||
break
|
||||
}
|
||||
if (typeName === 'code_block' || typeName === 'codeBlock' || typeName === 'code_fence' || typeName === 'fence') {
|
||||
fenceLanguage = String(node.attrs?.language || node.attrs?.lang || node.attrs?.info || '').trim().toLowerCase()
|
||||
break
|
||||
}
|
||||
}
|
||||
const disabledByFence = fenceLanguage === 'mermaid' || fenceLanguage === 'tex' || fenceLanguage === 'latex' || fenceLanguage === 'katex'
|
||||
return {
|
||||
disabled: inDocBlock || disabledByFence,
|
||||
inDocBlock,
|
||||
fenceLanguage,
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDocUploadState = (view) => {
|
||||
isDocUploadDisabled.value = getCursorContext(view).disabled
|
||||
}
|
||||
|
||||
const runHistoryCommand = (command) => {
|
||||
if (!crepe) return
|
||||
crepe.editor.action((ctx) => {
|
||||
@@ -480,6 +648,15 @@ const prepareImageFile = async (file) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.export-btn-wrapper')) {
|
||||
showExportDropdown.value = false
|
||||
}
|
||||
if (!e.target.closest('.image-btn-wrapper')) {
|
||||
showImageDropdown.value = false
|
||||
}
|
||||
})
|
||||
|
||||
if (!root.value) throw new Error('root.value is null')
|
||||
updateEditorTailSpace()
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
@@ -491,7 +668,7 @@ onMounted(async () => {
|
||||
|
||||
crepe = new Crepe({
|
||||
root: root.value,
|
||||
defaultValue: initialMarkdown.value || '',
|
||||
defaultValue: transformSpecialDocBlocksToLegacy(initialMarkdown.value || ''),
|
||||
features: {
|
||||
[Crepe.Feature.Latex]: true,
|
||||
[Crepe.Feature.ImageBlock]: true,
|
||||
@@ -547,6 +724,9 @@ onMounted(async () => {
|
||||
crepe.editor.use(copilotConfigCtx)
|
||||
crepe.editor.use(copilotGhostMark)
|
||||
crepe.editor.use(copilotPlugin)
|
||||
crepe.editor.use(docBlockRemark)
|
||||
crepe.editor.use(docBlockNode)
|
||||
crepe.editor.use(docBlockView)
|
||||
|
||||
|
||||
await crepe.create()
|
||||
@@ -557,6 +737,7 @@ onMounted(async () => {
|
||||
syncObjectUrls(doc)
|
||||
refreshSizeAndLimit(ctx)
|
||||
updateHistoryState(view)
|
||||
refreshDocUploadState(view)
|
||||
scheduleMarkdownSync()
|
||||
})
|
||||
})
|
||||
@@ -566,32 +747,79 @@ onMounted(async () => {
|
||||
setCopilotEnabled(view, aiEnabled.value)
|
||||
refreshSizeAndLimit(ctx)
|
||||
updateHistoryState(view)
|
||||
refreshDocUploadState(view)
|
||||
const editorDom = view.dom
|
||||
editorCopyHandler = (event) => {
|
||||
const state = view.state
|
||||
if (!selectionIncludesDocBlock(state)) return
|
||||
const { from, to } = state.selection
|
||||
const rawMarkdown = serializeSelectionToMarkdown(view, from, to)
|
||||
const clipboardMarkdown = transformDocBlockMarkdownForClipboard(rawMarkdown || '')
|
||||
if (!clipboardMarkdown) return
|
||||
event.preventDefault()
|
||||
event.clipboardData?.setData('text/plain', clipboardMarkdown)
|
||||
}
|
||||
editorDom.addEventListener('copy', editorCopyHandler)
|
||||
})
|
||||
scheduleMarkdownSync()
|
||||
})
|
||||
|
||||
const exportMarkdown = async () => {
|
||||
if (!crepe) return
|
||||
try {
|
||||
const markdown = await getExportMarkdown()
|
||||
const exportName = createExportName()
|
||||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
downloadBlob(blob, `${exportName}.md`)
|
||||
} catch (error) {
|
||||
console.error('Markdown export failed:', error)
|
||||
alert(`Markdown 导出失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearCurrentSuggestion(view)
|
||||
const exportDocx = async () => {
|
||||
try {
|
||||
console.log('Exporting DOCX...')
|
||||
const markdown = await getExportMarkdown()
|
||||
const blob = await buildDocxBlob(markdown)
|
||||
const exportName = createExportName()
|
||||
downloadBlob(blob, `${exportName}.docx`)
|
||||
console.log('DOCX export completed')
|
||||
} catch (error) {
|
||||
console.error('DOCX export failed:', error)
|
||||
alert(`DOCX导出失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const exportPdf = async () => {
|
||||
try {
|
||||
console.log('Exporting PDF via DOCX...')
|
||||
const markdown = await getExportMarkdown()
|
||||
const docxBlob = await buildDocxBlob(markdown)
|
||||
const exportName = createExportName()
|
||||
const formData = new FormData()
|
||||
formData.append('file', docxBlob, `${exportName}.docx`)
|
||||
|
||||
const res = await fetch(EXPORT_PDF_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': API_KEY,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const markdown = await crepe.getMarkdown()
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
const now = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
||||
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
a.href = url
|
||||
a.download = `save${datePart}${timePart}.md`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const pdfBlob = await res.blob()
|
||||
downloadBlob(pdfBlob, `${exportName}.pdf`)
|
||||
console.log('PDF export completed successfully')
|
||||
alert('PDF导出成功!')
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error)
|
||||
alert(`PDF导出失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerUpload = () => {
|
||||
@@ -622,7 +850,7 @@ const handleFileUpload = async (event) => {
|
||||
try {
|
||||
const text = await file.text()
|
||||
if (crepe && crepe.editor) {
|
||||
crepe.editor.action(replaceAll(text))
|
||||
crepe.editor.action(replaceAll(transformSpecialDocBlocksToLegacy(text)))
|
||||
}
|
||||
} catch {
|
||||
// File upload error, ignore
|
||||
@@ -647,6 +875,12 @@ const toggleAI = async () => {
|
||||
|
||||
const toggleImageDropdown = () => {
|
||||
showImageDropdown.value = !showImageDropdown.value
|
||||
showExportDropdown.value = false
|
||||
}
|
||||
|
||||
const toggleExportDropdown = () => {
|
||||
showExportDropdown.value = !showExportDropdown.value
|
||||
showImageDropdown.value = false
|
||||
}
|
||||
|
||||
const triggerImageUpload = () => {
|
||||
@@ -689,60 +923,186 @@ const insertMarkdownAtCursor = (markdown) => {
|
||||
})
|
||||
}
|
||||
|
||||
const buildCodeBlock = (file, text) => {
|
||||
const name = (file?.name || '').toLowerCase()
|
||||
const lang = name.endsWith('.json') ? 'json' : 'text'
|
||||
return `\n\`\`\`${lang}\n${text}\n\`\`\`\n`
|
||||
const insertDocBlockAtCursor = (attrs) => {
|
||||
if (!crepe) return
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const { state } = view
|
||||
const { from, to } = state.selection
|
||||
const docBlockType = state.schema.nodes[DOC_BLOCK_NODE_TYPE]
|
||||
if (!docBlockType) return
|
||||
|
||||
const blockNode = docBlockType.create({
|
||||
docType: attrs.docType,
|
||||
docName: attrs.docName,
|
||||
uploadTime: attrs.uploadTime,
|
||||
content: attrs.content,
|
||||
collapsed: Boolean(attrs.collapsed),
|
||||
})
|
||||
const tr = state.tr.replaceRangeWith(from, to, blockNode)
|
||||
const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
|
||||
view.dispatch(tr.scrollIntoView())
|
||||
view.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const insertEmptyParagraph = () => {
|
||||
if (!crepe) return
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const { state } = view
|
||||
const { from, to } = state.selection
|
||||
const tr = state.tr.insertText('\n\n', from, to)
|
||||
const nextPos = from + 2
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
|
||||
view.dispatch(tr)
|
||||
})
|
||||
}
|
||||
|
||||
const insertMultipleDocBlocks = (blocks) => {
|
||||
if (!crepe || blocks.length === 0) return
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
let tr = view.state.tr
|
||||
const docBlockType = view.state.schema.nodes[DOC_BLOCK_NODE_TYPE]
|
||||
if (!docBlockType) return
|
||||
|
||||
let currentPos = tr.selection.from
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
const maxPos = tr.doc.content.size
|
||||
|
||||
if (index > 0) {
|
||||
const insertPos = Math.min(currentPos, maxPos)
|
||||
tr = tr.insertText('\n', insertPos, insertPos)
|
||||
currentPos = insertPos + 1
|
||||
}
|
||||
|
||||
const blockNode = docBlockType.create({
|
||||
docType: block.docType,
|
||||
docName: block.docName,
|
||||
uploadTime: block.uploadTime,
|
||||
content: block.content,
|
||||
collapsed: Boolean(block.collapsed),
|
||||
})
|
||||
|
||||
const insertBlockPos = Math.min(currentPos, tr.doc.content.size)
|
||||
tr = tr.replaceRangeWith(insertBlockPos, insertBlockPos, blockNode)
|
||||
currentPos = insertBlockPos + blockNode.nodeSize
|
||||
})
|
||||
|
||||
const finalPos = Math.min(currentPos, tr.doc.content.size)
|
||||
if (finalPos >= 0 && finalPos <= tr.doc.content.size) {
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(finalPos), 1))
|
||||
}
|
||||
view.dispatch(tr.scrollIntoView())
|
||||
view.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
if (isDocUploadDisabled.value) return
|
||||
uploadFileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleUploadFile = async (event) => {
|
||||
const input = event.target
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const files = Array.from(input.files || [])
|
||||
if (files.length === 0) return
|
||||
|
||||
const convertible = isConvertibleFile(file)
|
||||
try {
|
||||
if (isImageFile(file)) {
|
||||
const objectUrl = await prepareImageFile(file)
|
||||
if (objectUrl) {
|
||||
clearCurrentGhost()
|
||||
insertImageAtCursor(objectUrl)
|
||||
}
|
||||
const BATCH_LIMIT = 10
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
|
||||
if (files.length > BATCH_LIMIT) {
|
||||
alert(t('uploadBatchLimit') || `一次最多上传${BATCH_LIMIT}个文件`)
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(t('uploadSizeLimit') || `${file.name} 超过${MAX_FILE_SIZE / 1024 / 1024}MB限制`)
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!isSupportedDocFile(file)) {
|
||||
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isDocUploadDisabled.value || !crepe) {
|
||||
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const total = files.length
|
||||
uploadProgress.value = { current: 0, total, filename: '' }
|
||||
|
||||
const results = []
|
||||
const errors = []
|
||||
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index]
|
||||
uploadProgress.value = { current: index + 1, total, filename: file.name }
|
||||
|
||||
try {
|
||||
const docType = getDocTypeFromFilename(file.name)
|
||||
let content = ''
|
||||
|
||||
if (isTextFile(file)) {
|
||||
const text = await file.text()
|
||||
clearCurrentGhost()
|
||||
insertMarkdownAtCursor(buildCodeBlock(file, text))
|
||||
return
|
||||
content = await file.text()
|
||||
} else if (isConvertibleFile(file)) {
|
||||
content = await convertFileToMarkdown(file)
|
||||
} else {
|
||||
throw new Error('不支持的文件类型')
|
||||
}
|
||||
|
||||
if (convertible) {
|
||||
const markdown = await convertFileToMarkdown(file)
|
||||
if (!markdown) {
|
||||
throw new Error('No markdown returned')
|
||||
}
|
||||
clearCurrentGhost()
|
||||
insertMarkdownAtCursor(markdown)
|
||||
return
|
||||
if (!content) {
|
||||
throw new Error('文档解析结果为空')
|
||||
}
|
||||
|
||||
warnUnsupportedInsertType()
|
||||
results.push({
|
||||
docType,
|
||||
docName: file.name || `document.${docType}`,
|
||||
content,
|
||||
index,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
if (convertible) {
|
||||
warnConvertError(message)
|
||||
} else {
|
||||
warnUploadError(message)
|
||||
errors.push({ filename: file.name, message })
|
||||
}
|
||||
} finally {
|
||||
}
|
||||
|
||||
uploadProgress.value = null
|
||||
clearCurrentGhost()
|
||||
|
||||
results.sort((a, b) => a.index - b.index)
|
||||
|
||||
const blocksToInsert = results.map(({ docType, docName, content }) => ({
|
||||
docType,
|
||||
docName,
|
||||
content,
|
||||
uploadTime: new Date().toISOString(),
|
||||
collapsed: false,
|
||||
}))
|
||||
|
||||
insertMultipleDocBlocks(blocksToInsert)
|
||||
|
||||
if (errors.length > 0) {
|
||||
const failCount = errors.length
|
||||
const errorMsgs = errors.map(e => `${e.filename}: ${e.message}`).join('\n')
|
||||
alert(`上传失败 ${failCount} 个文件:\n\n${errorMsgs}`)
|
||||
}
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageUpload = async (event) => {
|
||||
@@ -788,6 +1148,12 @@ onUnmounted(() => {
|
||||
|
||||
clearAllOcrCache()
|
||||
if (crepe) {
|
||||
if (editorCopyHandler) {
|
||||
crepe.editor.action((ctx) => {
|
||||
ctx.get(editorViewCtx).dom.removeEventListener('copy', editorCopyHandler)
|
||||
})
|
||||
editorCopyHandler = null
|
||||
}
|
||||
crepe.destroy()
|
||||
crepe = null
|
||||
}
|
||||
@@ -799,7 +1165,6 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-buttons {
|
||||
@@ -850,7 +1215,8 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 9999;
|
||||
z-index: 99999;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -904,14 +1270,81 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.size-indicator {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--muted-text);
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.size-indicator.over-limit {
|
||||
color: var(--danger-text);
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
animation: pulse-warning 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.warning-icon--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.75;
|
||||
background: rgba(220, 38, 38, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.size-tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--tooltip-bg);
|
||||
color: var(--tooltip-fg);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--panel-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.size-tooltip strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.size-tooltip span {
|
||||
opacity: 0.85;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tooltip-fade-enter-active,
|
||||
.tooltip-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.tooltip-fade-enter-from,
|
||||
.tooltip-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -977,6 +1410,40 @@ onUnmounted(() => {
|
||||
background: var(--crepe-color-hover);
|
||||
}
|
||||
|
||||
.export-btn-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.export-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--panel-shadow);
|
||||
overflow: hidden;
|
||||
z-index: 10000;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.export-dropdown button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.export-dropdown button:hover {
|
||||
background: var(--crepe-color-hover);
|
||||
}
|
||||
|
||||
.url-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -1196,5 +1663,55 @@ onUnmounted(() => {
|
||||
.copilot-ghost-block code {
|
||||
background-color: var(--ghost-code-bg);
|
||||
}
|
||||
|
||||
.upload-progress-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.upload-progress-dialog {
|
||||
background: var(--editor-bg, white);
|
||||
padding: 24px 32px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 auto 16px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import packageJson from '../../package.json'
|
||||
|
||||
const store = useSettingsStore()
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
const VERSION = packageJson.version || '0.0.0'
|
||||
|
||||
const isOpen = ref(false)
|
||||
let systemThemeMediaQuery = null
|
||||
|
||||
@@ -272,7 +275,7 @@ const t = (key) => store.t[key]
|
||||
<div class="about-card">
|
||||
<h4>llm-in-text</h4>
|
||||
<p>A smart Markdown editor with local LLM intelligence.</p>
|
||||
<p class="version">v0.1.0-beta</p>
|
||||
<p class="version">v{{ VERSION }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.mount('#app')
|
||||
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// Service worker registration failed, silently ignore
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCac
|
||||
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
|
||||
const DEBOUNCE_MS = 1000
|
||||
const SIZE_LIMIT = OCR_SIZE_LIMIT
|
||||
const DOC_SIZE_LIMIT = 32 * 1024 // 文档块32KB限制
|
||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
||||
|
||||
interface CopilotState {
|
||||
@@ -330,6 +331,31 @@ function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
|
||||
return `\n\n${lines.join('\n')}`
|
||||
}
|
||||
|
||||
// 从markdown中提取文档块内容用于AI补全上下文
|
||||
function extractDocBlocksFromMarkdown(markdown: string): string {
|
||||
const lines: string[] = []
|
||||
|
||||
// 使用正则表达式匹配文档块
|
||||
// <doc_type="pdf" doc_name="xxx" upload_time="xxx">content</doc_end>
|
||||
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>/g
|
||||
|
||||
let match
|
||||
while ((match = docBlockRegex.exec(markdown)) !== null) {
|
||||
const docType = match[1]
|
||||
const docName = match[2]
|
||||
const content = match[4].trim()
|
||||
|
||||
if (content) {
|
||||
// 将文档内容格式化为上下文,限制长度
|
||||
const truncatedContent = content.length > 500 ? content.substring(0, 500) + '...' : content
|
||||
lines.push(`<doc_type="${docType}" doc_name="${docName}">\n${truncatedContent}\n</doc_end>`)
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) return ''
|
||||
return `\n\n-- 已上传文档内容 --\n${lines.join('\n\n')}`
|
||||
}
|
||||
|
||||
function doFetchSuggestion(
|
||||
view: EditorView,
|
||||
runtime: CopilotRuntime,
|
||||
@@ -379,7 +405,6 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
|
||||
|
||||
const doc = view.state.doc
|
||||
const schema = view.state.schema
|
||||
const baseSize = doc.content.size
|
||||
|
||||
const serializer = runtime.ctx.get(serializerCtx)
|
||||
let prefixMarkdown = ''
|
||||
@@ -400,12 +425,21 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
|
||||
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
|
||||
}
|
||||
|
||||
const requestPrefix = `${prefixMarkdown}${buildOcrContextForRequest(doc, pos)}`
|
||||
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
|
||||
const ocrContextLen = requestPrefix.length - prefixMarkdown.length
|
||||
const totalWithOcr = totalTextLen + ocrContextLen
|
||||
// 构建上下文:OCR内容 + 上传文档内容
|
||||
const ocrContext = buildOcrContextForRequest(doc, pos)
|
||||
|
||||
const overLimit = totalWithOcr > SIZE_LIMIT
|
||||
// 从markdown中提取文档块内容用于AI补全上下文
|
||||
const docContext = extractDocBlocksFromMarkdown(prefixMarkdown + suffixMarkdown)
|
||||
|
||||
// 组合所有上下文到prefix前面
|
||||
const fullPrefixWithContext = `${ocrContext}${docContext}\n\n${prefixMarkdown}`
|
||||
|
||||
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
|
||||
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length
|
||||
const totalWithContext = totalTextLen + contextLen
|
||||
|
||||
// 使用32KB限制(文档上下文)
|
||||
const overLimit = totalWithContext > DOC_SIZE_LIMIT
|
||||
|
||||
if (overLimit) {
|
||||
setCopilotEnabled(view, false)
|
||||
@@ -422,9 +456,10 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
|
||||
runtime.requestSeq = requestSeq
|
||||
const requestDocVersion = runtime.docVersion
|
||||
|
||||
// 使用包含文档上下文的prefix
|
||||
runtime.debounceTimer = setTimeout(() => {
|
||||
runtime.debounceTimer = null
|
||||
doFetchSuggestion(view, runtime, pos, requestPrefix, suffixMarkdown, requestSeq, requestDocVersion)
|
||||
doFetchSuggestion(view, runtime, pos, fullPrefixWithContext, suffixMarkdown, requestSeq, requestDocVersion)
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
@@ -708,7 +743,12 @@ export function interruptCopilot(view: EditorView): void {
|
||||
}
|
||||
|
||||
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
|
||||
const size = view.state.doc.content.size
|
||||
let size = view.state.doc.content.size
|
||||
view.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'doc_block' && node.attrs.content) {
|
||||
size += String(node.attrs.content).length
|
||||
}
|
||||
})
|
||||
return { size, overLimit: size > SIZE_LIMIT }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createApp, reactive } from 'vue'
|
||||
import { serializerCtx } from '@milkdown/kit/core'
|
||||
import { $node, $remark, $view } from '@milkdown/kit/utils'
|
||||
import type { Node as ProseNode, Schema } from '@milkdown/prose/model'
|
||||
import type { EditorView, NodeView } from '@milkdown/prose/view'
|
||||
import DocBlockCrepe from '../components/DocBlockCrepe.vue'
|
||||
import {
|
||||
DOC_BLOCK_FENCE_LANG,
|
||||
DOC_BLOCK_NODE_TYPE,
|
||||
DOC_CONTEXT_LIMIT,
|
||||
buildLegacyDocBlock,
|
||||
buildDocContextFence,
|
||||
normalizeDocType,
|
||||
parseLegacyDocBlock,
|
||||
parseDocBlockValue,
|
||||
stripDocBlockMarkdown,
|
||||
} from '../utils/docBlock.js'
|
||||
|
||||
function serializeRangeToMarkdown(
|
||||
doc: ProseNode,
|
||||
from: number,
|
||||
to: number,
|
||||
schema: Schema,
|
||||
serializer: (content: ProseNode) => string
|
||||
): string {
|
||||
if (from >= to) return ''
|
||||
const slice = doc.slice(from, to)
|
||||
if (slice.content.size <= 0) return ''
|
||||
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
|
||||
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, '\n', '\n')
|
||||
}
|
||||
|
||||
function buildDocContext(doc: ProseNode, excludePos?: number) {
|
||||
const blocks: string[] = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== DOC_BLOCK_NODE_TYPE) return true
|
||||
if (excludePos !== undefined && pos === excludePos) return false
|
||||
blocks.push(
|
||||
buildDocContextFence({
|
||||
docType: node.attrs.docType,
|
||||
content: node.attrs.content,
|
||||
})
|
||||
)
|
||||
return false
|
||||
})
|
||||
return blocks.join('\n\n')
|
||||
}
|
||||
|
||||
class DocBlockNodeView implements NodeView {
|
||||
node: ProseNode
|
||||
view: EditorView
|
||||
getPos: () => number | undefined
|
||||
dom: HTMLElement
|
||||
app: ReturnType<typeof createApp> | null = null
|
||||
props: Record<string, any>
|
||||
serializer: (content: ProseNode) => string
|
||||
|
||||
constructor(node: ProseNode, view: EditorView, getPos: () => number | undefined, serializer: (content: ProseNode) => string) {
|
||||
this.node = node
|
||||
this.view = view
|
||||
this.getPos = getPos
|
||||
this.serializer = serializer
|
||||
this.dom = document.createElement('div')
|
||||
this.dom.className = 'doc-block-node-view'
|
||||
this.props = reactive({
|
||||
docType: node.attrs.docType,
|
||||
docName: node.attrs.docName,
|
||||
uploadTime: node.attrs.uploadTime,
|
||||
content: node.attrs.content,
|
||||
collapsed: node.attrs.collapsed,
|
||||
onUpdateContent: (content: string) => this.updateAttrs({ content }),
|
||||
onUpdateCollapsed: (collapsed: boolean) => this.updateAttrs({ collapsed }),
|
||||
onDelete: () => this.deleteNode(),
|
||||
resolveSuggestionRequest: (payload: { prefix: string; suffix: string; languageId: string }) => this.resolveSuggestionRequest(payload),
|
||||
})
|
||||
this.mount()
|
||||
}
|
||||
|
||||
mount() {
|
||||
this.app = createApp(DocBlockCrepe, this.props)
|
||||
this.app.mount(this.dom)
|
||||
}
|
||||
|
||||
getPosValue() {
|
||||
const pos = this.getPos()
|
||||
return typeof pos === 'number' ? pos : undefined
|
||||
}
|
||||
|
||||
updateAttrs(patch: Record<string, any>) {
|
||||
const pos = this.getPosValue()
|
||||
if (pos === undefined) return
|
||||
const nextAttrs = { ...this.node.attrs, ...patch }
|
||||
this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, nextAttrs))
|
||||
}
|
||||
|
||||
deleteNode() {
|
||||
const pos = this.getPosValue()
|
||||
if (pos === undefined) return
|
||||
const tr = this.view.state.tr.delete(pos, pos + this.node.nodeSize).scrollIntoView()
|
||||
this.view.dispatch(tr)
|
||||
this.view.focus()
|
||||
}
|
||||
|
||||
resolveSuggestionRequest(payload: { prefix: string; suffix: string; languageId: string }) {
|
||||
const pos = this.getPosValue()
|
||||
if (pos === undefined) return payload
|
||||
const doc = this.view.state.doc
|
||||
const schema = this.view.state.schema
|
||||
const before = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer))
|
||||
const after = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, pos + this.node.nodeSize, doc.content.size, schema, this.serializer))
|
||||
const docContext = buildDocContext(doc, pos)
|
||||
const mergedPrefix = [docContext, before, payload.prefix].filter(Boolean).join('\n\n')
|
||||
const mergedSuffix = [payload.suffix, after].filter(Boolean).join('\n\n')
|
||||
if (mergedPrefix.length + mergedSuffix.length > DOC_CONTEXT_LIMIT) {
|
||||
return {
|
||||
prefix: mergedPrefix.slice(0, DOC_CONTEXT_LIMIT),
|
||||
suffix: '',
|
||||
languageId: payload.languageId,
|
||||
blocked: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
prefix: mergedPrefix,
|
||||
suffix: mergedSuffix,
|
||||
languageId: payload.languageId,
|
||||
blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
update(node: ProseNode) {
|
||||
if (node.type !== this.node.type) return false
|
||||
this.node = node
|
||||
this.props.docType = node.attrs.docType
|
||||
this.props.docName = node.attrs.docName
|
||||
this.props.uploadTime = node.attrs.uploadTime
|
||||
this.props.content = node.attrs.content
|
||||
this.props.collapsed = node.attrs.collapsed
|
||||
return true
|
||||
}
|
||||
|
||||
stopEvent(event: Event) {
|
||||
const target = event.target as Node | null
|
||||
return Boolean(target && this.dom.contains(target))
|
||||
}
|
||||
|
||||
ignoreMutation() {
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.app?.unmount()
|
||||
this.app = null
|
||||
}
|
||||
}
|
||||
|
||||
function visitChildren(node: any, visitor: (child: any) => any) {
|
||||
if (!node || !Array.isArray(node.children)) return
|
||||
node.children = node.children.map((child: any) => {
|
||||
const next = visitor(child)
|
||||
if (next && next !== child) return next
|
||||
visitChildren(child, visitor)
|
||||
return child
|
||||
})
|
||||
}
|
||||
|
||||
export const docBlockRemark = $remark('docBlockRemark', () => () => {
|
||||
return (tree: any) => {
|
||||
visitChildren(tree, (node) => {
|
||||
if (node?.type === 'code' && node.lang === DOC_BLOCK_FENCE_LANG) {
|
||||
return {
|
||||
type: 'docBlock',
|
||||
value: String(node.value || ''),
|
||||
sourceType: 'code',
|
||||
}
|
||||
}
|
||||
if (node?.type === 'html' && typeof node.value === 'string' && node.value.includes('<doc_type=')) {
|
||||
return {
|
||||
type: 'docBlock',
|
||||
value: String(node.value || ''),
|
||||
sourceType: 'html',
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const docBlockNode = $node(DOC_BLOCK_NODE_TYPE, () => ({
|
||||
group: 'block',
|
||||
atom: true,
|
||||
isolating: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
marks: '',
|
||||
attrs: {
|
||||
docType: { default: 'txt' },
|
||||
docName: { default: 'document.txt' },
|
||||
uploadTime: { default: '' },
|
||||
content: { default: '' },
|
||||
collapsed: { default: false },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'div[data-doc-block="true"]',
|
||||
getAttrs: (dom) => ({
|
||||
docType: normalizeDocType((dom as HTMLElement).getAttribute('data-doc-type') || ''),
|
||||
docName: (dom as HTMLElement).getAttribute('data-doc-name') || 'document.txt',
|
||||
uploadTime: (dom as HTMLElement).getAttribute('data-doc-upload-time') || '',
|
||||
collapsed: ((dom as HTMLElement).getAttribute('data-doc-collapsed') || '') === 'true',
|
||||
content: '',
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'div',
|
||||
{
|
||||
'data-doc-block': 'true',
|
||||
'data-doc-type': node.attrs.docType,
|
||||
'data-doc-name': node.attrs.docName,
|
||||
'data-doc-upload-time': node.attrs.uploadTime,
|
||||
'data-doc-collapsed': String(Boolean(node.attrs.collapsed)),
|
||||
},
|
||||
],
|
||||
parseMarkdown: {
|
||||
match: (node) => node.type === 'docBlock',
|
||||
runner: (state, node, type) => {
|
||||
const attrs = node.sourceType === 'code'
|
||||
? parseDocBlockValue(String(node.value || ''))
|
||||
: parseLegacyDocBlock(String(node.value || ''))
|
||||
if (!attrs) return
|
||||
state.addNode(type, attrs)
|
||||
},
|
||||
},
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === DOC_BLOCK_NODE_TYPE,
|
||||
runner: (state, node) => {
|
||||
state.addNode('html', undefined, buildLegacyDocBlock(node.attrs))
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export const docBlockView = $view(docBlockNode, (ctx) => {
|
||||
const serializer = ctx.get(serializerCtx)
|
||||
return (node, view, getPos) => new DocBlockNodeView(node, view, getPos, serializer)
|
||||
})
|
||||
|
||||
export function buildDocContextFromDoc(doc: ProseNode, excludePos?: number) {
|
||||
return buildDocContext(doc, excludePos)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const debounceMs = ref(1000) // 1000 - 5000
|
||||
|
||||
// 3. Privacy
|
||||
const privacyMode = ref(false)
|
||||
const privacyMode = ref(true)
|
||||
|
||||
// 4. Preferences
|
||||
const language = ref('auto')
|
||||
|
||||
+4
-4
@@ -273,7 +273,7 @@ body {
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: color-mix(in srgb, var(--crepe-color-background, #fff) 88%, transparent);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.mermaid-inner::-webkit-scrollbar {
|
||||
@@ -315,7 +315,7 @@ body {
|
||||
.mermaid-error {
|
||||
padding: 12px 16px;
|
||||
margin: 0;
|
||||
background: color-mix(in srgb, var(--danger-text, #dc2626) 8%, transparent);
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border: 1px solid var(--danger-text, #dc2626);
|
||||
border-radius: 6px;
|
||||
color: var(--danger-text, #dc2626);
|
||||
@@ -331,12 +331,12 @@ body {
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-editor,
|
||||
:root[data-theme='dark'] .milkdown .cm-scroller {
|
||||
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
|
||||
background-color: rgba(237, 237, 237, 0.86);
|
||||
color: var(--crepe-color-on-surface);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .milkdown .cm-gutters {
|
||||
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
|
||||
background-color: rgba(237, 237, 237, 0.86);
|
||||
color: var(--crepe-color-on-surface-variant);
|
||||
border-right-color: var(--panel-border);
|
||||
}
|
||||
|
||||
+4
-58
@@ -1,10 +1,6 @@
|
||||
import { API_URL } from './config.js'
|
||||
import { API_URL, API_KEY } from './config.js'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
const API_KEY = 'your-secret-key-here'
|
||||
|
||||
let cachedIP = null
|
||||
|
||||
function generateRequestId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
@@ -46,20 +42,6 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getClientIP() {
|
||||
if (cachedIP) return cachedIP
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => controller.abort(), 3000)
|
||||
const res = await fetch('https://api.ipify.org?format=json', { signal: controller.signal })
|
||||
const data = await res.json()
|
||||
cachedIP = data.ip
|
||||
return cachedIP
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) {
|
||||
let normalizedLanguageId = 'markdown'
|
||||
if (typeof languageId === 'string' && languageId.trim()) {
|
||||
@@ -89,16 +71,10 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
|
||||
|
||||
try {
|
||||
const settings = useSettingsStore()
|
||||
const clientIP = await getClientIP()
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
'X-Request-Id': requestId,
|
||||
}
|
||||
|
||||
// Only send IP if privacy mode is OFF
|
||||
if (clientIP && !settings.privacyMode) {
|
||||
headers['X-Client-IP'] = clientIP
|
||||
'X-API-Key': API_KEY,
|
||||
}
|
||||
|
||||
const body = {
|
||||
@@ -126,38 +102,8 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('No reader available')
|
||||
}
|
||||
|
||||
let text = ''
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += new TextDecoder().decode(value)
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const jsonStr = line.slice(6).trim()
|
||||
if (!jsonStr) continue
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
if (data.content) {
|
||||
text += data.content
|
||||
}
|
||||
if (data.done || data.error) break
|
||||
} catch (e) {
|
||||
// skip invalid lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
const data = await res.json()
|
||||
return data.content || ''
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
// ignore abort
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export const DEBUG = import.meta.env.DEV
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteach.tech:8002'
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
|
||||
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
|
||||
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
|
||||
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf'
|
||||
export const API_KEY = import.meta.env.VITE_API_KEY || 'your-secret-key-here'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { CONVERT_URL } from './config.js'
|
||||
|
||||
const API_KEY = 'your-secret-key-here'
|
||||
|
||||
function readFileAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
@@ -25,7 +23,7 @@ export async function convertFileToMarkdown(file) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
'X-API-Key': 'your-secret-key-here',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file: base64,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
export const DOC_BLOCK_NODE_TYPE = 'doc_block'
|
||||
export const DOC_BLOCK_FENCE_LANG = 'llm-file'
|
||||
export const DOC_CONTEXT_LIMIT = 32 * 1024
|
||||
|
||||
const IMAGE_MD_RE = /!\[[^\]]*]\([^)]+\)/g
|
||||
const IMAGE_HTML_RE = /<img\b[^>]*>/gi
|
||||
const HEADER_SEPARATOR = '\n---\n'
|
||||
|
||||
export function normalizeDocType(value = '') {
|
||||
const lower = String(value || '').trim().toLowerCase()
|
||||
if (lower === 'txt' || lower === 'text' || lower === 'plain') return 'txt'
|
||||
if (lower === 'json') return 'json'
|
||||
if (lower === 'toml') return 'toml'
|
||||
if (lower === 'yaml' || lower === 'yml') return 'yaml'
|
||||
if (lower === 'doc' || lower === 'docx' || lower === 'word') return 'docx'
|
||||
if (lower === 'ppt' || lower === 'pptx' || lower === 'powerpoint') return 'pptx'
|
||||
if (lower === 'pdf') return 'pdf'
|
||||
return 'txt'
|
||||
}
|
||||
|
||||
export function getDocTypeFromFilename(name = '') {
|
||||
const lower = String(name || '').toLowerCase()
|
||||
if (lower.endsWith('.docx')) return 'docx'
|
||||
if (lower.endsWith('.pptx')) return 'pptx'
|
||||
if (lower.endsWith('.pdf')) return 'pdf'
|
||||
if (lower.endsWith('.json')) return 'json'
|
||||
if (lower.endsWith('.toml')) return 'toml'
|
||||
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'
|
||||
return 'txt'
|
||||
}
|
||||
|
||||
export function isSupportedDocFile(file) {
|
||||
if (!file) return false
|
||||
const name = String(file.name || '').toLowerCase()
|
||||
const type = String(file.type || '').toLowerCase()
|
||||
return (
|
||||
name.endsWith('.txt') ||
|
||||
name.endsWith('.json') ||
|
||||
name.endsWith('.toml') ||
|
||||
name.endsWith('.yaml') ||
|
||||
name.endsWith('.yml') ||
|
||||
name.endsWith('.docx') ||
|
||||
name.endsWith('.pptx') ||
|
||||
name.endsWith('.pdf') ||
|
||||
type === 'text/plain' ||
|
||||
type === 'application/json' ||
|
||||
type === 'text/yaml' ||
|
||||
type === 'text/x-yaml' ||
|
||||
type === 'application/x-yaml' ||
|
||||
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||
type === 'application/pdf'
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeDocContent(markdown = '') {
|
||||
return String(markdown || '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(IMAGE_MD_RE, '')
|
||||
.replace(IMAGE_HTML_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function quoteMeta(value = '') {
|
||||
return JSON.stringify(String(value ?? ''))
|
||||
}
|
||||
|
||||
function parseMetaLine(line = '') {
|
||||
const idx = line.indexOf(':')
|
||||
if (idx < 0) return null
|
||||
const key = line.slice(0, idx).trim()
|
||||
const rawValue = line.slice(idx + 1).trim()
|
||||
if (!key) return null
|
||||
try {
|
||||
return [key, JSON.parse(rawValue)]
|
||||
} catch {
|
||||
return [key, rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
function pickFence(content = '') {
|
||||
const matches = String(content || '').match(/`{3,}/g) || []
|
||||
const maxLen = matches.reduce((max, item) => Math.max(max, item.length), 2)
|
||||
return '`'.repeat(maxLen + 1)
|
||||
}
|
||||
|
||||
export function buildDocBlockValue(attrs = {}) {
|
||||
const docType = normalizeDocType(attrs.docType)
|
||||
const docName = String(attrs.docName || `document.${docType}`)
|
||||
const uploadTime = String(attrs.uploadTime || new Date().toISOString())
|
||||
const collapsed = Boolean(attrs.collapsed)
|
||||
const content = sanitizeDocContent(attrs.content || '')
|
||||
return [
|
||||
`type: ${quoteMeta(docType)}`,
|
||||
`name: ${quoteMeta(docName)}`,
|
||||
`uploadTime: ${quoteMeta(uploadTime)}`,
|
||||
`collapsed: ${collapsed ? 'true' : 'false'}`,
|
||||
'---',
|
||||
content,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function parseDocBlockValue(raw = '') {
|
||||
const normalized = String(raw || '').replace(/\r\n?/g, '\n')
|
||||
const separatorIndex = normalized.indexOf(HEADER_SEPARATOR)
|
||||
const headerText = separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : ''
|
||||
const bodyText = separatorIndex >= 0 ? normalized.slice(separatorIndex + HEADER_SEPARATOR.length) : normalized
|
||||
const attrs = {
|
||||
docType: 'txt',
|
||||
docName: 'document.txt',
|
||||
uploadTime: '',
|
||||
collapsed: false,
|
||||
content: sanitizeDocContent(bodyText),
|
||||
}
|
||||
|
||||
for (const line of headerText.split('\n')) {
|
||||
const parsed = parseMetaLine(line)
|
||||
if (!parsed) continue
|
||||
const [key, value] = parsed
|
||||
if (key === 'type') attrs.docType = normalizeDocType(value)
|
||||
if (key === 'name' && value) attrs.docName = String(value)
|
||||
if (key === 'uploadTime' && value) attrs.uploadTime = String(value)
|
||||
if (key === 'collapsed') attrs.collapsed = value === true || value === 'true'
|
||||
}
|
||||
|
||||
if (!attrs.docName) attrs.docName = `document.${attrs.docType}`
|
||||
return attrs
|
||||
}
|
||||
|
||||
export function buildDocBlockMarkdown(attrs = {}) {
|
||||
const value = buildDocBlockValue(attrs)
|
||||
const fence = pickFence(value)
|
||||
return `${fence}${DOC_BLOCK_FENCE_LANG}\n${value}\n${fence}`
|
||||
}
|
||||
|
||||
export function buildDocContextFence(attrs = {}) {
|
||||
const docType = normalizeDocType(attrs.docType)
|
||||
const content = sanitizeDocContent(attrs.content || '')
|
||||
const fence = pickFence(content)
|
||||
return `${fence}${docType}\n${content}\n${fence}`
|
||||
}
|
||||
|
||||
export function buildLegacyDocBlock(attrs = {}) {
|
||||
const docType = normalizeDocType(attrs.docType)
|
||||
const docName = String(attrs.docName || `document.${docType}`)
|
||||
const uploadTime = String(attrs.uploadTime || new Date().toISOString())
|
||||
const content = sanitizeDocContent(attrs.content || '')
|
||||
return `<doc_type="${docType}" doc_name="${docName}" upload_time="${uploadTime}" collapsed="${Boolean(attrs.collapsed)}">\n${content}\n</doc_end>`
|
||||
}
|
||||
|
||||
export function parseLegacyDocBlock(raw = '') {
|
||||
const match = String(raw || '').match(/^<doc_type="([^"]+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)"(?:\s+collapsed="([^"]+)")?>\n?([\s\S]*?)\n?<\/doc_end>$/)
|
||||
if (!match) return null
|
||||
return {
|
||||
docType: normalizeDocType(match[1]),
|
||||
docName: match[2] || 'document.txt',
|
||||
uploadTime: match[3] || '',
|
||||
collapsed: match[4] === 'true',
|
||||
content: sanitizeDocContent(match[5] || ''),
|
||||
}
|
||||
}
|
||||
|
||||
export function transformDocBlockMarkdownForClipboard(markdown = '') {
|
||||
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
|
||||
const replacedFence = String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
|
||||
const attrs = parseDocBlockValue(value)
|
||||
return `${prefix}${buildDocContextFence(attrs)}`
|
||||
})
|
||||
return replacedFence.replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
|
||||
const attrs = parseLegacyDocBlock(full)
|
||||
return attrs ? buildDocContextFence(attrs) : full
|
||||
})
|
||||
}
|
||||
|
||||
export function stripDocBlockMarkdown(markdown = '') {
|
||||
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g
|
||||
return String(markdown || '').replace(pattern, '$1').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
export function transformLegacyDocBlocksForExport(markdown = '') {
|
||||
return String(markdown || '').replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
|
||||
const attrs = parseLegacyDocBlock(full)
|
||||
return attrs ? buildDocBlockMarkdown(attrs) : full
|
||||
})
|
||||
}
|
||||
|
||||
export function transformSpecialDocBlocksToLegacy(markdown = '') {
|
||||
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
|
||||
return String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
|
||||
const attrs = parseDocBlockValue(value)
|
||||
return `${prefix}${buildLegacyDocBlock(attrs)}`
|
||||
})
|
||||
}
|
||||
@@ -35,10 +35,18 @@ export const translations = {
|
||||
exportPdf: 'Export PDF',
|
||||
uploadImg: 'Upload Image',
|
||||
uploadFile: 'Upload File',
|
||||
uploadDoc: 'Upload Document',
|
||||
uploadDocTypeWarning: 'Only txt, json, toml, yaml, docx, pptx, pdf formats are supported.',
|
||||
uploadDocSizeWarning: 'File size cannot exceed 10MB.',
|
||||
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
|
||||
uploadDocError: 'Document conversion failed:',
|
||||
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
|
||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
uploadBatchLimit: 'Maximum 10 files at once',
|
||||
uploadSizeLimit: 'File exceeds 50MB limit',
|
||||
uploading: 'Uploading files...',
|
||||
enableAI: 'Enable AI',
|
||||
disableAI: 'Disable AI',
|
||||
insertUrl: 'Insert Image from URL',
|
||||
@@ -84,10 +92,18 @@ export const translations = {
|
||||
exportPdf: '导出 PDF',
|
||||
uploadImg: '上传图片',
|
||||
uploadFile: '上传文件',
|
||||
uploadDoc: '上传文档',
|
||||
uploadDocTypeWarning: '仅支持 txt、json、toml、yaml、docx、pptx、pdf 格式的文档',
|
||||
uploadDocSizeWarning: '文件大小不能超过 10MB',
|
||||
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
|
||||
uploadDocError: '文档转换失败:',
|
||||
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
|
||||
uploadMdTypeWarning: '仅支持 Markdown(.md)和图片文件。',
|
||||
uploadFileError: '文件上传失败',
|
||||
uploadConvertError: '文件转换失败',
|
||||
uploadBatchLimit: '一次最多上传10个文件',
|
||||
uploadSizeLimit: '文件超过50MB限制',
|
||||
uploading: '正在上传文件...',
|
||||
enableAI: '启用 AI',
|
||||
disableAI: '禁用 AI',
|
||||
insertUrl: '通过 URL 插入图片',
|
||||
@@ -137,6 +153,9 @@ export const translations = {
|
||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||
uploadFileError: 'File upload failed.',
|
||||
uploadConvertError: 'File conversion failed.',
|
||||
uploadBatchLimit: 'Maximum 10 files at once',
|
||||
uploadSizeLimit: 'File exceeds 50MB limit',
|
||||
uploading: 'Uploading files...',
|
||||
enableAI: 'AIを有効化',
|
||||
disableAI: 'AIを無効化',
|
||||
insertUrl: 'URLから画像を挿入',
|
||||
|
||||
Reference in New Issue
Block a user