refactor(editor): migrate to ProseMirror Mark-based ghost text system
- Replace overlay-based GhostTextOverlay.vue with ProseMirror Mark system - Add AI toggle button with enable/disable functionality - Implement new copilotPlugin.ts using copilotGhostMark for inline suggestions - Fix cursor position offset in prompt.py by moving first suffix char to prefix - Improve API error handling with abort signal support and debug logging - Update model configuration from gpt-oss:120b to gpt-oss:20b - Add button tooltips and improve editor styling - Remove deprecated inlineSuggestionPlugin.ts - Update README with new architecture diagram and feature documentation
This commit is contained in:
@@ -1,46 +1,46 @@
|
|||||||
# LLM in Text - 智能写作助手
|
# LLM in Text - 智能写作助手
|
||||||
|
|
||||||
基于 Vue3 和 FastAPI 的智能写作助手,实现类似 GitHub Copilot 的 inline suggestions(行内建议)功能。
|
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能。
|
||||||
|
|
||||||
## 项目概述
|
## 功能特性
|
||||||
|
|
||||||
本项目是一个全屏 Markdown 编辑器,集成了大语言模型(LLM)的智能补全功能。当用户输入时,系统会根据上下文实时提供文本补全建议,用户可以通过 Tab 键接受建议或点击建议文本直接插入。
|
### Markdown 编辑器
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
### 前端
|
|
||||||
- **Vue 3** - 渐进式 JavaScript 框架
|
|
||||||
- **Vite** - 下一代前端构建工具
|
|
||||||
- **Milkdown** - 基于 ProseMirror 的 WYSIWYG Markdown 编辑器
|
|
||||||
- **Pinia** - Vue 状态管理
|
|
||||||
- **Axios** - HTTP 客户端
|
|
||||||
|
|
||||||
### 后端
|
|
||||||
- **FastAPI** - 现代化的 Python Web 框架
|
|
||||||
- **OpenAI API** - 大语言模型接口
|
|
||||||
- **Ollama** - 本地 LLM 服务支持
|
|
||||||
|
|
||||||
## 核心功能
|
|
||||||
|
|
||||||
### 1. 全屏 Markdown 编辑器
|
|
||||||
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
- 基于 Milkdown Crepe 的所见即所得编辑体验
|
||||||
- 支持完整的 Markdown 语法
|
- 支持完整 Markdown 语法和 LaTeX 公式
|
||||||
- 代码块高亮、图片粘贴等功能
|
- 导入/导出 Markdown 文件
|
||||||
- **上传/导出 Markdown 文件**(底部图标按钮)
|
|
||||||
|
|
||||||
### 2. 智能行内建议
|
### AI 智能补全
|
||||||
- 实时监听用户输入
|
- 实时生成文本补全建议(灰色显示)
|
||||||
- 基于上下文(光标前后文本)生成补全建议
|
- 流式响应,低延迟体验
|
||||||
- 流式响应,实时显示建议内容
|
- 多种交互方式:
|
||||||
- 支持多种交互方式:
|
|
||||||
- **Tab 键**:接受建议
|
- **Tab 键**:接受建议
|
||||||
- **Esc 键**:取消建议
|
- **Esc 键**:拒绝建议
|
||||||
- **点击建议**:直接插入
|
- **点击灰色文本**:接受建议
|
||||||
|
- **继续输入**:自动拒绝建议
|
||||||
|
|
||||||
### 3. 性能优化
|
### AI 开关控制
|
||||||
- 500ms 防抖机制,避免频繁请求
|
- 右下角 AI 开关按钮
|
||||||
- 流式传输(SSE),降低延迟
|
- 白色 = AI 启用,黑色 = AI 禁用
|
||||||
- 上下文智能截取(光标前30行 + 后5行)
|
- 禁用时自动清除灰色文本并停止 API 调用
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Frontend
|
||||||
|
A[Vue3] --> B[Milkdown Editor]
|
||||||
|
B --> C[ProseMirror Plugin]
|
||||||
|
C --> D[Ghost Text Mark]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend
|
||||||
|
E[FastAPI] --> F[LLM API]
|
||||||
|
F --> G[Stream Response]
|
||||||
|
end
|
||||||
|
|
||||||
|
D -->|SSE| E
|
||||||
|
G -->|text| D
|
||||||
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@@ -48,146 +48,116 @@
|
|||||||
llm-in-text/
|
llm-in-text/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── MilkdownEditor.vue # 主编辑器组件
|
│ │ └── MilkdownEditor.vue # 主编辑器组件
|
||||||
│ │ ├── GhostTextOverlay.vue # 建议文本显示组件
|
|
||||||
│ │ └── MarkdownEditor.vue # 备用编辑器
|
|
||||||
│ ├── plugins/
|
│ ├── plugins/
|
||||||
│ │ ├── inlineSuggestionPlugin.ts # 行内建议插件
|
│ │ └── copilotPlugin.ts # ProseMirror AI 补全插件
|
||||||
│ │ └── types.ts # 类型定义
|
│ ├── utils/
|
||||||
│ ├── router/
|
│ │ ├── api.js # API 调用封装
|
||||||
│ │ └── index.js # 路由配置
|
│ │ └── config.js # 配置文件
|
||||||
│ ├── store/
|
│ ├── App.vue
|
||||||
│ │ └── index.js # 状态管理
|
│ └── main.js
|
||||||
│ ├── App.vue # 根组件
|
|
||||||
│ └── main.js # 入口文件
|
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── main.py # FastAPI 服务器
|
│ ├── main.py # FastAPI 服务器
|
||||||
│ ├── llm.py # LLM API 调用
|
│ ├── llm.py # LLM API 调用
|
||||||
│ ├── prompt.py # Prompt 构建
|
│ ├── prompt.py # Prompt 构建
|
||||||
│ ├── requirements.txt # Python 依赖
|
│ └── requirements.txt
|
||||||
│ └── .env # 环境变量配置
|
|
||||||
├── plans/
|
|
||||||
│ ├── milkdown-editor-plan.md # 编辑器实施计划
|
|
||||||
│ └── inline-suggestions-plan.md # 建议功能实施计划
|
|
||||||
├── index.html
|
|
||||||
├── package.json
|
|
||||||
├── vite.config.js
|
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 前置要求
|
### 环境要求
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- Python 3.8+
|
- Python 3.8+
|
||||||
- OpenAI API Key 或 Ollama 服务
|
- OpenAI API Key 或 Ollama 服务
|
||||||
|
|
||||||
### 安装依赖
|
### 安装
|
||||||
|
|
||||||
**前端:**
|
|
||||||
```bash
|
```bash
|
||||||
|
# 前端
|
||||||
npm install
|
npm install
|
||||||
```
|
|
||||||
|
|
||||||
**后端:**
|
# 后端
|
||||||
```bash
|
|
||||||
cd backend
|
cd backend
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置环境变量
|
### 配置
|
||||||
|
|
||||||
在 `backend/.env` 文件中配置:
|
在 `backend/.env` 中配置:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OPENAI_API_KEY=your_api_key_here
|
OPENAI_API_KEY=your_api_key
|
||||||
OLLAMA_BASE_URL=http://localhost:11434/v1/
|
OLLAMA_BASE_URL=http://localhost:11434/v1/
|
||||||
OLLAMA_MODEL=gpt-4
|
OLLAMA_MODEL=gpt-4
|
||||||
```
|
```
|
||||||
|
|
||||||
### 启动服务
|
### 启动
|
||||||
|
|
||||||
**启动后端:**
|
|
||||||
```bash
|
```bash
|
||||||
|
# 后端(端口 8000)
|
||||||
cd backend
|
cd backend
|
||||||
python main.py
|
python main.py
|
||||||
```
|
|
||||||
|
|
||||||
**启动前端:**
|
# 前端(端口 5173)
|
||||||
```bash
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 `http://localhost:5173` 开始使用。
|
访问 http://localhost:5173
|
||||||
|
|
||||||
## API 接口
|
## API 接口
|
||||||
|
|
||||||
### POST /v1/completions
|
### POST /v1/completions
|
||||||
|
|
||||||
获取文本补全建议(流式响应)
|
流式获取补全建议
|
||||||
|
|
||||||
**请求体:**
|
**请求:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"prefix": "# Hello\n\nThis is ",
|
"prefix": "# Title\n\nContent ",
|
||||||
"suffix": "",
|
"suffix": "",
|
||||||
"languageId": "markdown"
|
"languageId": "markdown"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应(SSE 流):**
|
**响应(SSE):**
|
||||||
```
|
```
|
||||||
data: {"content": "a "}
|
data: {"content": "here"}
|
||||||
|
data: {"content": "here is"}
|
||||||
data: {"content": "a te"}
|
|
||||||
|
|
||||||
data: {"content": "a test"}
|
|
||||||
|
|
||||||
data: {"done": true}
|
data: {"done": true}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 已知问题
|
## 核心实现
|
||||||
|
|
||||||
### 待优化项(P1)
|
### ProseMirror Mark 系统
|
||||||
|
|
||||||
1. 代码重复 - fetchSuggestion 逻辑在两个文件中重复
|
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
|
||||||
2. 全局状态污染 - 插件使用模块级全局变量
|
|
||||||
|
|
||||||
### 轻微问题(P2)
|
```typescript
|
||||||
|
// 定义 ghost mark
|
||||||
|
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||||
|
excludes: '_',
|
||||||
|
inclusive: true,
|
||||||
|
toDOM: () => ['span', {
|
||||||
|
'data-copilot-ghost': '',
|
||||||
|
class: 'copilot-ghost-text'
|
||||||
|
}, 0]
|
||||||
|
}))
|
||||||
|
|
||||||
3. 大量调试日志影响性能
|
// CSS 样式
|
||||||
4. 缺少完整的类型定义
|
.copilot-ghost-text {
|
||||||
5. 建议文本无长度限制
|
color: #999;
|
||||||
6. API URL 硬编码在前端
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 开发指南
|
### 交互处理
|
||||||
|
|
||||||
### 代码规范
|
- 点击灰色文本区域:接受建议(移除 mark,保留文本)
|
||||||
|
- 点击其他区域:拒绝建议(删除灰色文本)
|
||||||
- **前端**:遵循 Vue 3 Composition API 最佳实践
|
- Tab 键:接受建议
|
||||||
- **后端**:遵循 FastAPI 异步编程模式
|
- Esc 键:拒绝建议
|
||||||
- **错误处理**:获取失败直接报错,不返回默认值
|
|
||||||
- **性能优化**:优先考虑降低延迟,避免冗余代码
|
|
||||||
|
|
||||||
### 调试
|
|
||||||
|
|
||||||
前端使用浏览器开发者工具,后端查看控制台输出。所有关键操作都有日志记录。
|
|
||||||
|
|
||||||
## 贡献指南
|
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request。在提交代码前,请确保:
|
|
||||||
|
|
||||||
1. 代码通过 ESLint 检查
|
|
||||||
2. 所有测试通过
|
|
||||||
3. 添加必要的注释和文档
|
|
||||||
4. 遵循项目的代码规范
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
## 致谢
|
|
||||||
|
|
||||||
- [Milkdown](https://milkdown.dev/) - 优秀的 Markdown 编辑器框架
|
|
||||||
- [FastAPI](https://fastapi.tiangolo.com/) - 现代化的 Python Web 框架
|
|
||||||
- [OpenAI](https://openai.com/) - 大语言模型 API
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
OPENAI_API_KEY=ollama
|
OPENAI_API_KEY=ollama
|
||||||
OLLAMA_HOST=http://192.168.0.120:11434
|
OLLAMA_HOST=http://192.168.0.120:11434
|
||||||
OLLAMA_MODEL=gpt-oss:120b
|
OLLAMA_MODEL=gpt-oss:20b
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -3,8 +3,8 @@ import json
|
|||||||
import ollama
|
import ollama
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:120b')
|
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||||
OLLAMA_HOST = os.getenv('OLLAMA_BASE_URL', 'http://192.168.0.120:11434')
|
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
|
||||||
|
|
||||||
# 移除 /v1/ 后缀(如果有的话),因为 Ollama Python 包使用原生 API
|
# 移除 /v1/ 后缀(如果有的话),因为 Ollama Python 包使用原生 API
|
||||||
if OLLAMA_HOST.endswith('/v1/'):
|
if OLLAMA_HOST.endswith('/v1/'):
|
||||||
|
|||||||
+3
-2
@@ -71,7 +71,7 @@ async def create_completion(request: CompletionRequest):
|
|||||||
print(f"[Backend] POST /v1/completions called")
|
print(f"[Backend] POST /v1/completions called")
|
||||||
print(f"[Backend] Received request - prefix length: {len(request.prefix)}, suffix length: {len(request.suffix)}")
|
print(f"[Backend] Received request - prefix length: {len(request.prefix)}, suffix length: {len(request.suffix)}")
|
||||||
|
|
||||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:120b')
|
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://192.168.0.120:11434')
|
||||||
|
|
||||||
print(f"[LLM] Using host: {OLLAMA_HOST}, model: {OLLAMA_MODEL}")
|
print(f"[LLM] Using host: {OLLAMA_HOST}, model: {OLLAMA_MODEL}")
|
||||||
@@ -123,8 +123,9 @@ async def create_completion(request: CompletionRequest):
|
|||||||
# 返回完整内容
|
# 返回完整内容
|
||||||
async def generate():
|
async def generate():
|
||||||
if content:
|
if content:
|
||||||
|
print(f"[LLM] Yielding full content: {repr(content)}")
|
||||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||||
yield f"data: {json.dumps({'done': true})}\n\n"
|
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ def build_prompt(prefix: str, suffix: str) -> str:
|
|||||||
"""
|
"""
|
||||||
MAX_CONTEXT_LINES = 30
|
MAX_CONTEXT_LINES = 30
|
||||||
|
|
||||||
|
# 修正:把suffix的第一个字符移到prefix末尾(解决光标位置偏差)
|
||||||
|
if suffix:
|
||||||
|
first_char = suffix[0]
|
||||||
|
prefix = prefix + first_char
|
||||||
|
suffix = suffix[1:]
|
||||||
|
|
||||||
prefix_lines = prefix.split('\n')
|
prefix_lines = prefix.split('\n')
|
||||||
suffix_lines = suffix.split('\n') if suffix else []
|
suffix_lines = suffix.split('\n') if suffix else []
|
||||||
|
|
||||||
@@ -25,6 +31,7 @@ RULES:
|
|||||||
- Match existing style, tone, and terminology
|
- Match existing style, tone, and terminology
|
||||||
- Maintain logical flow
|
- Maintain logical flow
|
||||||
- Write only the continuation, nothing else
|
- Write only the continuation, nothing else
|
||||||
|
- IMPORTANT: Start continuation directly from ⟨CURSOR⟩ position
|
||||||
|
|
||||||
TEXT:
|
TEXT:
|
||||||
{recent_prefix}⟨CURSOR⟩{recent_suffix}
|
{recent_prefix}⟨CURSOR⟩{recent_suffix}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="visible" class="ghost-text-overlay" :style="overlayStyle" @click="acceptSuggestion">
|
|
||||||
{{ displayText }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
suggestion: { type: String, default: '' },
|
|
||||||
position: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
validator: (value) => typeof value.left === 'number' && typeof value.top === 'number'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['accept', 'dismiss'])
|
|
||||||
|
|
||||||
const MAX_LENGTH = 200
|
|
||||||
|
|
||||||
const visible = computed(() => props.suggestion && props.suggestion.length > 0)
|
|
||||||
|
|
||||||
const displayText = computed(() => {
|
|
||||||
const text = props.suggestion
|
|
||||||
return text.length > MAX_LENGTH ? text.slice(0, MAX_LENGTH) + '...' : text
|
|
||||||
})
|
|
||||||
|
|
||||||
const overlayStyle = computed(() => ({
|
|
||||||
position: 'absolute',
|
|
||||||
left: `${props.position.left}px`,
|
|
||||||
top: `${props.position.top}px`,
|
|
||||||
fontSize: `${props.position.fontSize || 16}px`,
|
|
||||||
fontFamily: props.position.fontFamily || 'monospace',
|
|
||||||
color: '#999',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
cursor: 'text',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
zIndex: 1000,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const acceptSuggestion = () => emit('accept')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ghost-text-overlay {
|
|
||||||
opacity: 0.6;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghost-text-overlay:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+220
-107
@@ -9,6 +9,7 @@
|
|||||||
<polyline points="17 8 12 3 7 8"/>
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="btn-tooltip">导入 Markdown</span>
|
||||||
</button>
|
</button>
|
||||||
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md" style="display:none">
|
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md" style="display:none">
|
||||||
|
|
||||||
@@ -18,133 +19,247 @@
|
|||||||
<polyline points="7 10 12 15 17 10"/>
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="btn-tooltip">导出 Markdown</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="action-btn ai-toggle"
|
||||||
|
:class="{ 'ai-disabled': !aiEnabled }"
|
||||||
|
@click="toggleAI"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/>
|
||||||
|
<path d="M12 6v6l4 2"/>
|
||||||
|
</svg>
|
||||||
|
<span class="btn-tooltip">{{ aiEnabled ? '禁用 AI' : '启用 AI' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { replaceAll } from '@milkdown/kit/utils'
|
import { replaceAll } from '@milkdown/kit/utils'
|
||||||
import { Crepe } from '@milkdown/crepe'
|
import { Crepe } from '@milkdown/crepe'
|
||||||
import { createInlineSuggestionPlugin } from '../plugins/inlineSuggestionPlugin.js'
|
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, isCopilotEnabled, setCopilotEnabled, COPILOT_PLUGIN_KEY } from '../plugins/copilotPlugin'
|
||||||
|
import { fetchSuggestion } from '../utils/api.js'
|
||||||
|
import { DEBUG } from '../utils/config.js'
|
||||||
|
|
||||||
const root = ref(null)
|
const root = ref(null)
|
||||||
const containerRef = ref(null)
|
const containerRef = ref(null)
|
||||||
const fileInputRef = ref(null)
|
const fileInputRef = ref(null)
|
||||||
|
const aiEnabled = ref(true)
|
||||||
let crepe = null
|
let crepe = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!root.value) return
|
if (DEBUG) console.log('[Debug] onMounted called')
|
||||||
|
if (!root.value) throw new Error('root.value is null')
|
||||||
|
|
||||||
const plugin = createInlineSuggestionPlugin()
|
if (DEBUG) console.log('[Debug] Creating Crepe editor...')
|
||||||
|
crepe = new Crepe({
|
||||||
|
root: root.value,
|
||||||
|
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
|
||||||
|
features: {
|
||||||
|
[Crepe.Feature.Latex]: true,
|
||||||
|
},
|
||||||
|
featureConfigs: {
|
||||||
|
[Crepe.Feature.Latex]: {
|
||||||
|
katexOptions: {},
|
||||||
|
inlineEditConfirm: 'Escape'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
showLineNumber: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
crepe = new Crepe({
|
crepe.editor.config((ctx) => {
|
||||||
root: root.value,
|
ctx.set(copilotConfigCtx.key, {
|
||||||
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
|
fetchSuggestion,
|
||||||
features: { [Crepe.Feature.Latex]: true },
|
debounceMs: 500
|
||||||
featureConfigs: {
|
})
|
||||||
[Crepe.Feature.Latex]: { katexOptions: {}, inlineEditConfirm: 'Escape' }
|
})
|
||||||
},
|
|
||||||
config: { showLineNumber: false },
|
|
||||||
markdown: {
|
|
||||||
plugins: [plugin]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await crepe.create()
|
crepe.editor.use(copilotConfigCtx)
|
||||||
|
crepe.editor.use(copilotGhostMark)
|
||||||
|
crepe.editor.use(copilotPlugin)
|
||||||
|
|
||||||
|
await crepe.create()
|
||||||
|
|
||||||
|
if (DEBUG) console.log('[Debug] Crepe editor created with copilot plugin')
|
||||||
})
|
})
|
||||||
|
|
||||||
const exportMarkdown = async () => {
|
const exportMarkdown = async () => {
|
||||||
if (!crepe) return
|
if (!crepe) return
|
||||||
const markdown = await crepe.getMarkdown()
|
|
||||||
const blob = new Blob([markdown], { type: 'text/markdown' })
|
const { editorViewCtx } = await import('@milkdown/kit/core')
|
||||||
const url = URL.createObjectURL(blob)
|
const { COPILOT_PLUGIN_KEY } = await import('../plugins/copilotPlugin')
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
crepe.editor.action((ctx) => {
|
||||||
a.download = `document-${Date.now()}.md`
|
const view = ctx.get(editorViewCtx)
|
||||||
a.click()
|
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||||
URL.revokeObjectURL(url)
|
if (state?.suggestion && state.from < state.to) {
|
||||||
|
const tr = view.state.tr
|
||||||
|
.delete(state.from, state.to)
|
||||||
|
.setMeta(COPILOT_PLUGIN_KEY, { from: 0, to: 0, suggestion: '' })
|
||||||
|
view.dispatch(tr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const markdown = await crepe.getMarkdown()
|
||||||
|
const blob = new Blob([markdown], { type: 'text/markdown' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `document-${Date.now()}.md`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerUpload = () => fileInputRef.value?.click()
|
const triggerUpload = () => {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
const handleFileUpload = async (event) => {
|
const handleFileUpload = async (event) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
if (crepe?.editor) {
|
if (crepe && crepe.editor) {
|
||||||
crepe.editor.action(replaceAll(text))
|
crepe.editor.action(replaceAll(text))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Error] Upload failed:', e)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('[Error] Upload failed:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleAI = async () => {
|
||||||
|
aiEnabled.value = !aiEnabled.value
|
||||||
|
setCopilotEnabled(aiEnabled.value)
|
||||||
|
|
||||||
|
if (!aiEnabled.value && crepe) {
|
||||||
|
const { editorViewCtx } = await import('@milkdown/kit/core')
|
||||||
|
|
||||||
|
crepe.editor.action((ctx) => {
|
||||||
|
const view = ctx.get(editorViewCtx)
|
||||||
|
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||||
|
if (state?.suggestion && state.from < state.to) {
|
||||||
|
const tr = view.state.tr
|
||||||
|
.delete(state.from, state.to)
|
||||||
|
.setMeta(COPILOT_PLUGIN_KEY, { from: 0, to: 0, suggestion: '' })
|
||||||
|
view.dispatch(tr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (crepe) {
|
||||||
|
crepe.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.editor-container {
|
.editor-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
color: #666;
|
color: #666;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.action-btn:hover {
|
||||||
background-color: #4a90d9;
|
background-color: #4a90d9;
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #4a90d9;
|
border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.ai-disabled {
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.ai-disabled:hover {
|
||||||
|
background-color: #4a90d9;
|
||||||
|
color: white;
|
||||||
|
border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
top: -32px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover .btn-tooltip {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-editor {
|
.milkdown-editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-editor :deep(.milkdown) {
|
.milkdown-editor :deep(.milkdown) {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0 auto !important;
|
margin: 0 !important;
|
||||||
padding: 20px 40px !important;
|
padding: 20px 40px !important;
|
||||||
min-height: calc(100vh - 40px);
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-editor :deep(.milkdown__main) {
|
.milkdown-editor :deep(.milkdown__main) {
|
||||||
margin-left: 0 !important;
|
margin: 0 !important;
|
||||||
padding-left: 0 !important;
|
padding: 0 !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-editor :deep(.milkdown__editor) {
|
.milkdown-editor :deep(.milkdown__editor) {
|
||||||
margin-left: 0 !important;
|
margin: 0 !important;
|
||||||
padding-left: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-editor :deep(.milkdown__aside),
|
.milkdown-editor :deep(.milkdown__aside),
|
||||||
@@ -153,8 +268,23 @@ const handleFileUpload = async (event) => {
|
|||||||
.milkdown-editor :deep([class*="line-number"]),
|
.milkdown-editor :deep([class*="line-number"]),
|
||||||
.milkdown-editor :deep([class*="gutter"]),
|
.milkdown-editor :deep([class*="gutter"]),
|
||||||
.milkdown-editor :deep([class*="sidebar"]) {
|
.milkdown-editor :deep([class*="sidebar"]) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-editor::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-editor::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-editor::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-editor :deep(.milkdown__toolbar),
|
.milkdown-editor :deep(.milkdown__toolbar),
|
||||||
@@ -164,48 +294,31 @@ const handleFileUpload = async (event) => {
|
|||||||
.milkdown-editor :deep(.milkdown-bubble-menu),
|
.milkdown-editor :deep(.milkdown-bubble-menu),
|
||||||
.milkdown-editor :deep([class*="toolbar"]),
|
.milkdown-editor :deep([class*="toolbar"]),
|
||||||
.milkdown-editor :deep([class*="menu"]) {
|
.milkdown-editor :deep([class*="menu"]) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
height: 0 !important;
|
height: 0 !important;
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-editor :deep(.milkdown__block-handle),
|
.milkdown-editor :deep(.milkdown__block-handle),
|
||||||
.milkdown-editor :deep([class*="block-handle"]),
|
.milkdown-editor :deep([class*="block-handle"]),
|
||||||
.milkdown-editor :deep([class*="blockHandle"]) {
|
.milkdown-editor :deep([class*="blockHandle"]) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
}
|
min-width: 0 !important;
|
||||||
|
|
||||||
.milkdown-editor::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-editor::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-editor::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.ghost-text-decoration {
|
.copilot-ghost-text {
|
||||||
color: #999 !important;
|
color: #999;
|
||||||
opacity: 0.7 !important;
|
opacity: 0.6;
|
||||||
font-family: inherit !important;
|
pointer-events: none;
|
||||||
font-size: inherit !important;
|
white-space: pre-wrap;
|
||||||
line-height: inherit !important;
|
|
||||||
user-select: none !important;
|
|
||||||
pointer-events: auto !important;
|
|
||||||
cursor: text !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-text-decoration:hover {
|
.copilot-ghost-text.copilot-loading {
|
||||||
color: #666 !important;
|
opacity: 0.4;
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
|
||||||
|
import { $prose, $ctx, $markSchema } from '@milkdown/kit/utils'
|
||||||
|
import type { Ctx } from '@milkdown/kit/core'
|
||||||
|
import type { EditorView } from '@milkdown/prose/view'
|
||||||
|
|
||||||
|
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
|
||||||
|
const DEBOUNCE_MS = 500
|
||||||
|
|
||||||
|
let enabled = true
|
||||||
|
|
||||||
|
interface CopilotState {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
suggestion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CopilotConfig {
|
||||||
|
fetchSuggestion: (prefix: string, suffix: string, signal?: AbortSignal) => Promise<string>
|
||||||
|
debounceMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: CopilotState = {
|
||||||
|
from: 0,
|
||||||
|
to: 0,
|
||||||
|
suggestion: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const copilotConfigCtx = $ctx<CopilotConfig, 'copilotConfig'>({
|
||||||
|
fetchSuggestion: async () => '',
|
||||||
|
debounceMs: DEBOUNCE_MS
|
||||||
|
}, 'copilotConfig')
|
||||||
|
|
||||||
|
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
|
||||||
|
excludes: '_',
|
||||||
|
inclusive: true,
|
||||||
|
parseDOM: [{ tag: 'span[data-copilot-ghost]' }],
|
||||||
|
toDOM: () => ['span', { 'data-copilot-ghost': '', class: 'copilot-ghost-text' }, 0]
|
||||||
|
}))
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let abortController: AbortController | null = null
|
||||||
|
let currentCtx: Ctx | null = null
|
||||||
|
|
||||||
|
function clearGhostText(view: EditorView) {
|
||||||
|
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||||
|
if (state && state.suggestion && state.from < state.to) {
|
||||||
|
const tr = view.state.tr
|
||||||
|
.delete(state.from, state.to)
|
||||||
|
.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
|
||||||
|
view.dispatch(tr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertGhostText(view: EditorView, suggestion: string, from: number) {
|
||||||
|
if (!currentCtx || !suggestion) return
|
||||||
|
|
||||||
|
const schema = view.state.schema
|
||||||
|
const markType = schema.marks.copilot_ghost
|
||||||
|
|
||||||
|
if (!markType) {
|
||||||
|
console.error('[Copilot] copilot_ghost mark not found in schema')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tr = view.state.tr
|
||||||
|
tr.insertText(suggestion, from)
|
||||||
|
const endPos = from + suggestion.length
|
||||||
|
tr.addMark(from, endPos, markType.create())
|
||||||
|
tr.setMeta(COPILOT_PLUGIN_KEY, { from, to: endPos, suggestion })
|
||||||
|
view.dispatch(tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doFetchSuggestion(view: EditorView, pos: number, prefix: string, suffix: string) {
|
||||||
|
if (!currentCtx) return
|
||||||
|
|
||||||
|
const config = currentCtx.get(copilotConfigCtx.key)
|
||||||
|
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
|
|
||||||
|
abortController = new AbortController()
|
||||||
|
|
||||||
|
config.fetchSuggestion(prefix, suffix, abortController.signal)
|
||||||
|
.then(suggestion => {
|
||||||
|
if (view.state.selection.from !== pos) return
|
||||||
|
|
||||||
|
if (suggestion) {
|
||||||
|
insertGhostText(view, suggestion, pos)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (e.name !== 'AbortError') {
|
||||||
|
console.error('[Copilot] Error:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
abortController = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFetch(view: EditorView, pos: number, prefix: string, suffix: string) {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debounceTimer = null
|
||||||
|
doFetchSuggestion(view, pos, prefix, suffix)
|
||||||
|
}, DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptSuggestion(view: EditorView) {
|
||||||
|
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||||
|
if (!state?.suggestion || state.from >= state.to) return false
|
||||||
|
|
||||||
|
const tr = view.state.tr.removeMark(state.from, state.to, view.state.schema.marks.copilot_ghost)
|
||||||
|
const endPos = Math.min(state.to, tr.doc.content.size)
|
||||||
|
tr.setSelection(Selection.near(tr.doc.resolve(endPos)))
|
||||||
|
tr.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
|
||||||
|
view.dispatch(tr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectSuggestion(view: EditorView) {
|
||||||
|
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||||
|
if (!state?.suggestion) return false
|
||||||
|
|
||||||
|
clearGhostText(view)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const copilotPlugin = $prose((ctx) => {
|
||||||
|
currentCtx = ctx
|
||||||
|
|
||||||
|
return new Plugin<CopilotState>({
|
||||||
|
key: COPILOT_PLUGIN_KEY,
|
||||||
|
state: {
|
||||||
|
init: () => ({ ...initialState }),
|
||||||
|
apply: (tr, value) => {
|
||||||
|
const meta = tr.getMeta(COPILOT_PLUGIN_KEY)
|
||||||
|
if (meta !== undefined) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tr.docChanged && value.suggestion) {
|
||||||
|
return { ...initialState }
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
handleKeyDown: (view, event) => {
|
||||||
|
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||||
|
|
||||||
|
if (event.key === 'Tab' && state?.suggestion) {
|
||||||
|
event.preventDefault()
|
||||||
|
return acceptSuggestion(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape' && state?.suggestion) {
|
||||||
|
event.preventDefault()
|
||||||
|
return rejectSuggestion(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state?.suggestion && event.key !== 'Shift' && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Meta') {
|
||||||
|
clearGhostText(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
handleClick: (view, pos) => {
|
||||||
|
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||||
|
if (!state?.suggestion) return false
|
||||||
|
|
||||||
|
if (pos >= state.from && pos < state.to) {
|
||||||
|
return acceptSuggestion(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearGhostText(view)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
view: () => ({
|
||||||
|
update: (view, prevState) => {
|
||||||
|
if (view.state.doc.eq(prevState.doc) && view.state.selection.eq(prevState.selection)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = COPILOT_PLUGIN_KEY.getState(view.state)
|
||||||
|
if (state?.suggestion) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!view.state.doc.eq(prevState.doc)) {
|
||||||
|
const { from, to } = view.state.selection
|
||||||
|
if (from !== to) return
|
||||||
|
|
||||||
|
const doc = view.state.doc
|
||||||
|
const prefix = doc.textBetween(0, from)
|
||||||
|
const suffix = doc.textBetween(to, doc.content.size)
|
||||||
|
|
||||||
|
scheduleFetch(view, from, prefix, suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export { COPILOT_PLUGIN_KEY }
|
||||||
|
|
||||||
|
export function isCopilotEnabled(): boolean {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCopilotEnabled(value: boolean): void {
|
||||||
|
enabled = value
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = null
|
||||||
|
}
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { Plugin, PluginKey, Decoration, DecorationSet } from '@milkdown/prose/state'
|
|
||||||
import { EditorView } from '@milkdown/prose/view'
|
|
||||||
import { fetchSuggestion } from '../utils/api.js'
|
|
||||||
import { API_URL } from '../utils/config.js'
|
|
||||||
|
|
||||||
const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion')
|
|
||||||
const DEBOUNCE_MS = 300
|
|
||||||
|
|
||||||
interface InlineSuggestionState {
|
|
||||||
suggestion: string
|
|
||||||
suggestionPos: { from: number; to: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGhostTextDecoration(from: number, to: number, text: string) {
|
|
||||||
return Decoration.inline(from, to, {
|
|
||||||
class: 'ghost-text-decoration',
|
|
||||||
'data-suggestion': text,
|
|
||||||
}, { side: 1 })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInlineSuggestionPlugin(options: { apiUrl?: string } = {}) {
|
|
||||||
const apiUrl = options.apiUrl || API_URL
|
|
||||||
|
|
||||||
return new Plugin({
|
|
||||||
key: INLINE_SUGGESTION_KEY,
|
|
||||||
state: {
|
|
||||||
init: (): InlineSuggestionState => ({
|
|
||||||
suggestion: '',
|
|
||||||
suggestionPos: { from: 0, to: 0 }
|
|
||||||
}),
|
|
||||||
apply: (tr, value): InlineSuggestionState => {
|
|
||||||
if (!tr.docChanged) return value
|
|
||||||
|
|
||||||
const { from, to } = tr.selection
|
|
||||||
if (from === value.suggestionPos.from && to === value.suggestionPos.to) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return { suggestion: '', suggestionPos: { from: 0, to: 0 } }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations: (state) => {
|
|
||||||
const pluginState = INLINE_SUGGESTION_KEY.getState(state)
|
|
||||||
if (!pluginState.suggestion) {
|
|
||||||
return DecorationSet.empty
|
|
||||||
}
|
|
||||||
return DecorationSet.create(state.doc, [
|
|
||||||
createGhostTextDecoration(
|
|
||||||
pluginState.suggestionPos.from,
|
|
||||||
pluginState.suggestionPos.from + pluginState.suggestion.length,
|
|
||||||
pluginState.suggestion
|
|
||||||
)
|
|
||||||
])
|
|
||||||
},
|
|
||||||
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
|
|
||||||
const state = INLINE_SUGGESTION_KEY.getState(view.state)
|
|
||||||
|
|
||||||
if (event.key === 'Tab' && state.suggestion) {
|
|
||||||
event.preventDefault()
|
|
||||||
const { state: currentState, dispatch } = view
|
|
||||||
dispatch(currentState.tr.insertText(state.suggestion, currentState.selection.from))
|
|
||||||
dispatch(currentState.tr.setMeta(INLINE_SUGGESTION_KEY, {
|
|
||||||
suggestion: '',
|
|
||||||
suggestionPos: { from: 0, to: 0 }
|
|
||||||
}))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Escape' && state.suggestion) {
|
|
||||||
event.preventDefault()
|
|
||||||
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, {
|
|
||||||
suggestion: '',
|
|
||||||
suggestionPos: { from: 0, to: 0 }
|
|
||||||
}))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.suggestion) {
|
|
||||||
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, {
|
|
||||||
suggestion: '',
|
|
||||||
suggestionPos: { from: 0, to: 0 }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appendTransaction: (transactions, oldState, newState) => {
|
|
||||||
const lastTr = transactions[transactions.length - 1]
|
|
||||||
if (!lastTr || !lastTr.docChanged) return null
|
|
||||||
|
|
||||||
const currentState = INLINE_SUGGESTION_KEY.getState(newState)
|
|
||||||
const { from, to } = newState.selection
|
|
||||||
|
|
||||||
if (currentState.suggestion && from === currentState.suggestionPos.from) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
const prefix = newState.doc.textBetween(0, from)
|
|
||||||
const suffix = newState.doc.textBetween(to, newState.doc.content.size)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = await fetchSuggestion(prefix, suffix, apiUrl)
|
|
||||||
|
|
||||||
if (text && newState.selection.from === from) {
|
|
||||||
const newPluginState = {
|
|
||||||
suggestion: text,
|
|
||||||
suggestionPos: { from, to: from + text.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, newPluginState))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Inline suggestion error:', e)
|
|
||||||
}
|
|
||||||
}, DEBOUNCE_MS)
|
|
||||||
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export { INLINE_SUGGESTION_KEY }
|
|
||||||
+57
-25
@@ -1,30 +1,62 @@
|
|||||||
export async function fetchSuggestion(prefix, suffix, apiUrl = 'http://localhost:8000/v1/completions') {
|
import { DEBUG, API_URL } from './config.js'
|
||||||
const res = await fetch(apiUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) {
|
||||||
throw new Error(`HTTP ${res.status}`)
|
if (DEBUG) console.log('[Debug] fetchSuggestion called with prefix length:', prefix.length, 'suffix length:', suffix.length)
|
||||||
}
|
try {
|
||||||
|
const res = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
|
||||||
const reader = res.body?.getReader()
|
if (DEBUG) console.log('[Debug] fetchSuggestion response status:', res.status)
|
||||||
if (!reader) throw new Error('No reader available')
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text()
|
||||||
|
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
let text = ''
|
const reader = res.body?.getReader()
|
||||||
while (true) {
|
if (!reader) {
|
||||||
const { done, value } = await reader.read()
|
if (DEBUG) console.log('[Debug] No reader available')
|
||||||
if (done) break
|
throw new Error('No reader available')
|
||||||
const chunk = new TextDecoder().decode(value)
|
}
|
||||||
const lines = chunk.split('\n').filter(l => l.startsWith('data: '))
|
|
||||||
for (const line of lines) {
|
let text = ''
|
||||||
try {
|
let buffer = ''
|
||||||
const data = JSON.parse(line.slice(6))
|
while (true) {
|
||||||
if (data.content) text += data.content
|
const { done, value } = await reader.read()
|
||||||
if (data.done || data.error) break
|
if (done) break
|
||||||
} catch (e) {}
|
buffer += new TextDecoder().decode(value)
|
||||||
|
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
const jsonStr = line.slice(6).trim()
|
||||||
|
if (!jsonStr) continue
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
if (data.content) {
|
||||||
|
text += data.content
|
||||||
|
if (DEBUG) console.log('[Debug] Added content:', data.content)
|
||||||
|
}
|
||||||
|
if (data.done || data.error) break
|
||||||
|
} catch (e) {
|
||||||
|
if (DEBUG) console.warn('[Debug] JSON parse error for:', jsonStr.substring(0, 50))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG) console.log('[Debug] Final suggestion text:', text.substring(0, 100))
|
||||||
|
return text
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
if (DEBUG) console.log('[Debug] Request aborted')
|
||||||
|
} else {
|
||||||
|
if (DEBUG) console.error('[Debug] fetchSuggestion error:', e)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user