Compare commits

...

2 Commits

Author SHA1 Message Date
ydy0615 9ff51ac2f3 feat(plugin): add document export, doc‑block, and TTS/ASR support
Adds a DocBlock component that renders embedded documents, new export buttons for DOCX
and PDF, and updates the file‑upload picker to accept *.txt, *.docx, *.pptx, and *.pdf.
Introduces a DOCX→PDF conversion bridge in the backend and new /tts and /asr
endpoints that expose TTS and speech‑recognition functionality.  The README is
rewritten to describe the new features and clean up legacy documentation.  All
changes are backward‑compatible and do not introduce breaking API changes.
2026-04-04 23:56:18 +08:00
ydy0615 be4000b774 chore: 更新项目配置和依赖,优化前后端代码 2026-04-04 20:05:40 +08:00
28 changed files with 4533 additions and 1266 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"shortcuts": [
{
"label": "Run",
"command": "npm run dev",
"icon": "play"
}
]
}
+4 -3
View File
@@ -1,3 +1,4 @@
VITE_API_BASE_URL=http://149.104.29.239:8001 VITE_API_BASE_URL=
VITE_API_URL=http://149.104.29.239:8001/v1/completions VITE_API_URL=
VITE_OCR_URL=http://149.104.29.239:8001/v1/ocr VITE_OCR_URL=
VITE_CONVERT_URL=
+6 -1
View File
@@ -1,4 +1,4 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
@@ -39,3 +39,8 @@ env/
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# IDE directories
.kilocode/
.codex/
+52
View File
@@ -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. 状态管理
添加加载状态和错误处理,与现有按钮保持一致风格
+65
View File
@@ -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`)。
+60 -231
View File
@@ -1,264 +1,93 @@
# LLM in Text - 智能写作助手 # LLM in Text - 智能写作助手
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验 基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能。
## 功能特性 ## 功能特性
### Markdown 编辑器 ### Markdown 编辑器
- 基于 Milkdown Crepe 的所见即所得编辑体验 - 基于 Milkdown Crepe 的所见即所得编辑体验
- 支持完整 Markdown 语法和 LaTeX 公式 - 支持 Markdown 语法和 LaTeX 公式
- 支持 Mermaid 图表渲染
- 导入/导出 Markdown 文件 - 导入/导出 Markdown 文件
- 导出 DOCX 和 PDF 格式
### AI 智能补全 ### AI 智能补全
- 实时生成文本补全建议(灰色显示) - 实时生成文本补全建议(灰色显示)
- 流式响应,低延迟体验 - 流式响应,低延迟体验
- 多种交互方式: - 多种交互方式:Tab接受、Esc拒绝、点击接受
- **Tab 键**:接受建议
- **Esc 键**:拒绝建议
- **点击灰色文本**:接受建议
- **继续输入**:自动拒绝建议
### AI 开关控制 ### 文档处理
- 右下角 AI 开关按钮 - OCR 图片识别:上传图片自动识别文字
- 白色 = AI 启用,黑色 = AI 禁用 - 文档转换:PDF、DOCX、PPTX、TXT 转 Markdown
- 禁用时自动清除灰色文本并停止 API 调用 - 文档块嵌入:可折叠的文档预览块
- 智能大小限制:32KB自动禁用AI
### 设置面板
- 外观主题:亮色/暗色/跟随系统
- 背景模式:默认/暖色/阅读灯/自定义图片
- 模型智能:低/中/高思考级别
- 隐私控制:隐私模式防止发送IP
- 多语言界面:中英日韩德法
### 语音功能
- TTS文字转语音(macOS
- STT语音转文字
## 技术架构 ## 技术架构
```mermaid 前端: Vue3 + Vite + Milkdown + ProseMirror
flowchart TB 后端: FastAPI + Python + Ollama
subgraph Frontend["前端 (Vue3 + Vite)"]
A[App.vue] --> B[MilkdownEditor.vue]
B --> C[Crepe Editor]
C --> D[ProseMirror]
D --> E[copilotPlugin.ts]
E --> F[copilotGhostMark]
E --> G[api.js]
end
subgraph Backend["后端 (FastAPI + Python)"]
H[main.py<br/>FastAPI Server] --> I[prompt.py<br/>Prompt 构建]
H --> J[llm.py<br/>Ollama 调用]
J --> K[Ollama API]
end
G -->|POST /v1/completions<br/>SSE 流式响应| H
K -->|LLM 响应| J
```
## 项目结构
```
llm-in-text/
├── src/
│ ├── components/
│ │ └── MilkdownEditor.vue # 主编辑器组件
│ ├── plugins/
│ │ ├── copilotPlugin.ts # ProseMirror AI 补全插件
│ │ ├── types.ts # 类型定义
│ │ └── index.ts # 插件导出
│ ├── utils/
│ │ ├── api.js # API 调用封装
│ │ ├── config.js # 配置文件
│ │ └── ocrCache.js # OCR 缓存管理
│ ├── App.vue
│ └── main.js
├── backend/
│ ├── main.py # FastAPI 服务器
│ ├── llm.py # LLM API 调用
│ ├── prompt.py # Prompt 构建
│ └── requirements.txt
└── README.md
```
## 快速开始 ## 快速开始
### 环境要求 环境: Node.js 18+、Python 3.8+、Ollama
- Node.js 18+
- Python 3.8+
- Ollama 服务(或其他兼容 OpenAI API 的服务)
### 安装 安装:
- 前端: npm install
- 后端: pip install -r backend/requirements.txt
```bash 启动:
# 前端 - 后端: python backend/main.py (端口8001)
npm install - 前端: npm run dev (端口5173)
# 后端 ## API接口
cd backend
pip install -r requirements.txt
```
### 配置 - POST /v1/completions 流式补全建议
- POST /v1/ocr 图片文字识别
`backend/.env` 中配置: - POST /v1/convert 文档转换
- POST /v1/completions/cancel 取消请求
```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}
```
## 核心实现 ## 核心实现
### 后端设计 ### 后端
- main.py: FastAPI服务器、SSE流式响应
- llm.py: 异步Ollama调用、超时控制
- prompt.py: 7条Prompt规则
- tts_asr.py: macOS 语音处理
#### main.py - FastAPI 服务器 ### 前端
- 定义 `/v1/completions` 端点 - copilotPlugin.ts: ProseMirror Mark系统
- 使用 `StreamingResponse` 返回 SSE 流式响应 - 关键函数: scheduleFetch、insertGhostText
- CORS 配置允许跨域请求 - Pinia Store状态管理
#### llm.py - LLM 调用封装
- 使用 `ollama.AsyncClient` 异步调用
- 支持 `think='high'` 思考模式
- 返回 `content``thinking` 字段
#### prompt.py - Prompt 工程
精心设计的 Prompt 模板,包含 7 条核心规则:
| 规则 | 说明 |
|------|------|
| RULE #1 | 无缝连接 - 不重复 suffix 内容,避免"复读机"错误 |
| RULE #2 | 空白处理 - 避免双空格,正确对接标点 |
| RULE #3 | 缩进对齐 - 匹配当前缩进级别和类型 |
| RULE #4 | 列表维护 - 识别并继续任务列表、有序列表、无序列表 |
| RULE #5 | 语法闭合 - 自动闭合未完成的 Markdown 语法 |
| RULE #6 | 输出格式 - 仅输出续写文本,无解释无注释 |
| RULE #7 | 必须输出 - 始终提供有用的续写建议 |
### 前端设计
#### ProseMirror Mark 系统
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
```typescript
// 定义 ghost mark
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
excludes: '_',
inclusive: true,
toDOM: () => ['span', {
'data-copilot-ghost': '',
class: 'copilot-ghost-text'
}, 0]
}))
// CSS 样式
.copilot-ghost-text {
color: #999;
opacity: 0.6;
}
```
#### copilotPlugin 核心逻辑
```mermaid
flowchart LR
A[用户输入] --> B{文档变化?}
B -->|是| C[清除旧建议]
C --> D[防抖 1000ms]
D --> E[发送 API 请求]
E --> F[收到建议]
F --> G[插入 Ghost Text]
G --> H{用户操作}
H -->|Tab| I[接受建议<br/>移除 mark]
H -->|Esc| J[拒绝建议<br/>删除文本]
H -->|点击 Ghost| I
H -->|继续输入| J
```
#### 关键函数
| 函数 | 作用 |
|------|------|
| `scheduleFetch` | 防抖调度 API 请求 |
| `insertGhostText` | 插入带 mark 的建议文本 |
| `acceptSuggestion` | Tab 接受建议 |
| `rejectSuggestion` | Esc 拒绝建议 |
| `clearGhostText` | 清除当前建议 |
### 数据流
```mermaid
sequenceDiagram
participant U as 用户
participant E as Editor (ProseMirror)
participant P as copilotPlugin
participant A as api.js
participant B as Backend
participant L as LLM
U->>E: 输入文本
E->>P: view.update()
P->>P: 清除旧建议
P->>P: 防抖 1000ms
P->>A: fetchSuggestion(prefix, suffix)
A->>B: POST /v1/completions
B->>B: build_prompt()
B->>L: ollama.chat()
L-->>B: {content, thinking}
B-->>A: SSE stream
A-->>P: suggestion text
P->>E: insertGhostText()
E-->>U: 显示灰色建议
alt Tab 键
U->>P: Tab
P->>E: acceptSuggestion()
E-->>U: 建议变为正常文本
else Esc 键
U->>P: Esc
P->>E: rejectSuggestion()
E-->>U: 建议消失
else 继续输入
U->>E: 输入其他字符
E->>P: handleKeyDown()
P->>E: clearGhostText()
end
```
## 设计亮点 ## 设计亮点
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析 1. 前后端分离
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求 2. 低延迟优化:防抖+SSE+AbortController
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo 3. ProseMirror Mark系统
4. **多种交互方式**:Tab/Esc/点击/输入,用户体验友好 4. 多种交互方式
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能 5. 智能大小限制
6. 隐私保护
7. 多语言支持
8. 主题定制
9. 文档处理
10. 语音功能
## 开发指南
代码风格: Python(4空格,snake_case) JS/TS(2空格,camelCase)
测试: pytest
构建: npm run build
## 许可证 ## 许可证
+20
View File
@@ -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)
}
+11 -3
View File
@@ -9,9 +9,14 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b') 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') 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) client = ollama.AsyncClient(host=OLLAMA_HOST)
logger = logging.getLogger("llm") logger = logging.getLogger("llm")
@@ -97,7 +102,7 @@ async def call_ollama(
if thinking: if thinking:
kwargs["think"] = thinking kwargs["think"] = thinking
response = await client.chat(**kwargs) response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
except asyncio.CancelledError: except asyncio.CancelledError:
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
end_dt = datetime.now() end_dt = datetime.now()
@@ -156,7 +161,8 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
) )
try: try:
response = await client.chat( response = await asyncio.wait_for(
client.chat(
model=VLM_MODEL, model=VLM_MODEL,
messages=[{ messages=[{
'role': 'user', 'role': 'user',
@@ -165,6 +171,8 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
}], }],
stream=False, stream=False,
options={'temperature': 0.3} options={'temperature': 0.3}
),
timeout=OCR_TIMEOUT
) )
except Exception: except Exception:
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
+106 -10
View File
@@ -1,15 +1,18 @@
import asyncio import asyncio
import base64 import base64
import json import json
import logging import logging
import os import os
import re
import shutil
import subprocess
import tempfile import tempfile
import uuid import uuid
from typing import Optional from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security from fastapi import FastAPI, HTTPException, Request, Security, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse, Response
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
from pydantic import BaseModel from pydantic import BaseModel
@@ -81,6 +84,32 @@ class ConvertRequest(BaseModel):
filename: str = "document.pdf" filename: str = "document.pdf"
ALLOWED_CONVERT_EXTENSIONS = {".txt", ".docx", ".pptx", ".pdf"}
IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*]\([^)]+\)")
IMAGE_HTML_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
def _convert_docx_to_pdf(input_path: str, output_path: str) -> None:
node_executable = shutil.which("node")
if not node_executable:
raise RuntimeError("未找到 Node.js,无法转换 DOCX 为 PDF")
bridge_path = os.path.join(os.path.dirname(__file__), "docx2pdf_bridge.cjs")
if not os.path.exists(bridge_path):
raise RuntimeError("缺少 DOCX 转 PDF 桥接脚本")
result = subprocess.run(
[node_executable, bridge_path, input_path, output_path],
cwd=os.path.dirname(os.path.dirname(__file__)),
capture_output=True,
text=True,
)
if result.returncode != 0:
error_text = (result.stderr or result.stdout or "DOCX 转 PDF 失败").strip()
raise RuntimeError(error_text)
def _preview(text: str, limit: int = 80) -> str: def _preview(text: str, limit: int = 80) -> str:
value = (text or "").replace("\n", "\\n") value = (text or "").replace("\n", "\\n")
if len(value) <= limit: if len(value) <= limit:
@@ -88,6 +117,14 @@ def _preview(text: str, limit: int = 80) -> str:
return value[:limit] + "..." return value[:limit] + "..."
def _sanitize_converted_markdown(text: str) -> str:
value = (text or "").replace("\r\n", "\n").replace("\r", "\n")
value = IMAGE_MARKDOWN_RE.sub("", value)
value = IMAGE_HTML_RE.sub("", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _sse_payload(payload: dict) -> str: def _sse_payload(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n" return f"data: {json.dumps(payload)}\n\n"
@@ -253,7 +290,7 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
@app.post("/v1/convert") @app.post("/v1/convert")
async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(get_api_key)): async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(get_api_key)):
"""将文件转换为Markdown格式""" """Convert file to markdown"""
request_id = str(uuid.uuid4())[:8] request_id = str(uuid.uuid4())[:8]
try: try:
@@ -264,23 +301,33 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
len(request.file or ""), len(request.file or ""),
) )
# 解码Base64文件内容 # Decode base64
file_bytes = base64.b64decode(request.file) file_bytes = base64.b64decode(request.file)
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes)) 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() 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: with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
tmp.write(file_bytes) tmp.write(file_bytes)
tmp_path = tmp.name tmp_path = tmp.name
try: try:
# 使用MarkItDown转换为Markdown # Convert using MarkItDown
md = markitdown.MarkItDown() md = markitdown.MarkItDown()
result = md.convert(tmp_path) result = md.convert(tmp_path)
markdown_text = result.text_content markdown_text = _sanitize_converted_markdown(result.text_content)
logger.info( logger.info(
"[%s] /v1/convert success text_chars=%d text_preview='%s'", "[%s] /v1/convert success text_chars=%d text_preview='%s'",
@@ -294,7 +341,7 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
"filename": request.filename "filename": request.filename
} }
finally: finally:
# 清理临时文件 # Clean up temporary file
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
os.unlink(tmp_path) os.unlink(tmp_path)
@@ -303,7 +350,56 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
return JSONResponse(content={"error": str(e)}, status_code=500) 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__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001) uvicorn.run(app, host="0.0.0.0", port=8001)
# TTS and ASR routes
from tts_asr import register_tts_asr_routes
register_tts_asr_routes(app)
+8 -1
View File
@@ -1,4 +1,4 @@
fastapi fastapi
uvicorn uvicorn
ollama ollama
pydantic pydantic
@@ -10,3 +10,10 @@ python-docx
python-pptx python-pptx
openpyxl openpyxl
pypdf pypdf
# TTS and ASR dependencies
torch
transformers
soundfile
numpy
accelerate
+255
View File
@@ -0,0 +1,255 @@
# 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):
from backend.main import 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")
+2570 -872
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -6,16 +6,22 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "echo 'No tests configured yet'",
"check": "npm run build"
}, },
"dependencies": { "dependencies": {
"@blocknote/xl-docx-exporter": "^0.47.3",
"@milkdown/core": "^7.18.0", "@milkdown/core": "^7.18.0",
"@milkdown/crepe": "^7.18.0", "@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0", "@milkdown/kit": "^7.18.0",
"@milkdown/theme-nord": "^7.18.0", "@milkdown/theme-nord": "^7.18.0",
"@milkdown/vue": "^7.18.0", "@milkdown/vue": "^7.18.0",
"docx": "^9.6.0", "docx": "^9.6.0",
"docx-preview": "^0.3.7",
"docx2pdf-converter": "^2.1.1",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"jspdf": "^4.2.1",
"katex": "^0.16.9", "katex": "^0.16.9",
"markdown-it": "^13.0.0", "markdown-it": "^13.0.0",
"markdown-it-math": "^3.0.2", "markdown-it-math": "^3.0.2",
+329
View File
@@ -0,0 +1,329 @@
<template>
<div class="doc-block-crepe" :class="{ collapsed: collapsedState }">
<div class="doc-header">
<div class="doc-accent"></div>
<div class="doc-icon">
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M8 13h5"/>
<path d="M8 17h8"/>
</svg>
<svg v-else-if="docType === 'docx'" 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="m8 13 2 4 2-4 2 4 2-4"/>
</svg>
<svg v-else-if="docType === 'pptx'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="12" rx="2"/>
<path d="M8 20h8"/>
<path d="M12 16v4"/>
<path d="M9 8h3a2 2 0 0 1 0 4H9z"/>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M8 13h8"/>
<path d="M8 17h5"/>
</svg>
</div>
<div class="doc-meta">
<div class="doc-name">{{ docName }}</div>
<div class="doc-subline">{{ typeLabel }} · {{ displayTime }}</div>
</div>
<div class="doc-actions">
<button type="button" class="action-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="action-btn action-btn-danger" title="删除文件" @click="props.onDelete?.()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M8 6V4h8v2"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
</svg>
</button>
</div>
</div>
<div v-show="!collapsedState" class="doc-editor">
<div ref="editorRoot" class="inner-crepe"></div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { replaceAll } from '@milkdown/kit/utils'
import { 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 applyingExternalContent = false
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 typeLabel = computed(() => {
if (props.docType === 'docx') return 'DOCX'
if (props.docType === 'pptx') return 'PPTX'
if (props.docType === 'pdf') return 'PDF'
return 'TXT'
})
const toggleCollapse = () => {
collapsedState.value = !collapsedState.value
props.onUpdateCollapsed?.(collapsedState.value)
}
const syncContent = () => {
if (!crepe) return
if (syncTimer) clearTimeout(syncTimer)
syncTimer = setTimeout(async () => {
if (!crepe || applyingExternalContent) return
const markdown = await crepe.getMarkdown()
currentContent.value = markdown
props.onUpdateContent?.(markdown)
}, 120)
}
const syncExternalContent = async (nextValue) => {
if (!crepe) {
currentContent.value = nextValue || ''
return
}
if ((nextValue || '') === currentContent.value) return
applyingExternalContent = true
try {
crepe.editor.action(replaceAll(nextValue || ''))
currentContent.value = nextValue || ''
} finally {
applyingExternalContent = 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-block-crepe {
position: relative;
margin: 14px 0;
border: 1px solid color-mix(in srgb, var(--panel-border) 72%, transparent);
border-radius: 18px;
overflow: hidden;
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-bg) 82%, transparent) 0%, color-mix(in srgb, var(--crepe-color-surface-low) 88%, transparent) 100%);
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(14px);
}
.doc-header {
display: grid;
grid-template-columns: 4px 24px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: linear-gradient(135deg, color-mix(in srgb, var(--btn-bg) 76%, transparent) 0%, color-mix(in srgb, var(--crepe-color-surface) 78%, transparent) 100%);
border-bottom: 1px solid color-mix(in srgb, var(--panel-border) 76%, transparent);
}
.doc-accent {
width: 4px;
height: 36px;
border-radius: 999px;
background: linear-gradient(180deg, #4f8cff 0%, #7dc1ff 100%);
box-shadow: 0 0 18px rgba(79, 140, 255, 0.32);
}
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--btn-fg);
}
.doc-meta {
min-width: 0;
}
.doc-name {
font-size: 14px;
font-weight: 600;
color: var(--app-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-subline {
margin-top: 2px;
font-size: 11px;
color: var(--muted-text);
}
.doc-actions {
display: flex;
align-items: center;
gap: 6px;
}
.action-btn {
width: 30px;
height: 30px;
border: 1px solid color-mix(in srgb, var(--panel-border) 72%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--btn-bg) 84%, transparent);
color: var(--btn-fg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.14s ease, background-color 0.14s ease, border-color 0.14s ease;
}
.action-btn:hover {
transform: translateY(-1px);
background: var(--btn-hover-bg);
border-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
}
.action-btn-danger:hover {
background: rgba(220, 38, 38, 0.12);
border-color: rgba(220, 38, 38, 0.22);
color: #dc2626;
}
.doc-editor {
padding: 10px 12px 12px;
}
.inner-crepe {
border-radius: 14px;
overflow: hidden;
background: color-mix(in srgb, var(--crepe-color-background) 78%, transparent);
border: 1px solid color-mix(in srgb, var(--panel-border) 68%, transparent);
}
.inner-crepe :deep(.milkdown) {
background: transparent !important;
}
.inner-crepe :deep(.milkdown__main),
.inner-crepe :deep(.milkdown__editor) {
margin: 0 !important;
padding: 0 !important;
}
.inner-crepe :deep(.ProseMirror) {
min-height: 92px;
padding: 10px 12px 14px !important;
font-size: 14px !important;
line-height: 1.7;
}
.inner-crepe :deep(.ProseMirror h1),
.inner-crepe :deep(.ProseMirror h2),
.inner-crepe :deep(.ProseMirror h3),
.inner-crepe :deep(.ProseMirror p),
.inner-crepe :deep(.ProseMirror li),
.inner-crepe :deep(.ProseMirror blockquote),
.inner-crepe :deep(.ProseMirror code) {
font-size: inherit;
}
</style>
+195
View File
@@ -0,0 +1,195 @@
<template>
<div class="doc-block" :class="{ collapsed: isCollapsed }">
<!-- 深色条文件头 -->
<div class="doc-header">
<!-- 最左边文件类型icon -->
<div class="doc-icon">
<!-- PDF icon -->
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M9 15v-2h6v2"/>
<path d="M12 13v4"/>
</svg>
<!-- Word icon -->
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M10 9H8"/>
</svg>
<!-- PPT icon -->
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8"/>
<path d="M12 17v4"/>
</svg>
<!-- TXT icon -->
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
</svg>
</div>
<!-- 中间文件名 -->
<div class="doc-name">{{ docName }}</div>
<!-- 最右边下载按钮 + 折叠按钮 -->
<div class="doc-actions">
<button @click="downloadDoc" class="action-btn" title="下载文档">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button @click="toggleCollapse" class="action-btn collapse-btn" :title="isCollapsed ? '展开' : '折叠'">
<svg v-if="isCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
<!-- 浅色块文档内容非折叠状态显示 -->
<div class="doc-content" v-show="!isCollapsed">
<pre>{{ content }}</pre>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
docType: {
type: String,
default: 'text'
},
docName: {
type: String,
default: 'document.txt'
},
uploadTime: {
type: String,
default: ''
},
content: {
type: String,
default: ''
}
})
const isCollapsed = ref(false)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
const downloadDoc = () => {
const blob = new Blob([props.content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.docName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.doc-block {
margin: 12px 0;
border-radius: 8px;
overflow: hidden;
background: var(--crepe-color-surface-low);
border: 1px solid var(--panel-border);
}
.doc-block.collapsed .doc-content {
display: none;
}
/* 深色条 */
.doc-header {
display: flex;
align-items: center;
padding: 10px 12px;
background: var(--crepe-color-surface);
border-bottom: 1px solid var(--panel-border);
gap: 10px;
}
/* 文件类型icon */
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--crepe-color-primary);
}
/* 文件名 */
.doc-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--crepe-color-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作按钮 */
.doc-actions {
display: flex;
align-items: center;
gap: 4px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--crepe-color-on-surface-variant);
cursor: pointer;
border-radius: 4px;
opacity: 0.7;
}
.action-btn:hover {
background: var(--crepe-color-hover);
opacity: 1;
}
/* 浅色块:文档内容 */
.doc-content {
padding: 12px;
background: var(--crepe-color-surface-low);
max-height: 400px;
overflow-y: auto;
}
.doc-content pre {
margin: 0;
padding: 0;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--crepe-color-on-surface);
white-space: pre-wrap;
word-break: break-word;
}
</style>
+1 -1
View File
@@ -16,7 +16,7 @@ const props = defineProps({
}) })
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, html: false,
linkify: true, linkify: true,
typographer: true typographer: true
}) })
+306 -66
View File
@@ -1,4 +1,4 @@
<template> <template>
<div class="editor-container"> <div class="editor-container">
<div ref="root" class="milkdown-editor"></div> <div ref="root" class="milkdown-editor"></div>
@@ -35,8 +35,9 @@
<button <button
type="button" type="button"
class="action-btn" class="action-btn"
:class="{ 'force-disabled': isDocUploadDisabled }"
:aria-label="t('uploadFile')" :aria-label="t('uploadFile')"
:title="t('uploadFile')" :title="docUploadButtonTitle"
@click="triggerFileUpload" @click="triggerFileUpload"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -45,7 +46,7 @@
</svg> </svg>
<span class="btn-tooltip">{{ t('uploadFile') }}</span> <span class="btn-tooltip">{{ t('uploadFile') }}</span>
</button> </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,.docx,.pptx,.pdf,text/plain,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation" style="display:none">
<button <button
type="button" type="button"
@@ -63,21 +64,29 @@
</button> </button>
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md,text/markdown,text/x-markdown" style="display:none"> <input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md,text/markdown,text/x-markdown" style="display:none">
<div class="export-btn-wrapper">
<button <button
type="button" type="button"
class="action-btn" class="action-btn"
:aria-label="t('exportMd')" :aria-label="t('exportMd')"
:title="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"> <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"/> <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"/> <polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/> <line x1="12" y1="15" x2="12" y2="3"/>
<path d="m19 9-4 4-4-4"/>
</svg> </svg>
<span class="btn-tooltip">{{ t('exportMd') }}</span> <span class="btn-tooltip">{{ t('exportMd') }}</span>
</button> </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"> <div class="image-btn-wrapper">
<button <button
type="button" type="button"
@@ -154,12 +163,14 @@ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state' import { Selection } from '@milkdown/prose/state'
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history' 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 { 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 { mermaidRenderPreview, codeBlockConfig } from '../plugins/mermaidPlugin'
import { fetchSuggestion } from '../utils/api.js' import { fetchSuggestion } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings' 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 { convertFileToMarkdown } from '../utils/convert.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js' import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
import { DOC_BLOCK_NODE_TYPE, buildLegacyDocBlock, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js'
const emit = defineEmits(['update:markdown']) const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore() const settings = useSettingsStore()
@@ -174,15 +185,18 @@ const cameraInputRef = ref(null)
const aiEnabled = ref(true) const aiEnabled = ref(true)
const contentSize = ref(0) const contentSize = ref(0)
const showImageDropdown = ref(false) const showImageDropdown = ref(false)
const showExportDropdown = ref(false)
const showUrlDialog = ref(false) const showUrlDialog = ref(false)
const imageUrl = ref('') const imageUrl = ref('')
const canUndo = ref(false) const canUndo = ref(false)
const canRedo = ref(false) const canRedo = ref(false)
const isDocUploadDisabled = ref(false)
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT) const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024)) const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo') const undoLabel = computed(() => t('undo') || 'Undo')
const redoLabel = computed(() => t('redo') || 'Redo') const redoLabel = computed(() => t('redo') || 'Redo')
const cameraUploadLabel = computed(() => t('cameraUpload') || 'Use Camera') const cameraUploadLabel = computed(() => t('cameraUpload') || 'Use Camera')
const API_KEY = 'your-secret-key-here'
const supportsCameraCapture = computed(() => { const supportsCameraCapture = computed(() => {
if (typeof navigator === 'undefined') return false if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || '' const ua = navigator.userAgent || ''
@@ -192,47 +206,111 @@ const aiButtonLabel = computed(() => {
if (isOverLimit.value) return t('docTooLarge') if (isOverLimit.value) return t('docTooLarge')
return aiEnabled.value ? t('disableAI') : t('enableAI') return aiEnabled.value ? t('disableAI') : t('enableAI')
}) })
const docUploadButtonTitle = computed(() => {
if (isDocUploadDisabled.value) return t('uploadDocInBlockWarning') || '当前光标位置不能插入文件'
return t('uploadFile')
})
let crepe = null let crepe = null
let markdownSyncTimer = null let markdownSyncTimer = null
let rootResizeObserver = null let rootResizeObserver = null
let editorCopyHandler = null
const objectUrls = new Set() const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock']) const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i const MARKDOWN_EXT_RE = /\.md$/i
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/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 CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i
const TEXT_EXT_RE = /\.(txt|json)$/i const TEXT_EXT_RE = /\.txt$/i
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json']) const TEXT_MIME_TYPES = new Set(['text/plain'])
const CONVERT_MIME_TYPES = new Set([ const CONVERT_MIME_TYPES = new Set([
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/pdf', 'application/pdf',
'application/zip',
'application/x-zip-compressed',
]) ])
let lastInitialMarkdown = initialMarkdown.value let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)
const normalizeTrailingWhitespace = (value) => (value || '').replace(/\s+$/, '') 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) => { const syncInitialMarkdown = async (nextValue) => {
if (!crepe) { if (!crepe) {
lastInitialMarkdown = nextValue lastInitialMarkdown = transformSpecialDocBlocksToLegacy(nextValue)
return return
} }
const normalizedNextValue = transformSpecialDocBlocksToLegacy(nextValue)
try { try {
const current = await crepe.getMarkdown() const current = await crepe.getMarkdown()
const normalizedCurrent = normalizeTrailingWhitespace(current) const normalizedCurrent = normalizeTrailingWhitespace(current)
const normalizedLast = normalizeTrailingWhitespace(lastInitialMarkdown) const normalizedLast = normalizeTrailingWhitespace(lastInitialMarkdown)
if (!normalizedCurrent || normalizedCurrent === normalizedLast) { if (!normalizedCurrent || normalizedCurrent === normalizedLast) {
crepe.editor.action(replaceAll(nextValue)) crepe.editor.action(replaceAll(normalizedNextValue))
} }
} catch { } catch {
// Ignore sync errors // Ignore sync errors
} finally { } finally {
lastInitialMarkdown = nextValue lastInitialMarkdown = normalizedNextValue
} }
} }
@@ -338,6 +416,58 @@ const updateHistoryState = (view) => {
canRedo.value = redoDepth(view.state) > 0 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) => { const runHistoryCommand = (command) => {
if (!crepe) return if (!crepe) return
crepe.editor.action((ctx) => { crepe.editor.action((ctx) => {
@@ -480,6 +610,15 @@ const prepareImageFile = async (file) => {
} }
onMounted(async () => { 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') if (!root.value) throw new Error('root.value is null')
updateEditorTailSpace() updateEditorTailSpace()
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
@@ -491,7 +630,7 @@ onMounted(async () => {
crepe = new Crepe({ crepe = new Crepe({
root: root.value, root: root.value,
defaultValue: initialMarkdown.value || '', defaultValue: transformSpecialDocBlocksToLegacy(initialMarkdown.value || ''),
features: { features: {
[Crepe.Feature.Latex]: true, [Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true, [Crepe.Feature.ImageBlock]: true,
@@ -547,6 +686,9 @@ onMounted(async () => {
crepe.editor.use(copilotConfigCtx) crepe.editor.use(copilotConfigCtx)
crepe.editor.use(copilotGhostMark) crepe.editor.use(copilotGhostMark)
crepe.editor.use(copilotPlugin) crepe.editor.use(copilotPlugin)
crepe.editor.use(docBlockRemark)
crepe.editor.use(docBlockNode)
crepe.editor.use(docBlockView)
await crepe.create() await crepe.create()
@@ -557,6 +699,7 @@ onMounted(async () => {
syncObjectUrls(doc) syncObjectUrls(doc)
refreshSizeAndLimit(ctx) refreshSizeAndLimit(ctx)
updateHistoryState(view) updateHistoryState(view)
refreshDocUploadState(view)
scheduleMarkdownSync() scheduleMarkdownSync()
}) })
}) })
@@ -566,32 +709,79 @@ onMounted(async () => {
setCopilotEnabled(view, aiEnabled.value) setCopilotEnabled(view, aiEnabled.value)
refreshSizeAndLimit(ctx) refreshSizeAndLimit(ctx)
updateHistoryState(view) 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() scheduleMarkdownSync()
}) })
const exportMarkdown = async () => { 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 exportDocx = async () => {
const view = ctx.get(editorViewCtx) try {
clearCurrentSuggestion(view) 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() if (!res.ok) {
const blob = new Blob([markdown], { type: 'text/markdown' }) const errorText = await res.text()
const url = URL.createObjectURL(blob) throw new Error(`HTTP ${res.status}: ${errorText}`)
const a = document.createElement('a') }
const now = new Date()
const pad = (n) => String(n).padStart(2, '0') const pdfBlob = await res.blob()
const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` downloadBlob(pdfBlob, `${exportName}.pdf`)
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` console.log('PDF export completed successfully')
a.href = url alert('PDF导出成功!')
a.download = `save${datePart}${timePart}.md` } catch (error) {
document.body.appendChild(a) console.error('PDF export failed:', error)
a.click() alert(`PDF导出失败: ${error.message}`)
a.remove() }
URL.revokeObjectURL(url)
} }
const triggerUpload = () => { const triggerUpload = () => {
@@ -622,7 +812,7 @@ const handleFileUpload = async (event) => {
try { try {
const text = await file.text() const text = await file.text()
if (crepe && crepe.editor) { if (crepe && crepe.editor) {
crepe.editor.action(replaceAll(text)) crepe.editor.action(replaceAll(transformSpecialDocBlocksToLegacy(text)))
} }
} catch { } catch {
// File upload error, ignore // File upload error, ignore
@@ -647,6 +837,12 @@ const toggleAI = async () => {
const toggleImageDropdown = () => { const toggleImageDropdown = () => {
showImageDropdown.value = !showImageDropdown.value showImageDropdown.value = !showImageDropdown.value
showExportDropdown.value = false
}
const toggleExportDropdown = () => {
showExportDropdown.value = !showExportDropdown.value
showImageDropdown.value = false
} }
const triggerImageUpload = () => { const triggerImageUpload = () => {
@@ -689,13 +885,14 @@ const insertMarkdownAtCursor = (markdown) => {
}) })
} }
const buildCodeBlock = (file, text) => { const insertDocBlockAtCursor = (attrs) => {
const name = (file?.name || '').toLowerCase() if (!crepe) return
const lang = name.endsWith('.json') ? 'json' : 'text' const markdown = buildLegacyDocBlock(attrs)
return `\n\`\`\`${lang}\n${text}\n\`\`\`\n` insertMarkdownAtCursor(`\n${markdown}\n`)
} }
const triggerFileUpload = () => { const triggerFileUpload = () => {
if (isDocUploadDisabled.value) return
uploadFileInputRef.value?.click() uploadFileInputRef.value?.click()
} }
@@ -704,42 +901,44 @@ const handleUploadFile = async (event) => {
const file = input.files?.[0] const file = input.files?.[0]
if (!file) return if (!file) return
const convertible = isConvertibleFile(file)
try { try {
if (isImageFile(file)) { if (!isSupportedDocFile(file)) {
const objectUrl = await prepareImageFile(file) alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
if (objectUrl) {
clearCurrentGhost()
insertImageAtCursor(objectUrl)
}
return return
} }
if (isDocUploadDisabled.value || !crepe) {
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
return
}
const docType = getDocTypeFromFilename(file.name)
let content = ''
if (isTextFile(file)) { if (isTextFile(file)) {
const text = await file.text() content = await file.text()
clearCurrentGhost() } else if (isConvertibleFile(file)) {
insertMarkdownAtCursor(buildCodeBlock(file, text)) content = await convertFileToMarkdown(file)
} else {
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
return return
} }
if (convertible) { if (!content) {
const markdown = await convertFileToMarkdown(file) throw new Error('文档解析结果为空')
if (!markdown) {
throw new Error('No markdown returned')
}
clearCurrentGhost()
insertMarkdownAtCursor(markdown)
return
} }
warnUnsupportedInsertType() clearCurrentGhost()
insertDocBlockAtCursor({
docType,
docName: file.name || `document.${docType}`,
uploadTime: new Date().toISOString(),
collapsed: false,
content,
})
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : '' const message = e instanceof Error ? e.message : ''
if (convertible) {
warnConvertError(message) warnConvertError(message)
} else {
warnUploadError(message)
}
} finally { } finally {
input.value = '' input.value = ''
} }
@@ -788,6 +987,12 @@ onUnmounted(() => {
clearAllOcrCache() clearAllOcrCache()
if (crepe) { if (crepe) {
if (editorCopyHandler) {
crepe.editor.action((ctx) => {
ctx.get(editorViewCtx).dom.removeEventListener('copy', editorCopyHandler)
})
editorCopyHandler = null
}
crepe.destroy() crepe.destroy()
crepe = null crepe = null
} }
@@ -799,7 +1004,6 @@ onUnmounted(() => {
position: relative; position: relative;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden;
} }
.history-buttons { .history-buttons {
@@ -850,7 +1054,8 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
z-index: 9999; z-index: 99999;
transform: translateZ(0);
} }
.action-btn { .action-btn {
@@ -977,6 +1182,40 @@ onUnmounted(() => {
background: var(--crepe-color-hover); 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 { .url-dialog-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -1198,3 +1437,4 @@ onUnmounted(() => {
} }
</style> </style>
+4 -1
View File
@@ -2,10 +2,13 @@
import { ref, watch, computed, onMounted, onUnmounted } from 'vue' import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import packageJson from '../../package.json'
const store = useSettingsStore() const store = useSettingsStore()
const { setTheme } = useTheme() const { setTheme } = useTheme()
const VERSION = packageJson.version || '0.0.0'
const isOpen = ref(false) const isOpen = ref(false)
let systemThemeMediaQuery = null let systemThemeMediaQuery = null
@@ -272,7 +275,7 @@ const t = (key) => store.t[key]
<div class="about-card"> <div class="about-card">
<h4>llm-in-text</h4> <h4>llm-in-text</h4>
<p>A smart Markdown editor with local LLM intelligence.</p> <p>A smart Markdown editor with local LLM intelligence.</p>
<p class="version">v0.1.0-beta</p> <p class="version">v{{ VERSION }}</p>
</div> </div>
</section> </section>
</div> </div>
+1 -1
View File
@@ -10,7 +10,7 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.mount('#app') app.mount('#app')
if (import.meta.env.PROD && 'serviceWorker' in navigator) { if (import.meta.env.PROD && 'serviceWorker' in navigator && false) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => { navigator.serviceWorker.register('/sw.js').catch(() => {
// Service worker registration failed, silently ignore // Service worker registration failed, silently ignore
+42 -7
View File
@@ -9,6 +9,7 @@ import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCac
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot') const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
const DEBOUNCE_MS = 1000 const DEBOUNCE_MS = 1000
const SIZE_LIMIT = OCR_SIZE_LIMIT const SIZE_LIMIT = OCR_SIZE_LIMIT
const DOC_SIZE_LIMIT = 32 * 1024 // 文档块32KB限制
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock']) const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
interface CopilotState { interface CopilotState {
@@ -330,6 +331,31 @@ function buildOcrContextForRequest(doc: ProseNode, cursorPos: number): string {
return `\n\n${lines.join('\n')}` return `\n\n${lines.join('\n')}`
} }
// 从markdown中提取文档块内容用于AI补全上下文
function extractDocBlocksFromMarkdown(markdown: string): string {
const lines: string[] = []
// 使用正则表达式匹配文档块
// <doc_type="pdf" doc_name="xxx" upload_time="xxx">content</doc_end>
const docBlockRegex = /<doc_type="(\w+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)">([\s\S]*?)<\/doc_end>/g
let match
while ((match = docBlockRegex.exec(markdown)) !== null) {
const docType = match[1]
const docName = match[2]
const content = match[4].trim()
if (content) {
// 将文档内容格式化为上下文,限制长度
const truncatedContent = content.length > 500 ? content.substring(0, 500) + '...' : content
lines.push(`<doc_type="${docType}" doc_name="${docName}">\n${truncatedContent}\n</doc_end>`)
}
}
if (lines.length === 0) return ''
return `\n\n-- 已上传文档内容 --\n${lines.join('\n\n')}`
}
function doFetchSuggestion( function doFetchSuggestion(
view: EditorView, view: EditorView,
runtime: CopilotRuntime, runtime: CopilotRuntime,
@@ -379,7 +405,6 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
const doc = view.state.doc const doc = view.state.doc
const schema = view.state.schema const schema = view.state.schema
const baseSize = doc.content.size
const serializer = runtime.ctx.get(serializerCtx) const serializer = runtime.ctx.get(serializerCtx)
let prefixMarkdown = '' let prefixMarkdown = ''
@@ -400,12 +425,21 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n') suffixMarkdown = doc.textBetween(pos, doc.content.size, '\n', '\n')
} }
const requestPrefix = `${prefixMarkdown}${buildOcrContextForRequest(doc, pos)}` // 构建上下文:OCR内容 + 上传文档内容
const totalTextLen = (prefixMarkdown + suffixMarkdown).length const ocrContext = buildOcrContextForRequest(doc, pos)
const ocrContextLen = requestPrefix.length - prefixMarkdown.length
const totalWithOcr = totalTextLen + ocrContextLen
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) { if (overLimit) {
setCopilotEnabled(view, false) setCopilotEnabled(view, false)
@@ -422,9 +456,10 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
runtime.requestSeq = requestSeq runtime.requestSeq = requestSeq
const requestDocVersion = runtime.docVersion const requestDocVersion = runtime.docVersion
// 使用包含文档上下文的prefix
runtime.debounceTimer = setTimeout(() => { runtime.debounceTimer = setTimeout(() => {
runtime.debounceTimer = null runtime.debounceTimer = null
doFetchSuggestion(view, runtime, pos, requestPrefix, suffixMarkdown, requestSeq, requestDocVersion) doFetchSuggestion(view, runtime, pos, fullPrefixWithContext, suffixMarkdown, requestSeq, requestDocVersion)
}, debounceMs) }, debounceMs)
} }
+249
View File
@@ -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)
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const useSettingsStore = defineStore('settings', () => {
const debounceMs = ref(1000) // 1000 - 5000 const debounceMs = ref(1000) // 1000 - 5000
// 3. Privacy // 3. Privacy
const privacyMode = ref(false) const privacyMode = ref(true)
// 4. Preferences // 4. Preferences
const language = ref('auto') const language = ref('auto')
+4 -4
View File
@@ -273,7 +273,7 @@ body {
overflow: auto; overflow: auto;
border-radius: 8px; border-radius: 8px;
padding: 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 { .mermaid-inner::-webkit-scrollbar {
@@ -315,7 +315,7 @@ body {
.mermaid-error { .mermaid-error {
padding: 12px 16px; padding: 12px 16px;
margin: 0; 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: 1px solid var(--danger-text, #dc2626);
border-radius: 6px; border-radius: 6px;
color: var(--danger-text, #dc2626); color: var(--danger-text, #dc2626);
@@ -331,12 +331,12 @@ body {
:root[data-theme='dark'] .milkdown .cm-editor, :root[data-theme='dark'] .milkdown .cm-editor,
:root[data-theme='dark'] .milkdown .cm-scroller { :root[data-theme='dark'] .milkdown .cm-scroller {
background-color: 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); color: var(--crepe-color-on-surface);
} }
:root[data-theme='dark'] .milkdown .cm-gutters { :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); color: var(--crepe-color-on-surface-variant);
border-right-color: var(--panel-border); border-right-color: var(--panel-border);
} }
-26
View File
@@ -1,10 +1,6 @@
import { API_URL } from './config.js' import { API_URL } from './config.js'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
const API_KEY = 'your-secret-key-here'
let cachedIP = null
function generateRequestId() { function generateRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID() return crypto.randomUUID()
@@ -34,7 +30,6 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': API_KEY,
}, },
body: JSON.stringify({ body: JSON.stringify({
request_id: requestId, request_id: requestId,
@@ -46,20 +41,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) { export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) {
let normalizedLanguageId = 'markdown' let normalizedLanguageId = 'markdown'
if (typeof languageId === 'string' && languageId.trim()) { if (typeof languageId === 'string' && languageId.trim()) {
@@ -89,18 +70,11 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
try { try {
const settings = useSettingsStore() const settings = useSettingsStore()
const clientIP = await getClientIP()
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'X-Request-Id': requestId, 'X-Request-Id': requestId,
} }
// Only send IP if privacy mode is OFF
if (clientIP && !settings.privacyMode) {
headers['X-Client-IP'] = clientIP
}
const body = { const body = {
prefix, prefix,
suffix, suffix,
+3
View File
@@ -1,5 +1,8 @@
export const DEBUG = import.meta.env.DEV
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteach.tech:8002' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteach.tech:8002'
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions` export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr` export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert` export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf'
+1 -3
View File
@@ -1,7 +1,5 @@
import { CONVERT_URL } from './config.js' import { CONVERT_URL } from './config.js'
const API_KEY = 'your-secret-key-here'
function readFileAsBase64(file) { function readFileAsBase64(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
@@ -25,7 +23,7 @@ export async function convertFileToMarkdown(file) {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': API_KEY, 'X-API-Key': 'your-secret-key-here',
}, },
body: JSON.stringify({ body: JSON.stringify({
file: base64, file: base64,
+180
View File
@@ -0,0 +1,180 @@
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 === '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'
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('.docx') ||
name.endsWith('.pptx') ||
name.endsWith('.pdf') ||
type === 'text/plain' ||
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)}`
})
}
+10
View File
@@ -35,6 +35,11 @@ export const translations = {
exportPdf: 'Export PDF', exportPdf: 'Export PDF',
uploadImg: 'Upload Image', uploadImg: 'Upload Image',
uploadFile: 'Upload File', uploadFile: 'Upload File',
uploadDoc: 'Upload Document',
uploadDocTypeWarning: 'Only txt, docx, pptx, pdf formats are supported.',
uploadDocSizeWarning: 'File size cannot exceed 10MB.',
uploadDocInBlockWarning: 'Cannot insert document inside an existing document block. Please move cursor outside.',
uploadDocError: 'Document conversion failed:',
uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.', uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
uploadFileError: 'File upload failed.', uploadFileError: 'File upload failed.',
@@ -84,6 +89,11 @@ export const translations = {
exportPdf: '导出 PDF', exportPdf: '导出 PDF',
uploadImg: '上传图片', uploadImg: '上传图片',
uploadFile: '上传文件', uploadFile: '上传文件',
uploadDoc: '上传文档',
uploadDocTypeWarning: '仅支持 txt、docx、pptx、pdf 格式的文档',
uploadDocSizeWarning: '文件大小不能超过 10MB',
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
uploadDocError: '文档转换失败:',
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。', uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
uploadMdTypeWarning: '仅支持 Markdown.md)和图片文件。', uploadMdTypeWarning: '仅支持 Markdown.md)和图片文件。',
uploadFileError: '文件上传失败', uploadFileError: '文件上传失败',