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:
204
README.md
204
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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/'):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
234
src/plugins/copilotPlugin.ts
Normal file
234
src/plugins/copilotPlugin.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user