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:
“ydy0615”
2026-02-13 09:24:50 +08:00
parent 16e76e1e90
commit 65d4a57d33
12 changed files with 617 additions and 440 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:8000/v1/completions

204
README.md
View File

@@ -1,46 +1,46 @@
# LLM in Text - 智能写作助手
基于 Vue3 和 FastAPI 的智能写作助手,实现类似 GitHub Copilot 的 inline suggestions行内建议功能。
基于 Vue3 和 FastAPI 的智能 Markdown 编辑器集成大语言模型LLM实时补全建议功能。
## 项目概述
## 功能特性
本项目是一个全屏 Markdown 编辑器集成了大语言模型LLM的智能补全功能。当用户输入时系统会根据上下文实时提供文本补全建议用户可以通过 Tab 键接受建议或点击建议文本直接插入。
## 技术栈
### 前端
- **Vue 3** - 渐进式 JavaScript 框架
- **Vite** - 下一代前端构建工具
- **Milkdown** - 基于 ProseMirror 的 WYSIWYG Markdown 编辑器
- **Pinia** - Vue 状态管理
- **Axios** - HTTP 客户端
### 后端
- **FastAPI** - 现代化的 Python Web 框架
- **OpenAI API** - 大语言模型接口
- **Ollama** - 本地 LLM 服务支持
## 核心功能
### 1. 全屏 Markdown 编辑器
### Markdown 编辑器
- 基于 Milkdown Crepe 的所见即所得编辑体验
- 支持完整 Markdown 语法
- 代码块高亮、图片粘贴等功能
- **上传/导出 Markdown 文件**(底部图标按钮)
- 支持完整 Markdown 语法和 LaTeX 公式
- 导入/导出 Markdown 文件
### 2. 智能行内建议
- 实时监听用户输入
- 基于上下文(光标前后文本)生成补全建议
- 流式响应,实时显示建议内容
- 支持多种交互方式:
### AI 智能补全
- 实时生成文本补全建议(灰色显示)
- 流式响应,低延迟体验
- 多种交互方式:
- **Tab 键**:接受建议
- **Esc 键**取消建议
- **点击建议**:直接插入
- **Esc 键**拒绝建议
- **点击灰色文本**:接受建议
- **继续输入**:自动拒绝建议
### 3. 性能优化
- 500ms 防抖机制,避免频繁请求
- 流式传输SSE降低延迟
- 上下文智能截取光标前30行 + 后5行
### AI 开关控制
- 右下角 AI 开关按钮
- 白色 = AI 启用,黑色 = AI 禁用
- 禁用时自动清除灰色文本并停止 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/
├── src/
│ ├── components/
│ │ ── MilkdownEditor.vue # 主编辑器组件
│ │ ├── GhostTextOverlay.vue # 建议文本显示组件
│ │ └── MarkdownEditor.vue # 备用编辑器
│ │ ── MilkdownEditor.vue # 主编辑器组件
│ ├── plugins/
│ │ ── inlineSuggestionPlugin.ts # 行内建议插件
│ └── types.ts # 类型定义
│ ├── router/
│ │ └── index.js # 路由配置
│ ├── store/
│ └── index.js # 状态管理
│ ├── App.vue # 根组件
│ └── main.js # 入口文件
│ │ ── copilotPlugin.ts # ProseMirror AI 补全插件
├── utils/
│ ├── api.js # API 调用封装
│ │ └── config.js # 配置文件
│ ├── App.vue
│ └── main.js
├── backend/
│ ├── main.py # FastAPI 服务器
│ ├── llm.py # LLM API 调用
│ ├── prompt.py # Prompt 构建
── requirements.txt # Python 依赖
│ └── .env # 环境变量配置
├── plans/
│ ├── milkdown-editor-plan.md # 编辑器实施计划
│ └── inline-suggestions-plan.md # 建议功能实施计划
├── index.html
├── package.json
├── vite.config.js
│ ├── main.py # FastAPI 服务器
│ ├── llm.py # LLM API 调用
│ ├── prompt.py # Prompt 构建
── requirements.txt
└── README.md
```
## 快速开始
### 前置要求
### 环境要求
- Node.js 18+
- Python 3.8+
- OpenAI API Key 或 Ollama 服务
### 安装依赖
### 安装
**前端:**
```bash
# 前端
npm install
```
**后端:**
```bash
# 后端
cd backend
pip install -r requirements.txt
```
### 配置环境变量
### 配置
`backend/.env` 文件中配置:
`backend/.env` 中配置:
```env
OPENAI_API_KEY=your_api_key_here
OPENAI_API_KEY=your_api_key
OLLAMA_BASE_URL=http://localhost:11434/v1/
OLLAMA_MODEL=gpt-4
```
### 启动服务
### 启动
**启动后端:**
```bash
# 后端(端口 8000
cd backend
python main.py
```
**启动前端:**
```bash
# 前端(端口 5173
npm run dev
```
访问 `http://localhost:5173` 开始使用。
访问 http://localhost:5173
## API 接口
### POST /v1/completions
获取文本补全建议(流式响应)
流式获取补全建议
**请求**
**请求:**
```json
{
"prefix": "# Hello\n\nThis is ",
"prefix": "# Title\n\nContent ",
"suffix": "",
"languageId": "markdown"
}
```
**响应SSE**
**响应SSE**
```
data: {"content": "a "}
data: {"content": "a te"}
data: {"content": "a test"}
data: {"content": "here"}
data: {"content": "here is"}
data: {"done": true}
```
## 已知问题
## 核心实现
### 待优化项P1
### ProseMirror Mark 系统
1. 代码重复 - fetchSuggestion 逻辑在两个文件中重复
2. 全局状态污染 - 插件使用模块级全局变量
使用 ProseMirror 的 Mark 系统实现灰色建议文本:
### 轻微问题P2
```typescript
// 定义 ghost mark
export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
excludes: '_',
inclusive: true,
toDOM: () => ['span', {
'data-copilot-ghost': '',
class: 'copilot-ghost-text'
}, 0]
}))
3. 大量调试日志影响性能
4. 缺少完整的类型定义
5. 建议文本无长度限制
6. API URL 硬编码在前端
// CSS 样式
.copilot-ghost-text {
color: #999;
opacity: 0.6;
}
```
## 开发指南
### 交互处理
### 代码规范
- **前端**:遵循 Vue 3 Composition API 最佳实践
- **后端**:遵循 FastAPI 异步编程模式
- **错误处理**:获取失败直接报错,不返回默认值
- **性能优化**:优先考虑降低延迟,避免冗余代码
### 调试
前端使用浏览器开发者工具,后端查看控制台输出。所有关键操作都有日志记录。
## 贡献指南
欢迎提交 Issue 和 Pull Request。在提交代码前请确保
1. 代码通过 ESLint 检查
2. 所有测试通过
3. 添加必要的注释和文档
4. 遵循项目的代码规范
- 点击灰色文本区域:接受建议(移除 mark保留文本
- 点击其他区域:拒绝建议(删除灰色文本)
- Tab 键:接受建议
- Esc 键:拒绝建议
## 许可证
MIT License
## 致谢
- [Milkdown](https://milkdown.dev/) - 优秀的 Markdown 编辑器框架
- [FastAPI](https://fastapi.tiangolo.com/) - 现代化的 Python Web 框架
- [OpenAI](https://openai.com/) - 大语言模型 API

View File

@@ -1,3 +1,3 @@
OPENAI_API_KEY=ollama
OLLAMA_HOST=http://192.168.0.120:11434
OLLAMA_MODEL=gpt-oss:120b
OLLAMA_MODEL=gpt-oss:20b

View File

@@ -3,8 +3,8 @@ import json
import ollama
from typing import AsyncGenerator
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:120b')
OLLAMA_HOST = os.getenv('OLLAMA_BASE_URL', 'http://192.168.0.120:11434')
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
# 移除 /v1/ 后缀(如果有的话),因为 Ollama Python 包使用原生 API
if OLLAMA_HOST.endswith('/v1/'):

View File

@@ -71,7 +71,7 @@ async def create_completion(request: CompletionRequest):
print(f"[Backend] POST /v1/completions called")
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')
print(f"[LLM] Using host: {OLLAMA_HOST}, model: {OLLAMA_MODEL}")
@@ -123,8 +123,9 @@ async def create_completion(request: CompletionRequest):
# 返回完整内容
async def generate():
if content:
print(f"[LLM] Yielding full content: {repr(content)}")
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")

View File

@@ -8,6 +8,12 @@ def build_prompt(prefix: str, suffix: str) -> str:
"""
MAX_CONTEXT_LINES = 30
# 修正把suffix的第一个字符移到prefix末尾解决光标位置偏差
if suffix:
first_char = suffix[0]
prefix = prefix + first_char
suffix = suffix[1:]
prefix_lines = prefix.split('\n')
suffix_lines = suffix.split('\n') if suffix else []
@@ -25,6 +31,7 @@ RULES:
- Match existing style, tone, and terminology
- Maintain logical flow
- Write only the continuation, nothing else
- IMPORTANT: Start continuation directly from ⟨CURSOR⟩ position
TEXT:
{recent_prefix}⟨CURSOR⟩{recent_suffix}

View File

@@ -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>

View File

@@ -9,6 +9,7 @@
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span class="btn-tooltip">导入 Markdown</span>
</button>
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md" style="display:none">
@@ -18,133 +19,247 @@
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</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>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { replaceAll } from '@milkdown/kit/utils'
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 containerRef = ref(null)
const fileInputRef = ref(null)
const aiEnabled = ref(true)
let crepe = null
onMounted(async () => {
if (!root.value) return
const plugin = createInlineSuggestionPlugin()
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 },
markdown: {
plugins: [plugin]
}
})
await crepe.create()
if (DEBUG) console.log('[Debug] onMounted called')
if (!root.value) throw new Error('root.value is null')
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.editor.config((ctx) => {
ctx.set(copilotConfigCtx.key, {
fetchSuggestion,
debounceMs: 500
})
})
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 () => {
if (!crepe) return
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)
if (!crepe) return
const { editorViewCtx } = await import('@milkdown/kit/core')
const { COPILOT_PLUGIN_KEY } = await import('../plugins/copilotPlugin')
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)
}
})
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 file = event.target.files?.[0]
if (!file) return
try {
const text = await file.text()
if (crepe?.editor) {
crepe.editor.action(replaceAll(text))
const file = event.target.files?.[0]
if (!file) return
try {
const text = await file.text()
if (crepe && crepe.editor) {
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>
<style scoped>
.editor-container {
position: relative;
width: 100vw;
height: 100vh;
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.action-buttons {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
gap: 8px;
z-index: 9999;
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
gap: 8px;
z-index: 9999;
}
.action-btn {
width: 44px;
height: 44px;
padding: 10px;
background-color: #fff;
color: #666;
border: 1px solid #ddd;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
width: 44px;
height: 44px;
padding: 10px;
background-color: #fff;
color: #666;
border: 1px solid #ddd;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.action-btn:hover {
background-color: #4a90d9;
color: white;
border-color: #4a90d9;
background-color: #4a90d9;
color: white;
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 {
width: 100%;
height: 100%;
background-color: #ffffff;
width: 100%;
height: 100%;
background-color: #ffffff;
overflow-y: auto;
}
.milkdown-editor :deep(.milkdown) {
max-width: none;
margin: 0 auto !important;
padding: 20px 40px !important;
min-height: calc(100vh - 40px);
max-width: none;
margin: 0 !important;
padding: 20px 40px !important;
min-height: 100%;
}
.milkdown-editor :deep(.milkdown__main) {
margin-left: 0 !important;
padding-left: 0 !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
.milkdown-editor :deep(.milkdown__editor) {
margin-left: 0 !important;
padding-left: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.milkdown-editor :deep(.milkdown__aside),
@@ -153,8 +268,23 @@ const handleFileUpload = async (event) => {
.milkdown-editor :deep([class*="line-number"]),
.milkdown-editor :deep([class*="gutter"]),
.milkdown-editor :deep([class*="sidebar"]) {
display: none !important;
width: 0 !important;
display: none !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),
@@ -164,48 +294,31 @@ const handleFileUpload = async (event) => {
.milkdown-editor :deep(.milkdown-bubble-menu),
.milkdown-editor :deep([class*="toolbar"]),
.milkdown-editor :deep([class*="menu"]) {
display: none !important;
visibility: hidden !important;
height: 0 !important;
width: 0 !important;
display: none !important;
visibility: hidden !important;
height: 0 !important;
width: 0 !important;
}
.milkdown-editor :deep(.milkdown__block-handle),
.milkdown-editor :deep([class*="block-handle"]),
.milkdown-editor :deep([class*="blockHandle"]) {
display: none !important;
visibility: hidden !important;
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;
display: none !important;
visibility: hidden !important;
width: 0 !important;
min-width: 0 !important;
}
</style>
<style>
.ghost-text-decoration {
color: #999 !important;
opacity: 0.7 !important;
font-family: inherit !important;
font-size: inherit !important;
line-height: inherit !important;
user-select: none !important;
pointer-events: auto !important;
cursor: text !important;
.copilot-ghost-text {
color: #999;
opacity: 0.6;
pointer-events: none;
white-space: pre-wrap;
}
.ghost-text-decoration:hover {
color: #666 !important;
opacity: 1 !important;
.copilot-ghost-text.copilot-loading {
opacity: 0.4;
}
</style>

View File

@@ -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
}
}
}

View File

@@ -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 }

View File

@@ -1,30 +1,62 @@
export async function fetchSuggestion(prefix, suffix, apiUrl = 'http://localhost:8000/v1/completions') {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
})
import { DEBUG, API_URL } from './config.js'
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) {
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 (!reader) throw new Error('No reader available')
if (DEBUG) console.log('[Debug] fetchSuggestion response status:', res.status)
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = new TextDecoder().decode(value)
const lines = chunk.split('\n').filter(l => l.startsWith('data: '))
for (const line of lines) {
try {
const data = JSON.parse(line.slice(6))
if (data.content) text += data.content
if (data.done || data.error) break
} catch (e) {}
const reader = res.body?.getReader()
if (!reader) {
if (DEBUG) console.log('[Debug] No reader available')
throw new Error('No reader available')
}
let text = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += new TextDecoder().decode(value)
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const jsonStr = line.slice(6).trim()
if (!jsonStr) continue
try {
const data = JSON.parse(jsonStr)
if (data.content) {
text += data.content
if (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
}