From 16e76e1e902b7546632bf22f5ba6c44aa9315373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cydy0615=E2=80=9D?= <“allenyuan410@gmail.com”> Date: Thu, 12 Feb 2026 18:52:16 +0800 Subject: [PATCH] refactor(editor): migrate to Milkdown with LaTeX support and clean up legacy code - Removed old contenteditable-based MarkdownEditor component - Integrated Milkdown Crepe with LaTeX (KaTeX) rendering support - Simplified inline suggestion plugin using ProseMirror decorations - Removed debug logging and unused components (HelloWorld, plan files) - Increased debounce from 150ms to 500ms for better performance - Fixed SSE JSON serialization in backend main.py --- README.md | 39 +- backend/main.py | 3 +- index.html | 1 + package-lock.json | 17 +- package.json | 3 +- plans/inline-suggestions-plan.md | 625 ------------------------- plans/milkdown-editor-plan.md | 396 ---------------- src/components/GhostTextOverlay.vue | 59 ++- src/components/HelloWorld.vue | 43 -- src/components/MarkdownEditor.vue | 642 -------------------------- src/components/MarkdownPreview.vue | 191 ++++++++ src/components/MilkdownEditor.vue | 498 +++++--------------- src/plugins/inlineSuggestionPlugin.ts | 198 ++++---- src/utils/api.js | 72 +-- 14 files changed, 487 insertions(+), 2300 deletions(-) delete mode 100644 plans/inline-suggestions-plan.md delete mode 100644 plans/milkdown-editor-plan.md delete mode 100644 src/components/HelloWorld.vue delete mode 100644 src/components/MarkdownEditor.vue create mode 100644 src/components/MarkdownPreview.vue diff --git a/README.md b/README.md index c9cc74b..fe2e57c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - 基于 Milkdown Crepe 的所见即所得编辑体验 - 支持完整的 Markdown 语法 - 代码块高亮、图片粘贴等功能 -- 导出 Markdown 文件 +- **上传/导出 Markdown 文件**(底部图标按钮) ### 2. 智能行内建议 - 实时监听用户输入 @@ -38,7 +38,7 @@ - **点击建议**:直接插入 ### 3. 性能优化 -- 150ms 防抖机制,避免频繁请求 +- 500ms 防抖机制,避免频繁请求 - 流式传输(SSE),降低延迟 - 上下文智能截取(光标前30行 + 后5行) @@ -148,36 +148,17 @@ data: {"done": true} ## 已知问题 -### 🔴 严重问题(P0) +### 待优化项(P1) -1. ~~模板语法错误~~ - 已修复 - - GhostTextOverlay 组件标签已正确使用尖括号 +1. 代码重复 - fetchSuggestion 逻辑在两个文件中重复 +2. 全局状态污染 - 插件使用模块级全局变量 -2. ~~字符串截取错误~~ - 已修复 - - 代码使用 `slice()` 而非 `substring(-50)` +### 轻微问题(P2) -3. ~~错误处理违反原则~~ - 已修复 - - 获取失败时会抛出错误而非返回空字符串 - -### 🟡 中等问题(P1) - -4. ~~内存泄漏风险~~ - 已修复 - - 组件卸载时已清理 debounceTimer - -5. ~~不可靠的事件绑定~~ - 已修复 - - 使用 500ms 防抖机制 - -6. 代码重复 - fetchSuggestion 逻辑在两个文件中重复 -7. 全局状态污染 - 插件使用模块级全局变量 - -### 🟢 轻微问题(P2) - -8. 大量调试日志影响性能 -9. 缺少完整的类型定义 -10. 没有加载状态指示器 -11. 建议文本无长度限制 -12. API URL 硬编码在前端 -13. 后端缺少 CORS 配置 +3. 大量调试日志影响性能 +4. 缺少完整的类型定义 +5. 建议文本无长度限制 +6. API URL 硬编码在前端 ## 开发指南 diff --git a/backend/main.py b/backend/main.py index 2c6c311..f7c2504 100644 --- a/backend/main.py +++ b/backend/main.py @@ -123,9 +123,8 @@ 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: {{'done': true}}\n\n" + yield f"data: {json.dumps({'done': true})}\n\n" return StreamingResponse(generate(), media_type="text/event-stream") diff --git a/index.html b/index.html index 63eddb7..cba95be 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ llm-in-text +
diff --git a/package-lock.json b/package-lock.json index b9d6676..3e4a510 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2008,13 +2008,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -2513,9 +2513,10 @@ } }, "node_modules/lodash-es": { - "version": "4.17.22", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", - "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" }, "node_modules/longest-streak": { "version": "3.1.0", diff --git a/package.json b/package.json index 61ba80c..8367b5f 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "@milkdown/kit": "^7.18.0", "@milkdown/theme-nord": "^7.18.0", "@milkdown/vue": "^7.18.0", - "axios": "^1.13.2", + "katex": "^0.16.9", "markdown-it": "^13.0.0", + "markdown-it-math": "^3.0.2", "pinia": "^2.3.1", "prismjs": "^1.29.0", "vue": "^3.5.24", diff --git a/plans/inline-suggestions-plan.md b/plans/inline-suggestions-plan.md deleted file mode 100644 index ae124dd..0000000 --- a/plans/inline-suggestions-plan.md +++ /dev/null @@ -1,625 +0,0 @@ -# Inline Autocomplete Suggestions 实现计划 - -## 技术栈确认 -- **前端**: Vue3 + Milkdown Editor -- **后端**: Python FastAPI -- **LLM**: OpenAI API(流式响应) -- **范围**: 基础流式补全,无需复杂缓存机制 - -## 系统架构 - -```mermaid -flowchart TB - subgraph 前端 [Vue3 + Milkdown] - E[Milkdown Editor] - I[InlineSuggestionPlugin
输入监听+防抖] - G[GhostTextOverlay
虚影渲染层] - end - - subgraph 后端 [FastAPI] - API[/v1/completions
补全接口] - P[PromptBuilder
上下文构建] - L[OpenAI Client
LLM调用] - end - - I -- "输入事件" --> G - G -- "POST {prefix, suffix}" --> API - API -- "流式响应" --> G -``` - -## 实现步骤 - -### 1. 前端:创建 Inline Suggestion Plugin -**文件**: `src/plugins/inlineSuggestionPlugin.ts` - -#### 核心实现要点 - -```typescript -import { Plugin, PluginKey } from '@milkdown/prose/state'; -import { EditorView } from '@milkdown/prose/view'; - -const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion'); -const DEBOUNCE_MS = 500; - -interface InlineSuggestionOptions { - apiUrl?: string; - onSuggestion?: (suggestion: string) => void; - onError?: (error: Error) => void; -} - -interface SuggestionState { - suggestion: string; - visible: boolean; - loading: boolean; -} - -function createInlineSuggestionPlugin(options: InlineSuggestionOptions = {}) { - const apiUrl = options.apiUrl || 'http://localhost:8000/v1/completions'; - const onSuggestion = options.onSuggestion || (() => {}); - const onError = options.onError || ((error) => console.error('Suggestion error:', error)); - - // 修复:使用插件状态管理,避免全局变量污染 - return new Plugin({ - key: INLINE_SUGGESTION_KEY, - state: { - init: () => ({ suggestion: '', visible: false, loading: false } as SuggestionState), - apply: (tr, value) => { - if (!tr.docChanged) return value; - const { from, to } = tr.selection; - // 如果光标位置没有变化,保持当前状态 - if (from === value.from && to === value.to) { - return value; - } - // 光标位置变化,重置建议状态 - return { suggestion: '', visible: false, loading: false, from, to }; - }, - }, - props: { - handleKeyDown: (view: EditorView, event: KeyboardEvent) => { - const state = INLINE_SUGGESTION_KEY.getState(view.state) as SuggestionState; - - if (event.key === 'Tab' && state.visible) { - event.preventDefault(); - if (state.suggestion) { - view.dispatch(view.state.tr.insertText(state.suggestion, view.state.selection.from)); - // 重置状态 - view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, { - suggestion: '', - visible: false, - loading: false - })); - return true; - } - } - - if (event.key === 'Escape' && state.visible) { - event.preventDefault(); - view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, { - suggestion: '', - visible: false, - loading: false - })); - return true; - } - - return false; - }, - }, - appendTransaction: (transactions, oldState, newState) => { - const lastTr = transactions[transactions.length - 1]; - if (!lastTr || !lastTr.docChanged) return null; - - const { from, to } = newState.selection; - const prefix = newState.doc.textBetween(0, from); - const suffix = newState.doc.textBetween(to, newState.doc.content.size); - - // 修复:使用插件级别的 debounce 管理 - let debounceTimer: NodeJS.Timeout | null = null; - - clearTimeout(debounceTimer); - debounceTimer = setTimeout(async () => { - try { - // 设置加载状态 - newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, { - suggestion: '', - visible: false, - loading: true - })); - - const text = await fetchSuggestion(apiUrl, prefix, suffix); - - // 检查光标位置是否仍然有效 - const currentState = INLINE_SUGGESTION_KEY.getState(newState) as SuggestionState; - if (currentState.from === from && currentState.to === to) { - newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, { - suggestion: text, - visible: true, - loading: false - })); - onSuggestion(text); - } - } catch (e) { - onError(e as Error); - newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, { - suggestion: '', - visible: false, - loading: false - })); - } - }, DEBOUNCE_MS); - - return null; - }, - }); -} - -// 修复:提取共享的 fetchSuggestion 函数,避免代码重复 -async function fetchSuggestion(apiUrl: string, prefix: string, suffix: string): Promise { - const res = await fetch(apiUrl, { - 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; -} - -export { createInlineSuggestionPlugin, INLINE_SUGGESTION_KEY, fetchSuggestion }; -``` - -### 2. 前端:GhostText 渲染组件 -**文件**: `src/components/GhostTextOverlay.vue` - -#### 核心实现要点 - -```vue - - - - - -``` - -### 3. 修改 MilkdownEditor 集成插件 -**文件**: `src/components/MilkdownEditor.vue` - -#### 集成要点 - -```vue - - - -``` - -### 4. 后端:FastAPI 服务 -**文件**: `backend/main.py` - -#### 核心实现要点 - -```python -from fastapi import FastAPI, HTTPException -from fastapi.responses import StreamingResponse -from fastapi.middleware.cors import CORSMiddleware # 修复:添加 CORS 支持 -from pydantic import BaseModel -import os -import json - -app = FastAPI() - -# 修复:添加 CORS 中间件 -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # 生产环境应该限制具体域名 - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -class CompletionRequest(BaseModel): - prefix: str - suffix: str - languageId: str = 'markdown' - -def generate_stream(request: CompletionRequest): - from prompt import build_prompt - from llm import stream_openai - - try: - prompt = build_prompt(request.prefix, request.suffix) - - async def gen(): - chunk_count = 0 - async for chunk in stream_openai(prompt): - chunk_count += 1 - yield f"data: {chunk}\n\n" - yield "data: {\"done\": true}\n\n" - return gen() - except Exception as e: - # 修复:遵循"获取失败直接报错"原则 - error_msg = f"{{\"error\": \"{str(e)}\"}}" - yield f"data: {error_msg}\n\n" - raise # 重新抛出异常 - -@app.post("/v1/completions") -async def create_completion(request: CompletionRequest): - return StreamingResponse(generate_stream(request), media_type="text/event-stream") - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) -``` - -### 5. 后端:Prompt 构建和 LLM 调用 -**文件**: `backend/prompt.py`, `backend/llm.py` - -#### Prompt 构建 - -```python -import os -from typing import Tuple - -def build_prompt(prefix: str, suffix: str) -> str: - """ - 构建用于代码补全的 Prompt。 - 参考 completions-sample-code 的 extractPrompt 逻辑简化实现。 - """ - MAX_CONTEXT_LINES = 30 - - prefix_lines = prefix.split('\n') - suffix_lines = suffix.split('\n') if suffix else [] - - recent_prefix = '\n'.join(prefix_lines[-MAX_CONTEXT_LINES:]) - recent_suffix = '\n'.join(suffix_lines[:5]) - - prompt = f""" -You are a helpful writing assistant. Continue the text naturally based on the context. - -Context (before cursor): -{recent_prefix} - -Complete this: -{suffix if suffix else '(cursor here)'} - -Continue:""" - - return prompt.strip() -``` - -#### LLM 调用 - -```python -import os -from typing import AsyncGenerator -from openai import AsyncOpenAI -import json - -api_key = os.getenv('OPENAI_API_KEY', 'ollama') -base_url = os.getenv('OLLAMA_BASE_URL', 'http://localhost:11434/v1/') -model = os.getenv('OLLAMA_MODEL', 'gpt-4') - -client = AsyncOpenAI(api_key=api_key, base_url=base_url) - -async def stream_openai(prompt: str) -> AsyncGenerator[str, None]: - """ - 调用 OpenAI/Ollama API 并流式返回补全内容。 - 参考 completions-sample-code 的 streaming 逻辑。 - """ - try: - stream = await client.chat.completions.create( - model=model, - messages=[{"role": "user", "content": prompt}], - stream=True, - max_tokens=128, - temperature=0.2, - ) - - chunk_count = 0 - async for chunk in stream: - if chunk.choices[0].delta.content: - content = chunk.choices[0].delta.content - chunk_count += 1 - yield json.dumps({"content": content}) - - except Exception as e: - # 修复:遵循"获取失败直接报错"原则 - yield json.dumps({"error": str(e)}) - raise # 重新抛出异常 -``` - -## 文件结构 - -``` -llm-in-text/ -├── src/ -│ ├── components/ -│ │ └── MilkdownEditor.vue [修改] -│ ├── plugins/ -│ │ ├── inlineSuggestionPlugin.ts [修改] -│ │ └── types.ts [新建] -│ └── ... -└── backend/ - ├── main.py [修改] - ├── prompt.py [修改] - ├── llm.py [修改] - └── requirements.txt [修改] -``` - -## API 设计 - -### 请求 -```json -POST /v1/completions -{ - "prefix": "# Hello\n\nThis is ", - "suffix": "", - "languageId": "markdown" -} -``` - -### 响应(流式 SSE) -``` -data: {"content": "a "} - -data: {"content": "a te"} - -data: {"content": "a test"} - -data: {"done": true} -``` - -## 已知问题及修复方案 - -### 🔴 严重问题(P0) - -#### 1. 全局状态污染 -**位置**: `inlineSuggestionPlugin.ts:6-8` -**问题**: 使用模块级全局变量,多个编辑器实例会共享状态 -**修复**: 使用 ProseMirror 插件的状态管理机制,每个插件实例维护自己的状态 - -#### 2. 错误处理违反原则 -**位置**: `llm.py:42-44`, `main.py:34-37` -**问题**: 错误时只返回错误信息,不抛出异常 -**修复**: 遵循"获取失败直接报错"原则,在 yield 错误信息后重新抛出异常 - -### 🟡 中等问题(P1) - -#### 3. 代码重复 -**问题**: `fetchSuggestion` 逻辑在两个文件中重复 -**修复**: 提取共享的 `fetchSuggestion` 函数,在插件和编辑器组件中复用 - -#### 4. 缺少 CORS 配置 -**问题**: 后端没有配置 CORS,可能导致跨域请求失败 -**修复**: 在 FastAPI 中添加 CORS 中间件 - -#### 5. 建议文本无长度限制 -**问题**: 建议文本可能过长,影响显示效果 -**修复**: 在 GhostTextOverlay 组件中添加 `maxLength` prop,截断过长的建议 - -### 🟢 轻微问题(P2) - -#### 6. 缺少加载状态 -**问题**: 用户无法知道是否正在获取建议 -**修复**: 在插件状态中添加 `loading` 字段,在 UI 中显示加载指示器 - -#### 7. 缺少类型定义 -**问题**: TypeScript 代码中缺少完整的类型定义 -**修复**: 添加 `SuggestionState` 接口和完整的类型定义 - -#### 8. API URL 硬编码 -**问题**: API URL 硬编码在前端代码中 -**修复**: 使用环境变量 `VITE_API_URL` 配置 API URL - -## 参考代码映射 - -| completions-sample-code | 本项目实现 | -|------------------------|-----------| -| `ghostText.ts getGhostText()` | 后端 LLM 调用逻辑 | -| `inlineCompletion.ts GhostText` | 前端 Plugin 核心逻辑 | -| `networking.ts postRequest()` | 后端 API 接口 | -| `prompt/extractPrompt()` | 后端 Prompt 构建 | - -## 最佳实践 - -### 错误处理 -- 遵循"获取失败直接报错"原则 -- 不返回默认值,不尝试隐藏报错信息 -- 在前端和后端都实现完整的错误处理 - -### 性能优化 -- 使用 150ms 防抖,避免频繁请求 -- 流式传输(SSE),降低延迟 -- 及时清理定时器和事件监听器 - -### 代码质量 -- 避免全局变量,使用插件状态管理 -- 提取共享逻辑,避免代码重复 -- 添加完整的类型定义 -- 移除调试日志或条件化输出 - -## 下一步 -确认计划后切换到 Code 模式开始实现。 diff --git a/plans/milkdown-editor-plan.md b/plans/milkdown-editor-plan.md deleted file mode 100644 index 2bb582f..0000000 --- a/plans/milkdown-editor-plan.md +++ /dev/null @@ -1,396 +0,0 @@ -# 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
建议文本显示] - J[InlineSuggestionPlugin
智能补全] - end - - C --> I - C --> J -``` - -## 实施步骤 - -### Step 1: 安装依赖包 - -```bash -npm install @milkdown/crepe @milkdown/vue -``` - -### Step 2: 创建 Milkdown 编辑器组件 - -**文件**: `src/components/MilkdownEditor.vue` - -#### 核心实现要点 - -```vue - - - - - -``` - -### 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. 模板语法错误~~ ✅ 已修复 -- GhostTextOverlay 组件标签已正确使用尖括号 - -~~2. 字符串截取错误~~ ✅ 已修复 -- 代码使用 `slice()` 而非 `substring(-50)` - -~~3. 错误处理违反原则~~ ✅ 已修复 -- 获取失败时会抛出错误而非返回空字符串 - -### 🟡 中等问题(P1) - -~~4. 内存泄漏风险~~ ✅ 已修复 -- 组件卸载时已清理 debounceTimer - -~~5. 不可靠的事件绑定~~ ✅ 已修复 -- 使用 500ms 防抖机制 - -6. 代码重复 -- fetchSuggestion 逻辑在两个文件中重复 - -7. 全局状态污染 -- 插件使用模块级全局变量 - -### 🟢 轻微问题(P2) - -8. 大量调试日志影响性能 -9. 缺少完整的类型定义 -10. 没有加载状态指示器 -11. 建议文本无长度限制 -12. API URL 硬编码在前端 -13. 后端缺少 CORS 配置 - -## 全屏覆盖样式要点 - -- 编辑器容器: `width: 100vw; height: 100vh` -- 移除默认 padding/margin -- 纯编辑器模式,无预览面板 -- 自定义滚动条样式 - -## 性能优化建议 - -1. **防抖优化**: 保持 500ms 防抖,避免频繁请求 -2. **流式响应**: 使用 SSE 流式传输,降低延迟 -3. **上下文截取**: 智能截取上下文(光标前30行 + 后5行) -4. **内存管理**: 及时清理定时器和事件监听器 -5. **代码精简**: 移除冗余代码和注释 - -## 测试要点 - -1. 编辑器基本功能测试 -2. 建议功能测试(Tab 接受、Esc 取消、点击接受) -3. 错误处理测试(网络错误、API 错误) -4. 性能测试(大量文本输入) -5. 内存泄漏测试(长时间使用) diff --git a/src/components/GhostTextOverlay.vue b/src/components/GhostTextOverlay.vue index 5ce31ef..2a35c8b 100644 --- a/src/components/GhostTextOverlay.vue +++ b/src/components/GhostTextOverlay.vue @@ -1,7 +1,6 @@ @@ -9,39 +8,37 @@ 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' - } + 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_SUGGESTION_LENGTH = 200 +const MAX_LENGTH = 200 -const visible = computed(() => props.suggestion && props.position) +const visible = computed(() => props.suggestion && props.suggestion.length > 0) -const truncatedSuggestion = computed(() => { - if (props.suggestion.length > MAX_SUGGESTION_LENGTH) { - return props.suggestion.slice(0, MAX_SUGGESTION_LENGTH) + '...' - } - return props.suggestion +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, + 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') @@ -49,12 +46,12 @@ const acceptSuggestion = () => emit('accept') diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index 546ebbc..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,43 +0,0 @@ - - - - - diff --git a/src/components/MarkdownEditor.vue b/src/components/MarkdownEditor.vue deleted file mode 100644 index b476b56..0000000 --- a/src/components/MarkdownEditor.vue +++ /dev/null @@ -1,642 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/components/MarkdownPreview.vue b/src/components/MarkdownPreview.vue new file mode 100644 index 0000000..3c286a1 --- /dev/null +++ b/src/components/MarkdownPreview.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/src/components/MilkdownEditor.vue b/src/components/MilkdownEditor.vue index 004ac8a..69f5352 100644 --- a/src/components/MilkdownEditor.vue +++ b/src/components/MilkdownEditor.vue @@ -2,17 +2,7 @@
- - -
- -
- -
正在获取建议...
- diff --git a/src/plugins/inlineSuggestionPlugin.ts b/src/plugins/inlineSuggestionPlugin.ts index e15c3d4..f4cfdb1 100644 --- a/src/plugins/inlineSuggestionPlugin.ts +++ b/src/plugins/inlineSuggestionPlugin.ts @@ -1,96 +1,124 @@ -import { Plugin, PluginKey } from '@milkdown/prose/state'; -import { EditorView } from '@milkdown/prose/view'; -import { fetchSuggestion } from '../utils/api.js'; -import { DEBUG, API_URL } from '../utils/config.js'; +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 = 150; - -interface InlineSuggestionOptions { - apiUrl?: string; -} +const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion') +const DEBOUNCE_MS = 300 interface InlineSuggestionState { - suggestion: string; - visible: boolean; - debounceTimer: ReturnType | null; - currentSuggestion: string; - suggestionPos: { from: number; to: number }; + suggestion: string + suggestionPos: { from: number; to: number } } -function createInlineSuggestionPlugin(options: InlineSuggestionOptions = {}) { - const apiUrl = options.apiUrl || API_URL; +function createGhostTextDecoration(from: number, to: number, text: string) { + return Decoration.inline(from, to, { + class: 'ghost-text-decoration', + 'data-suggestion': text, + }, { side: 1 }) +} - return new Plugin({ - key: INLINE_SUGGESTION_KEY, - state: { - init: () => ({ - suggestion: '', - visible: false, - debounceTimer: null, - currentSuggestion: '', - suggestionPos: { from: 0, to: 0 } - }), - apply: (tr, value) => { - if (!tr.docChanged) return value; - const { from, to } = tr.selection; - if (from === value.suggestionPos.from && to === value.suggestionPos.to) { - return value; - } - return { ...value, suggestion: '', visible: false }; - }, - }, - props: { - handleKeyDown: (view: EditorView, event: KeyboardEvent) => { - const state = INLINE_SUGGESTION_KEY.getState(view.state); - if (event.key === 'Tab' && state.visible) { - event.preventDefault(); - if (state.suggestion) { - view.dispatch(view.state.tr.insertText(state.suggestion, view.state.selection.from)); - return true; - } - } - if (event.key === 'Escape') { - if (state.visible) { - view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, { ...state, suggestion: '', visible: false })); - return true; - } - } - return false; - }, - }, - appendTransaction: (transactions, oldState, newState) => { - const lastTr = transactions[transactions.length - 1]; - if (!lastTr || !lastTr.docChanged) return null; +export function createInlineSuggestionPlugin(options: { apiUrl?: string } = {}) { + const apiUrl = options.apiUrl || API_URL - const currentState = INLINE_SUGGESTION_KEY.getState(newState); + 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 } + } - clearTimeout(currentState.debounceTimer); - currentState.debounceTimer = setTimeout(async () => { - const { from, to } = newState.selection; - const prefix = newState.doc.textBetween(0, from); - const suffix = newState.doc.textBetween(to, newState.doc.content.size); + newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, newPluginState)) + } + } catch (e) { + console.error('Inline suggestion error:', e) + } + }, DEBOUNCE_MS) - try { - const text = await fetchSuggestion(prefix, suffix, apiUrl); - - if (text && newState.selection.from === from) { - newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, { - ...currentState, - currentSuggestion: text, - suggestionPos: { from, to: from + text.length }, - suggestion: text, - visible: true - })); - } - } catch (e) { - if (DEBUG) console.error('Inline suggestion error:', e); - } - }, DEBOUNCE_MS); - - return null; - }, - }); + return null + }, + }) } -export { createInlineSuggestionPlugin, INLINE_SUGGESTION_KEY }; +export { INLINE_SUGGESTION_KEY } diff --git a/src/utils/api.js b/src/utils/api.js index 53a03dd..cc4cb23 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -1,52 +1,30 @@ -import { DEBUG, API_URL } from './config.js' +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' }), + }) -export async function fetchSuggestion(prefix, suffix, 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' }), - }) + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } - 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}`) - } + const reader = res.body?.getReader() + if (!reader) throw new Error('No reader available') - const reader = res.body?.getReader() - if (!reader) { - if (DEBUG) console.log('[Debug] No reader available') - throw new Error('No reader available') - } - - let text = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - const chunk = new TextDecoder().decode(value) - if (DEBUG) console.log('[Debug] Received chunk:', chunk.substring(0, 100)) - - 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 (DEBUG) console.log('[Debug] Added content:', data.content) - } - if (data.done || data.error) break - } catch (e) { - if (DEBUG) console.warn('[Debug] JSON parse error:', e) - } - } - } - - if (DEBUG) console.log('[Debug] Final suggestion text:', text.substring(0, 100)) - return text - } catch (e) { - if (DEBUG) console.error('[Debug] fetchSuggestion error:', e) - throw e + 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) {} } + } + return text }