Files
llm-in-text/plans/milkdown-editor-plan.md
T
ydy0615 2abf276d10 feat: switch from OpenAI API to native Ollama Python client
This commit refactors the LLM integration to use Ollama's native Python client instead of OpenAI-compatible API, while fixing critical template syntax errors and improving project structure.

Key changes:
- Replace openai package with ollama package in backend requirements
- Rewrite llm.py to use ollama.AsyncClient for direct Ollama API calls
- Update main.py to use non-streaming Ollama responses with thinking extraction
- Fix template syntax error in MilkdownEditor.vue (GhostTextOverlay component tags)
- Fix string截取错误 by using slice() instead of substring()
- Add src/utils/api.js and src/utils/config.js for shared configuration
- Add CORS middleware to FastAPI backend
- Update prompt.py with clearer instructions for continuation generation
- Add comprehensive README.md documentation

BREAKING CHANGE: Environment variables OLLAMA_BASE_URL changed to OLLAMA_HOST (remove /v1/ suffix)
2026-02-07 09:10:26 +08:00

424 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Milkdown 全屏 WYSIWYG Markdown 编辑器实施方案
## 项目概述
基于 `@milkdown/crepe` 实现全屏覆盖的所见即所得 Markdown 编辑器,替换现有的 contenteditable 编辑器。
## 技术选型
- **核心编辑器**: `@milkdown/crepe` - 功能完整的 WYSIWYG Markdown 编辑器
- **Vue 集成**: `@milkdown/vue` - Vue 3 组件支持
- **主题**: `frame`(简洁框架主题)
## 系统架构
```mermaid
graph TB
A[App.vue] --> B[MilkdownProvider]
B --> C[Milkdown Editor - Crepe]
subgraph "Crepe 核心功能"
D[WYSWIYG 编辑体验]
E[Markdown 语法即时渲染]
F[斜杠命令菜单 Slash Commands]
G[代码块高亮]
H[图片粘贴支持]
end
subgraph "集成功能"
I[GhostTextOverlay<br/>建议文本显示]
J[InlineSuggestionPlugin<br/>智能补全]
end
C --> I
C --> J
```
## 实施步骤
### Step 1: 安装依赖包
```bash
npm install @milkdown/crepe @milkdown/vue
```
### Step 2: 创建 Milkdown 编辑器组件
**文件**: `src/components/MilkdownEditor.vue`
#### 核心实现要点
```vue
<template>
<div class="editor-container" ref="containerRef">
<button class="export-btn" @click="exportMarkdown">导出文件</button>
<div ref="root" class="milkdown-editor"></div>
<!-- 修复正确的组件标签语法 -->
<GhostTextOverlay
v-if="suggestion && cursorRect"
:suggestion="suggestion"
:position="cursorRect"
@accept="acceptSuggestion"
@dismiss="dismissSuggestion"
/>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { Crepe } from '@milkdown/crepe'
import GhostTextOverlay from './GhostTextOverlay.vue'
const root = ref(null)
const containerRef = ref(null)
let crepe = null
const suggestion = ref('')
const cursorRect = ref(null)
let debounceTimer = null
let lastPos = -1
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/v1/completions'
const DEBOUNCE_MS = 150
onMounted(async () => {
if (!root.value) return
crepe = new Crepe({
root: root.value,
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
})
await crepe.create()
// 修复:使用更可靠的事件绑定方式
initEditorEvents()
})
// 修复:组件卸载时清理资源
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
if (crepe) {
crepe.destroy()
}
})
const getCursorPosition = async () => {
if (!crepe) return null
try {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
const { from } = view.state.selection
const coords = view.coordsAtPos(from)
const containerRect = containerRef.value?.getBoundingClientRect()
if (!containerRect) return null
return {
left: coords.left - containerRect.left,
top: coords.top - containerRect.top + window.scrollY,
fontSize: 16,
fontFamily: 'monospace',
}
} catch (e) {
console.error('getCursorPosition error:', e)
return null
}
}
const fetchSuggestion = async (prefix, suffix) => {
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
})
// 修复:遵循"获取失败直接报错"原则
if (!res.ok) {
const errorText = await res.text()
throw new Error(`API request failed: ${res.status} - ${errorText}`)
}
const reader = res.body?.getReader()
if (!reader) {
throw new Error('No response body reader available')
}
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) {
// 忽略 JSON 解析错误,继续处理下一行
}
}
}
return text
} catch (e) {
// 修复:直接抛出错误,不返回空字符串
throw e
}
}
const onInput = async () => {
if (!crepe) return
try {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
const { from } = view.state.selection
if (from === lastPos) return
lastPos = from
const prefix = view.state.doc.textBetween(0, from)
const suffix = view.state.doc.textBetween(from, view.state.doc.content.size)
// 修复:使用正确的字符串截取方法
console.log('Prefix preview:', prefix.slice(-50))
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
try {
cursorRect.value = await getCursorPosition()
suggestion.value = await fetchSuggestion(prefix, suffix)
} catch (e) {
console.error('Failed to fetch suggestion:', e)
suggestion.value = ''
}
}, DEBOUNCE_MS)
} catch (e) {
console.error('onInput error:', e)
}
}
const handleTab = () => {
if (suggestion.value) {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
view.dispatch(view.state.tr.insertText(suggestion.value))
suggestion.value = ''
}
}
const dismissSuggestion = () => {
suggestion.value = ''
}
const acceptSuggestion = () => {
if (suggestion.value) {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
view.dispatch(view.state.tr.insertText(suggestion.value))
suggestion.value = ''
}
}
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)
}
// 修复:使用更可靠的事件绑定方式
const initEditorEvents = () => {
if (!crepe) return
try {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
// 直接在编辑器 DOM 上监听输入事件
view.dom.addEventListener('input', onInput)
view.dom.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
handleTab()
}
})
} catch (e) {
console.error('Failed to bind events:', e)
}
}
</script>
<style scoped>
.editor-container {
position: relative;
}
.export-btn {
position: fixed;
top: 20px;
right: 20px;
padding: 8px 16px;
background-color: #4a90d9;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
z-index: 1000;
}
.export-btn:hover {
background-color: #3a7bc8;
}
.milkdown-editor {
width: 100vw;
height: 100vh;
background-color: #ffffff;
overflow-y: auto;
}
.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) {
max-width: 900px;
margin: 0 auto !important;
padding: 20px 40px !important;
min-height: calc(100vh - 40px);
}
.milkdown-editor :deep(*) {
margin-top: 0 !important;
margin-bottom: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
</style>
```
### Step 3: 更新 App.vue
- 移除旧的 `MarkdownEditor` 组件引用
- 使用新的 `MilkdownEditor` 组件
- 保持全屏布局(100vh × 100vw
### Step 4: 添加样式配置
`main.js` 中导入 Crepe 主题:
```js
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
```
## 已知问题及修复方案
### 🔴 严重问题(P0
#### 1. 模板语法错误
**位置**: `MilkdownEditor.vue:7-13`
**问题**: GhostTextOverlay 组件标签缺少尖括号
**修复**: 使用正确的 Vue 组件标签语法 `<GhostTextOverlay>``</GhostTextOverlay>`
#### 2. 字符串截取错误
**位置**: `MilkdownEditor.vue:155`
**问题**: `prefix.substring(-50)` 在 JavaScript 中会返回整个字符串
**修复**: 改为 `prefix.slice(-50)``prefix.substring(prefix.length - 50)`
#### 3. 错误处理违反原则
**位置**: `MilkdownEditor.vue:92-94`
**问题**: 请求失败时返回空字符串而不是抛出错误
**修复**: 遵循"获取失败直接报错"原则,抛出异常而不是返回默认值
### 🟡 中等问题(P1
#### 4. 内存泄漏风险
**问题**: 组件卸载时没有清理 `debounceTimer`
**修复**: 添加 `onUnmounted` 生命周期钩子,清理定时器和编辑器实例
#### 5. 不可靠的事件绑定
**问题**: 使用硬编码的 500ms 延迟等待编辑器创建
**修复**: 在 `await crepe.create()` 后直接调用 `initEditorEvents()`
#### 6. 代码重复
**问题**: `fetchSuggestion` 逻辑在两个文件中重复
**修复**: 将共享逻辑提取到独立的工具函数或服务中
#### 7. 全局状态污染
**问题**: 插件使用模块级全局变量
**修复**: 使用 ProseMirror 插件的状态管理机制
### 🟢 轻微问题(P2
#### 8. 大量调试日志
**问题**: 代码中包含大量 `console.log` 调试语句
**修复**: 移除或条件化调试日志
#### 9. 缺少类型定义
**问题**: TypeScript 代码中缺少完整的类型定义
**修复**: 添加完整的 TypeScript 类型定义
#### 10. 没有加载状态
**问题**: 用户无法知道是否正在获取建议
**修复**: 添加加载状态指示器
#### 11. 建议文本无长度限制
**问题**: 建议文本可能过长
**修复**: 添加建议文本长度限制
#### 12. API URL 硬编码
**问题**: API URL 硬编码在前端代码中
**修复**: 使用环境变量配置 API URL
#### 13. 缺少 CORS 配置
**问题**: 后端没有配置 CORS
**修复**: 在 FastAPI 中添加 CORS 中间件
## 全屏覆盖样式要点
- 编辑器容器: `width: 100vw; height: 100vh`
- 移除默认 padding/margin
- 纯编辑器模式,无预览面板
- 自定义滚动条样式
## 性能优化建议
1. **防抖优化**: 保持 150ms 防抖,避免频繁请求
2. **流式响应**: 使用 SSE 流式传输,降低延迟
3. **上下文截取**: 智能截取上下文(光标前30行 + 后5行)
4. **内存管理**: 及时清理定时器和事件监听器
5. **代码精简**: 移除冗余代码和注释
## 测试要点
1. 编辑器基本功能测试
2. 建议功能测试(Tab 接受、Esc 取消、点击接受)
3. 错误处理测试(网络错误、API 错误)
4. 性能测试(大量文本输入)
5. 内存泄漏测试(长时间使用)