chore: 更新项目配置和依赖,优化前后端代码
This commit is contained in:
9
.cline/kanban/config.json
Normal file
9
.cline/kanban/config.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"shortcuts": [
|
||||
{
|
||||
"label": "Run",
|
||||
"command": "npm run dev",
|
||||
"icon": "play"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
# Logs
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -39,3 +39,8 @@ env/
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
|
||||
# IDE directories
|
||||
.kilocode/
|
||||
.codex/
|
||||
65
AGENTS.md
Normal file
65
AGENTS.md
Normal 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`)。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# LLM in Text - 智能写作助手
|
||||
# LLM in Text - 智能写作助手
|
||||
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验。
|
||||
|
||||
|
||||
BIN
README.md.fixed
Normal file
BIN
README.md.fixed
Normal file
Binary file not shown.
BIN
README.md.original
Normal file
BIN
README.md.original
Normal file
Binary file not shown.
265
README.md.tmp
Normal file
265
README.md.tmp
Normal file
@@ -0,0 +1,265 @@
|
||||
# LLM in Text - 智能写作助手
|
||||
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### Markdown 编辑器
|
||||
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
||||
- 支持完整 Markdown 语法和 LaTeX 公式
|
||||
- 导入/导出 Markdown 文件
|
||||
|
||||
### AI 智能补全
|
||||
- 实时生成文本补全建议(灰色显示)
|
||||
- 流式响应,低延迟体验
|
||||
- 多种交互方式:
|
||||
- **Tab 键**:接受建议
|
||||
- **Esc 键**:拒绝建议
|
||||
- **点击灰色文本**:接受建议
|
||||
- **继续输入**:自动拒绝建议
|
||||
|
||||
### AI 开关控制
|
||||
- 右下角 AI 开关按钮
|
||||
- 白色 = AI 启用,黑色 = AI 禁用
|
||||
- 禁用时自动清除灰色文本并停止 API 调用
|
||||
|
||||
## 技术架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Frontend["前端 (Vue3 + Vite)"]
|
||||
A[App.vue] --> B[MilkdownEditor.vue]
|
||||
B --> C[Crepe Editor]
|
||||
C --> D[ProseMirror]
|
||||
D --> E[copilotPlugin.ts]
|
||||
E --> F[copilotGhostMark]
|
||||
E --> G[api.js]
|
||||
end
|
||||
|
||||
subgraph Backend["后端 (FastAPI + Python)"]
|
||||
H[main.py<br/>FastAPI Server] --> I[prompt.py<br/>Prompt 构建]
|
||||
H --> J[llm.py<br/>Ollama 调用]
|
||||
J --> K[Ollama API]
|
||||
end
|
||||
|
||||
G -->|POST /v1/completions<br/>SSE 流式响应| H
|
||||
K -->|LLM 响应| J
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
llm-in-text/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ └── MilkdownEditor.vue # 主编辑器组件
|
||||
│ ├── plugins/
|
||||
│ │ ├── copilotPlugin.ts # ProseMirror AI 补全插件
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ └── index.ts # 插件导出
|
||||
│ ├── utils/
|
||||
│ │ ├── api.js # API 调用封装
|
||||
│ │ ├── config.js # 配置文件
|
||||
│ │ └── ocrCache.js # OCR 缓存管理
|
||||
│ ├── App.vue
|
||||
│ └── main.js
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI 服务器
|
||||
│ ├── llm.py # LLM API 调用
|
||||
│ ├── prompt.py # Prompt 构建
|
||||
│ └── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- Python 3.8+
|
||||
- Ollama 服务(或其他兼容 OpenAI API 的服务)
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
|
||||
# 后端
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
在 `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}
|
||||
```
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 后端设计
|
||||
|
||||
#### main.py - FastAPI 服务器
|
||||
- 定义 `/v1/completions` 端点
|
||||
- 使用 `StreamingResponse` 返回 SSE 流式响应
|
||||
- CORS 配置允许跨域请求
|
||||
|
||||
#### llm.py - LLM 调用封装
|
||||
- 使用 `ollama.AsyncClient` 异步调用
|
||||
- 支持 `think='high'` 思考模式
|
||||
- 返回 `content` 和 `thinking` 字段
|
||||
|
||||
#### prompt.py - Prompt 工程
|
||||
精心设计的 Prompt 模板,包含 7 条核心规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| RULE #1 | 无缝连接 - 不重复 suffix 内容,避免"复读机"错误 |
|
||||
| RULE #2 | 空白处理 - 避免双空格,正确对接标点 |
|
||||
| RULE #3 | 缩进对齐 - 匹配当前缩进级别和类型 |
|
||||
| RULE #4 | 列表维护 - 识别并继续任务列表、有序列表、无序列表 |
|
||||
| RULE #5 | 语法闭合 - 自动闭合未完成的 Markdown 语法 |
|
||||
| RULE #6 | 输出格式 - 仅输出续写文本,无解释无注释 |
|
||||
| RULE #7 | 必须输出 - 始终提供有用的续写建议 |
|
||||
|
||||
### 前端设计
|
||||
|
||||
#### ProseMirror Mark 系统
|
||||
|
||||
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
|
||||
|
||||
```typescript
|
||||
// 定义 ghost mark
|
||||
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||
excludes: '_',
|
||||
inclusive: true,
|
||||
toDOM: () => ['span', {
|
||||
'data-copilot-ghost': '',
|
||||
class: 'copilot-ghost-text'
|
||||
}, 0]
|
||||
}))
|
||||
|
||||
// CSS 样式
|
||||
.copilot-ghost-text {
|
||||
color: #999;
|
||||
opacity: 0.6;
|
||||
}
|
||||
```
|
||||
|
||||
#### copilotPlugin 核心逻辑
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[用户输入] --> B{文档变化?}
|
||||
B -->|是| C[清除旧建议]
|
||||
C --> D[防抖 1000ms]
|
||||
D --> E[发送 API 请求]
|
||||
E --> F[收到建议]
|
||||
F --> G[插入 Ghost Text]
|
||||
|
||||
G --> H{用户操作}
|
||||
H -->|Tab| I[接受建议<br/>移除 mark]
|
||||
H -->|Esc| J[拒绝建议<br/>删除文本]
|
||||
H -->|点击 Ghost| I
|
||||
H -->|继续输入| J
|
||||
```
|
||||
|
||||
#### 关键函数
|
||||
|
||||
| 函数 | 作用 |
|
||||
|------|------|
|
||||
| `scheduleFetch` | 防抖调度 API 请求 |
|
||||
| `insertGhostText` | 插入带 mark 的建议文本 |
|
||||
| `acceptSuggestion` | Tab 接受建议 |
|
||||
| `rejectSuggestion` | Esc 拒绝建议 |
|
||||
| `clearGhostText` | 清除当前建议 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant E as Editor (ProseMirror)
|
||||
participant P as copilotPlugin
|
||||
participant A as api.js
|
||||
participant B as Backend
|
||||
participant L as LLM
|
||||
|
||||
U->>E: 输入文本
|
||||
E->>P: view.update()
|
||||
P->>P: 清除旧建议
|
||||
P->>P: 防抖 1000ms
|
||||
P->>A: fetchSuggestion(prefix, suffix)
|
||||
A->>B: POST /v1/completions
|
||||
B->>B: build_prompt()
|
||||
B->>L: ollama.chat()
|
||||
L-->>B: {content, thinking}
|
||||
B-->>A: SSE stream
|
||||
A-->>P: suggestion text
|
||||
P->>E: insertGhostText()
|
||||
E-->>U: 显示灰色建议
|
||||
|
||||
alt Tab 键
|
||||
U->>P: Tab
|
||||
P->>E: acceptSuggestion()
|
||||
E-->>U: 建议变为正常文本
|
||||
else Esc 键
|
||||
U->>P: Esc
|
||||
P->>E: rejectSuggestion()
|
||||
E-->>U: 建议消失
|
||||
else 继续输入
|
||||
U->>E: 输入其他字符
|
||||
E->>P: handleKeyDown()
|
||||
P->>E: clearGhostText()
|
||||
end
|
||||
```
|
||||
|
||||
## 设计亮点
|
||||
|
||||
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
|
||||
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求
|
||||
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
|
||||
4. **多种交互方式**:Tab/Esc/点击/输入,用户体验友好
|
||||
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
BIN
README_clean.md
Normal file
BIN
README_clean.md
Normal file
Binary file not shown.
BIN
README_correct.md
Normal file
BIN
README_correct.md
Normal file
Binary file not shown.
BIN
README_fixed.md
Normal file
BIN
README_fixed.md
Normal file
Binary file not shown.
265
README_original.txt
Normal file
265
README_original.txt
Normal file
@@ -0,0 +1,265 @@
|
||||
# LLM in Text - 智能写作助手
|
||||
|
||||
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### Markdown 编辑器
|
||||
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
||||
- 支持完整 Markdown 语法和 LaTeX 公式
|
||||
- 导入/导出 Markdown 文件
|
||||
|
||||
### AI 智能补全
|
||||
- 实时生成文本补全建议(灰色显示)
|
||||
- 流式响应,低延迟体验
|
||||
- 多种交互方式:
|
||||
- **Tab 键**:接受建议
|
||||
- **Esc 键**:拒绝建议
|
||||
- **点击灰色文本**:接受建议
|
||||
- **继续输入**:自动拒绝建议
|
||||
|
||||
### AI 开关控制
|
||||
- 右下角 AI 开关按钮
|
||||
- 白色 = AI 启用,黑色 = AI 禁用
|
||||
- 禁用时自动清除灰色文本并停止 API 调用
|
||||
|
||||
## 技术架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Frontend["前端 (Vue3 + Vite)"]
|
||||
A[App.vue] --> B[MilkdownEditor.vue]
|
||||
B --> C[Crepe Editor]
|
||||
C --> D[ProseMirror]
|
||||
D --> E[copilotPlugin.ts]
|
||||
E --> F[copilotGhostMark]
|
||||
E --> G[api.js]
|
||||
end
|
||||
|
||||
subgraph Backend["后端 (FastAPI + Python)"]
|
||||
H[main.py<br/>FastAPI Server] --> I[prompt.py<br/>Prompt 构建]
|
||||
H --> J[llm.py<br/>Ollama 调用]
|
||||
J --> K[Ollama API]
|
||||
end
|
||||
|
||||
G -->|POST /v1/completions<br/>SSE 流式响应| H
|
||||
K -->|LLM 响应| J
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
llm-in-text/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ └── MilkdownEditor.vue # 主编辑器组件
|
||||
│ ├── plugins/
|
||||
│ │ ├── copilotPlugin.ts # ProseMirror AI 补全插件
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ └── index.ts # 插件导出
|
||||
│ ├── utils/
|
||||
│ │ ├── api.js # API 调用封装
|
||||
│ │ ├── config.js # 配置文件
|
||||
│ │ └── ocrCache.js # OCR 缓存管理
|
||||
│ ├── App.vue
|
||||
│ └── main.js
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI 服务器
|
||||
│ ├── llm.py # LLM API 调用
|
||||
│ ├── prompt.py # Prompt 构建
|
||||
│ └── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- Python 3.8+
|
||||
- Ollama 服务(或其他兼容 OpenAI API 的服务)
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
|
||||
# 后端
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
在 `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}
|
||||
```
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 后端设计
|
||||
|
||||
#### main.py - FastAPI 服务器
|
||||
- 定义 `/v1/completions` 端点
|
||||
- 使用 `StreamingResponse` 返回 SSE 流式响应
|
||||
- CORS 配置允许跨域请求
|
||||
|
||||
#### llm.py - LLM 调用封装
|
||||
- 使用 `ollama.AsyncClient` 异步调用
|
||||
- 支持 `think='high'` 思考模式
|
||||
- 返回 `content` 和 `thinking` 字段
|
||||
|
||||
#### prompt.py - Prompt 工程
|
||||
精心设计的 Prompt 模板,包含 7 条核心规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| RULE #1 | 无缝连接 - 不重复 suffix 内容,避免"复读机"错误 |
|
||||
| RULE #2 | 空白处理 - 避免双空格,正确对接标点 |
|
||||
| RULE #3 | 缩进对齐 - 匹配当前缩进级别和类型 |
|
||||
| RULE #4 | 列表维护 - 识别并继续任务列表、有序列表、无序列表 |
|
||||
| RULE #5 | 语法闭合 - 自动闭合未完成的 Markdown 语法 |
|
||||
| RULE #6 | 输出格式 - 仅输出续写文本,无解释无注释 |
|
||||
| RULE #7 | 必须输出 - 始终提供有用的续写建议 |
|
||||
|
||||
### 前端设计
|
||||
|
||||
#### ProseMirror Mark 系统
|
||||
|
||||
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
|
||||
|
||||
```typescript
|
||||
// 定义 ghost mark
|
||||
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||
excludes: '_',
|
||||
inclusive: true,
|
||||
toDOM: () => ['span', {
|
||||
'data-copilot-ghost': '',
|
||||
class: 'copilot-ghost-text'
|
||||
}, 0]
|
||||
}))
|
||||
|
||||
// CSS 样式
|
||||
.copilot-ghost-text {
|
||||
color: #999;
|
||||
opacity: 0.6;
|
||||
}
|
||||
```
|
||||
|
||||
#### copilotPlugin 核心逻辑
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[用户输入] --> B{文档变化?}
|
||||
B -->|是| C[清除旧建议]
|
||||
C --> D[防抖 1000ms]
|
||||
D --> E[发送 API 请求]
|
||||
E --> F[收到建议]
|
||||
F --> G[插入 Ghost Text]
|
||||
|
||||
G --> H{用户操作}
|
||||
H -->|Tab| I[接受建议<br/>移除 mark]
|
||||
H -->|Esc| J[拒绝建议<br/>删除文本]
|
||||
H -->|点击 Ghost| I
|
||||
H -->|继续输入| J
|
||||
```
|
||||
|
||||
#### 关键函数
|
||||
|
||||
| 函数 | 作用 |
|
||||
|------|------|
|
||||
| `scheduleFetch` | 防抖调度 API 请求 |
|
||||
| `insertGhostText` | 插入带 mark 的建议文本 |
|
||||
| `acceptSuggestion` | Tab 接受建议 |
|
||||
| `rejectSuggestion` | Esc 拒绝建议 |
|
||||
| `clearGhostText` | 清除当前建议 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant E as Editor (ProseMirror)
|
||||
participant P as copilotPlugin
|
||||
participant A as api.js
|
||||
participant B as Backend
|
||||
participant L as LLM
|
||||
|
||||
U->>E: 输入文本
|
||||
E->>P: view.update()
|
||||
P->>P: 清除旧建议
|
||||
P->>P: 防抖 1000ms
|
||||
P->>A: fetchSuggestion(prefix, suffix)
|
||||
A->>B: POST /v1/completions
|
||||
B->>B: build_prompt()
|
||||
B->>L: ollama.chat()
|
||||
L-->>B: {content, thinking}
|
||||
B-->>A: SSE stream
|
||||
A-->>P: suggestion text
|
||||
P->>E: insertGhostText()
|
||||
E-->>U: 显示灰色建议
|
||||
|
||||
alt Tab 键
|
||||
U->>P: Tab
|
||||
P->>E: acceptSuggestion()
|
||||
E-->>U: 建议变为正常文本
|
||||
else Esc 键
|
||||
U->>P: Esc
|
||||
P->>E: rejectSuggestion()
|
||||
E-->>U: 建议消失
|
||||
else 继续输入
|
||||
U->>E: 输入其他字符
|
||||
E->>P: handleKeyDown()
|
||||
P->>E: clearGhostText()
|
||||
end
|
||||
```
|
||||
|
||||
## 设计亮点
|
||||
|
||||
1. **前后端分离**:前端只负责渲染和数据回传,后端负责 LLM 调用、Prompt 构建和数据解析
|
||||
2. **低延迟优化**:防抖机制 (1000ms) + SSE 流式响应 + AbortController 取消过期请求
|
||||
3. **ProseMirror Mark 系统**:与编辑器状态完美集成,支持 Undo/Redo
|
||||
4. **多种交互方式**:Tab/Esc/点击/输入,用户体验友好
|
||||
5. **智能大小限制**:文档超过 32KB 自动禁用 AI 功能
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -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")
|
||||
|
||||
@@ -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,15 +161,18 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.chat(
|
||||
model=VLM_MODEL,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
'content': VLM_OCR_CONTEXT_PROMPT,
|
||||
'images': [image_bytes]
|
||||
}],
|
||||
stream=False,
|
||||
options={'temperature': 0.3}
|
||||
response = await asyncio.wait_for(
|
||||
client.chat(
|
||||
model=VLM_MODEL,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
'content': VLM_OCR_CONTEXT_PROMPT,
|
||||
'images': [image_bytes]
|
||||
}],
|
||||
stream=False,
|
||||
options={'temperature': 0.3}
|
||||
),
|
||||
timeout=OCR_TIMEOUT
|
||||
)
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -238,7 +238,7 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
||||
)
|
||||
image_bytes = base64.b64decode(request.image)
|
||||
logger.info("[%s] /v1/ocr decoded image_bytes=%d", request_id, len(image_bytes))
|
||||
result = await call_vlm_ocr(image_bytes, request.language)
|
||||
result = await call_vlm_ocr(image_bytes, request.language)
|
||||
logger.info(
|
||||
"[%s] /v1/ocr success text_chars=%d text_preview='%s'",
|
||||
request_id,
|
||||
@@ -253,7 +253,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格式"""
|
||||
"""鐏忓棙鏋冩禒鎯版祮閹诡澀璐烳arkdown閺嶇厧绱?""
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
|
||||
try:
|
||||
@@ -264,20 +264,20 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
len(request.file or ""),
|
||||
)
|
||||
|
||||
# 解码Base64文件内容
|
||||
# 鐟欙絿鐖淏ase64閺傚洣娆㈤崘鍛啇
|
||||
file_bytes = base64.b64decode(request.file)
|
||||
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
|
||||
|
||||
# 获取文件扩展名
|
||||
# 閼惧嘲褰囬弬鍥︽閹碘晛鐫嶉崥?
|
||||
ext = os.path.splitext(request.filename)[1].lower()
|
||||
|
||||
# 创建临时文件
|
||||
# 閸掓稑缂撴稉瀛樻閺傚洣娆?
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
||||
tmp.write(file_bytes)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# 使用MarkItDown转换为Markdown
|
||||
# 娴h法鏁arkItDown鏉烆剚宕叉稉绡梐rkdown
|
||||
md = markitdown.MarkItDown()
|
||||
result = md.convert(tmp_path)
|
||||
markdown_text = result.text_content
|
||||
@@ -294,7 +294,7 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
"filename": request.filename
|
||||
}
|
||||
finally:
|
||||
# 清理临时文件
|
||||
# 濞撳懐鎮婃稉瀛樻閺傚洣娆?
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@@ -307,3 +307,9 @@ if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
|
||||
# TTS and STT routes
|
||||
from tts_asr import register_tts_asr_routes
|
||||
register_tts_asr_routes(app)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
141
backend/tts_stt.py
Normal file
141
backend/tts_stt.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# TTS and Speech Recognition API for macOS Silicon
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import base64
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Security
|
||||
from pydantic import BaseModel
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
router = APIRouter()
|
||||
api_key_header = APIKeyHeader(name="X-API-Key")
|
||||
logger = logging.getLogger("tts_stt")
|
||||
|
||||
|
||||
def _speak_text_macos(text: str, voice: str = "meijia", rate: float = 0.5) -> bytes:
|
||||
import subprocess
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
output_path = tmp.name
|
||||
try:
|
||||
cmd = ["say", "-v", voice, "-r", str(rate * 10), "--output-format", "WAVE", "-o", output_path, text]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"TTS failed: {result.stderr}")
|
||||
with open(output_path, "rb") as f:
|
||||
audio_data = f.read()
|
||||
return audio_data
|
||||
finally:
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
|
||||
|
||||
async def _speak_text_macos_async(text: str, voice: str = "meijia", rate: float = 0.5) -> bytes:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _speak_text_macos, text, voice, rate)
|
||||
|
||||
|
||||
def _recognize_speech_macos(audio_data: bytes, language: str = "zh-CN") -> str:
|
||||
import tempfile
|
||||
try:
|
||||
import whisper
|
||||
model = whisper.load_model("tiny")
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp2:
|
||||
tmp2.write(audio_data)
|
||||
audio_for_whisper = tmp2.name
|
||||
try:
|
||||
result = model.transcribe(audio_for_whisper, language=language[:2])
|
||||
return result["text"]
|
||||
finally:
|
||||
if os.path.exists(audio_for_whisper):
|
||||
os.unlink(audio_for_whisper)
|
||||
except ImportError:
|
||||
raise Exception("Whisper is required for speech recognition on macOS")
|
||||
|
||||
|
||||
async def _recognize_speech_macos_async(audio_data: bytes, language: str = "zh-CN") -> str:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _recognize_speech_macos, audio_data, language)
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
voice: str = "meijia"
|
||||
rate: float = 0.5
|
||||
format: str = "wav"
|
||||
|
||||
|
||||
class TTSResponse(BaseModel):
|
||||
audio_base64: str
|
||||
format: str
|
||||
duration_ms: int
|
||||
|
||||
|
||||
class STTRequest(BaseModel):
|
||||
audio_base64: str
|
||||
language: str = "zh-CN"
|
||||
|
||||
|
||||
class STTResponse(BaseModel):
|
||||
text: str
|
||||
language: str
|
||||
|
||||
|
||||
@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", request_id, len(req.text), req.voice)
|
||||
audio_data = await _speak_text_macos_async(req.text, req.voice, req.rate)
|
||||
if req.format.lower() == "mp3":
|
||||
import tempfile
|
||||
import subprocess
|
||||
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", output_path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"MP3 conversion failed: {result.stderr}")
|
||||
with open(output_path, "rb") as f:
|
||||
audio_data = f.read()
|
||||
finally:
|
||||
for p in [input_path, output_path]:
|
||||
if os.path.exists(p):
|
||||
os.unlink(p)
|
||||
duration_ms = len(audio_data) * 1000 // 16000
|
||||
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("/stt", response_model=STTResponse)
|
||||
async def speech_to_text(req: STTRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = str(hash(req.audio_base64))[:8]
|
||||
try:
|
||||
logger.info("[STT][%s] audio_base64_chars=%d language=%s", request_id, len(req.audio_base64), req.language)
|
||||
audio_data = base64.b64decode(req.audio_base64)
|
||||
text = await _recognize_speech_macos_async(audio_data, req.language)
|
||||
logger.info("[STT][%s] success text_chars=%d", request_id, len(text))
|
||||
return STTResponse(text=text, language=req.language)
|
||||
except Exception as e:
|
||||
logger.exception("[STT] failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def get_api_key(api_key: str):
|
||||
from backend.main import API_KEY
|
||||
if api_key != API_KEY:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Could not validate credentials")
|
||||
return api_key
|
||||
|
||||
|
||||
def register_tts_stt_routes(app):
|
||||
app.include_router(router, prefix="/v1/tts-stt")
|
||||
BIN
original_readme.md
Normal file
BIN
original_readme.md
Normal file
Binary file not shown.
1907
package-lock.json
generated
1907
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "echo 'No tests configured yet'",
|
||||
"check": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@milkdown/core": "^7.18.0",
|
||||
|
||||
250
src/components/DocBlockCrepe.vue
Normal file
250
src/components/DocBlockCrepe.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="doc-block-crepe" :class="{ collapsed: isCollapsed }">
|
||||
<div class="doc-header">
|
||||
<div class="doc-icon">
|
||||
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M9 15v-2h6v2"/>
|
||||
<path d="M12 13v4"/>
|
||||
</svg>
|
||||
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M16 13H8"/>
|
||||
<path d="M16 17H8"/>
|
||||
<path d="M10 9H8"/>
|
||||
</svg>
|
||||
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<path d="M8 21h8"/>
|
||||
<path d="M12 17v4"/>
|
||||
</svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M16 13H8"/>
|
||||
<path d="M16 17H8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="doc-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-editor" v-show="!isCollapsed">
|
||||
<div ref="editorRoot" class="inner-crepe"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { copilotPlugin, copilotConfigCtx, setCopilotEnabled } from '../plugins/copilotPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
|
||||
const props = defineProps({
|
||||
docType: { type: String, default: 'text' },
|
||||
docName: { type: String, default: 'document.txt' },
|
||||
uploadTime: { type: String, default: '' },
|
||||
initialContent: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:content', 'delete'])
|
||||
|
||||
const editorRoot = ref(null)
|
||||
const isCollapsed = ref(false)
|
||||
let crepe = null
|
||||
let internalChangeTimer = null
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
const downloadDoc = () => {
|
||||
if (!crepe) return
|
||||
crepe.getMarkdown().then(markdown => {
|
||||
const blob = new Blob([markdown], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = props.docName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
|
||||
const syncContent = () => {
|
||||
if (!crepe) return
|
||||
if (internalChangeTimer) clearTimeout(internalChangeTimer)
|
||||
internalChangeTimer = setTimeout(async () => {
|
||||
const markdown = await crepe.getMarkdown()
|
||||
emit('update:content', markdown)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!editorRoot.value) return
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
defaultValue: props.initialContent || '',
|
||||
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,
|
||||
debounceMs: 1000
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.initialContent, (newVal) => {
|
||||
if (crepe && newVal !== undefined) {
|
||||
crepe.editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const currentPos = view.state.selection.from
|
||||
view.dispatch(view.state.tr.insertText(newVal))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (internalChangeTimer) clearTimeout(internalChangeTimer)
|
||||
if (crepe) {
|
||||
crepe.destroy()
|
||||
crepe = null
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
getContent: () => crepe ? crepe.getMarkdown() : Promise.resolve(''),
|
||||
getEditor: () => crepe
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-block-crepe {
|
||||
margin: 12px 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--crepe-color-surface-low);
|
||||
border: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.doc-block-crepe.collapsed .doc-editor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--crepe-color-surface);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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-editor {
|
||||
padding: 8px;
|
||||
background: var(--crepe-color-surface-low);
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.inner-crepe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.inner-crepe :deep(.milkdown) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.inner-crepe :deep(.ProseMirror) {
|
||||
min-height: 80px;
|
||||
padding: 8px !important;
|
||||
}
|
||||
</style>
|
||||
195
src/components/DocumentBlock.vue
Normal file
195
src/components/DocumentBlock.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1198,3 +1198,4 @@ onUnmounted(() => {
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -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)}`
|
||||
// 构建上下文:OCR内容 + 上传文档内容
|
||||
const ocrContext = buildOcrContextForRequest(doc, pos)
|
||||
|
||||
// 从markdown中提取文档块内容用于AI补全上下文
|
||||
const docContext = extractDocBlocksFromMarkdown(prefixMarkdown + suffixMarkdown)
|
||||
|
||||
// 组合所有上下文到prefix前面
|
||||
const fullPrefixWithContext = `${ocrContext}${docContext}\n\n${prefixMarkdown}`
|
||||
|
||||
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
|
||||
const ocrContextLen = requestPrefix.length - prefixMarkdown.length
|
||||
const totalWithOcr = totalTextLen + ocrContextLen
|
||||
const contextLen = fullPrefixWithContext.length - prefixMarkdown.length
|
||||
const totalWithContext = totalTextLen + contextLen
|
||||
|
||||
const overLimit = totalWithOcr > SIZE_LIMIT
|
||||
// 使用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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { API_URL } 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()
|
||||
@@ -34,7 +30,6 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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) {
|
||||
let normalizedLanguageId = 'markdown'
|
||||
if (typeof languageId === 'string' && languageId.trim()) {
|
||||
@@ -89,18 +70,11 @@ 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
|
||||
}
|
||||
|
||||
const body = {
|
||||
prefix,
|
||||
suffix,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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`
|
||||
|
||||
@@ -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,6 @@ export async function convertFileToMarkdown(file) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file: base64,
|
||||
|
||||
@@ -35,6 +35,11 @@ export const translations = {
|
||||
exportPdf: 'Export PDF',
|
||||
uploadImg: 'Upload Image',
|
||||
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.',
|
||||
uploadMdTypeWarning: 'Only Markdown (.md) files and image files are supported.',
|
||||
uploadFileError: 'File upload failed.',
|
||||
@@ -84,6 +89,11 @@ export const translations = {
|
||||
exportPdf: '导出 PDF',
|
||||
uploadImg: '上传图片',
|
||||
uploadFile: '上传文件',
|
||||
uploadDoc: '上传文档',
|
||||
uploadDocTypeWarning: '仅支持 txt、docx、pptx、pdf 格式的文档',
|
||||
uploadDocSizeWarning: '文件大小不能超过 10MB',
|
||||
uploadDocInBlockWarning: '无法在现有文档块内插入新文档,请将光标移到文档外部',
|
||||
uploadDocError: '文档转换失败:',
|
||||
uploadFileTypeWarning: '不支持的文件类型。仅支持 doc/docx/ppt/pptx/pdf/zip、图片、txt/json。',
|
||||
uploadMdTypeWarning: '仅支持 Markdown(.md)和图片文件。',
|
||||
uploadFileError: '文件上传失败',
|
||||
|
||||
BIN
update_readme.py
Normal file
BIN
update_readme.py
Normal file
Binary file not shown.
Reference in New Issue
Block a user