chore: 更新项目配置和依赖,优化前后端代码
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"label": "Run",
|
||||||
|
"command": "npm run dev",
|
||||||
|
"icon": "play"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+4
-3
@@ -1,3 +1,4 @@
|
|||||||
VITE_API_BASE_URL=http://149.104.29.239:8001
|
VITE_API_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
@@ -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/
|
||||||
@@ -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 体验。
|
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能,提供类似 GitHub Copilot 的 Ghost Text 体验。
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+265
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
+11
-3
@@ -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
|
||||||
|
|||||||
+13
-7
@@ -1,4 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -253,7 +253,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格式"""
|
"""鐏忓棙鏋冩禒鎯版祮閹诡澀璐烳arkdown閺嶇厧绱?""
|
||||||
request_id = str(uuid.uuid4())[:8]
|
request_id = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -264,20 +264,20 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
|||||||
len(request.file or ""),
|
len(request.file or ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 解码Base64文件内容
|
# 鐟欙絿鐖淏ase64閺傚洣娆㈤崘鍛啇
|
||||||
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))
|
||||||
|
|
||||||
# 获取文件扩展名
|
# 閼惧嘲褰囬弬鍥︽閹碘晛鐫嶉崥?
|
||||||
ext = os.path.splitext(request.filename)[1].lower()
|
ext = os.path.splitext(request.filename)[1].lower()
|
||||||
|
|
||||||
# 创建临时文件
|
# 閸掓稑缂撴稉瀛樻閺傚洣娆?
|
||||||
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
|
# 娴h法鏁arkItDown鏉烆剚宕叉稉绡梐rkdown
|
||||||
md = markitdown.MarkItDown()
|
md = markitdown.MarkItDown()
|
||||||
result = md.convert(tmp_path)
|
result = md.convert(tmp_path)
|
||||||
markdown_text = result.text_content
|
markdown_text = result.text_content
|
||||||
@@ -294,7 +294,7 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
|||||||
"filename": request.filename
|
"filename": request.filename
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
# 清理临时文件
|
# 濞撳懐鎮婃稉瀛樻閺傚洣娆?
|
||||||
if os.path.exists(tmp_path):
|
if os.path.exists(tmp_path):
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
@@ -307,3 +307,9 @@ 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 STT routes
|
||||||
|
from tts_asr import register_tts_asr_routes
|
||||||
|
register_tts_asr_routes(app)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
Binary file not shown.
Generated
+1031
-876
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -6,7 +6,9 @@
|
|||||||
"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": {
|
||||||
"@milkdown/core": "^7.18.0",
|
"@milkdown/core": "^7.18.0",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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({
|
const md = new MarkdownIt({
|
||||||
html: true,
|
html: false,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
typographer: true
|
typographer: true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -1198,3 +1198,4 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
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`
|
||||||
|
|||||||
@@ -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,6 @@ export async function convertFileToMarkdown(file) {
|
|||||||
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({
|
||||||
file: base64,
|
file: base64,
|
||||||
|
|||||||
@@ -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: '文件上传失败',
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user