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)
This commit is contained in:
2026-02-07 08:53:37 +08:00
committed by “ydy0615”
parent 5f00e71ceb
commit 2abf276d10
17 changed files with 1564 additions and 404 deletions
+10
View File
@@ -0,0 +1,10 @@
VITE_API_URL=http://localhost:8000/v1/completions
# Ollama 配置
OLLAMA_HOST=http://192.168.0.120:11434
OLLAMA_MODEL=gpt-oss:120b
# 可选:其他配置
# 如果ollama需要认证,可以使用以下变量
# OLLAMA_USERNAME=your_username
# OLLAMA_PASSWORD=your_password
+208 -3
View File
@@ -1,5 +1,210 @@
# Vue 3 + Vite
# LLM in Text - 智能写作助手
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
基于 Vue3 和 FastAPI 的智能写作助手,实现类似 GitHub Copilot 的 inline suggestions(行内建议)功能。
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
## 项目概述
本项目是一个全屏 Markdown 编辑器,集成了大语言模型(LLM)的智能补全功能。当用户输入时,系统会根据上下文实时提供文本补全建议,用户可以通过 Tab 键接受建议或点击建议文本直接插入。
## 技术栈
### 前端
- **Vue 3** - 渐进式 JavaScript 框架
- **Vite** - 下一代前端构建工具
- **Milkdown** - 基于 ProseMirror 的 WYSIWYG Markdown 编辑器
- **Pinia** - Vue 状态管理
- **Axios** - HTTP 客户端
### 后端
- **FastAPI** - 现代化的 Python Web 框架
- **OpenAI API** - 大语言模型接口
- **Ollama** - 本地 LLM 服务支持
## 核心功能
### 1. 全屏 Markdown 编辑器
- 基于 Milkdown Crepe 的所见即所得编辑体验
- 支持完整的 Markdown 语法
- 代码块高亮、图片粘贴等功能
- 导出 Markdown 文件
### 2. 智能行内建议
- 实时监听用户输入
- 基于上下文(光标前后文本)生成补全建议
- 流式响应,实时显示建议内容
- 支持多种交互方式:
- **Tab 键**:接受建议
- **Esc 键**:取消建议
- **点击建议**:直接插入
### 3. 性能优化
- 150ms 防抖机制,避免频繁请求
- 流式传输(SSE),降低延迟
- 上下文智能截取(光标前30行 + 后5行)
## 项目结构
```
llm-in-text/
├── src/
│ ├── components/
│ │ ├── MilkdownEditor.vue # 主编辑器组件
│ │ ├── GhostTextOverlay.vue # 建议文本显示组件
│ │ └── MarkdownEditor.vue # 备用编辑器
│ ├── plugins/
│ │ ├── inlineSuggestionPlugin.ts # 行内建议插件
│ │ └── types.ts # 类型定义
│ ├── router/
│ │ └── index.js # 路由配置
│ ├── store/
│ │ └── index.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
└── 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` 文件中配置:
```env
OPENAI_API_KEY=your_api_key_here
OLLAMA_BASE_URL=http://localhost:11434/v1/
OLLAMA_MODEL=gpt-4
```
### 启动服务
**启动后端:**
```bash
cd backend
python main.py
```
**启动前端:**
```bash
npm run dev
```
访问 `http://localhost:5173` 开始使用。
## API 接口
### POST /v1/completions
获取文本补全建议(流式响应)
**请求体:**
```json
{
"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. **模板语法错误** - [`MilkdownEditor.vue:7-13`](src/components/MilkdownEditor.vue:7-13)
- GhostTextOverlay 组件标签缺少尖括号
- 导致建议功能完全失效
2. **字符串截取错误** - [`MilkdownEditor.vue:155`](src/components/MilkdownEditor.vue:155)
- `prefix.substring(-50)` 应该改为 `prefix.slice(-50)`
3. **错误处理违反原则** - [`MilkdownEditor.vue:92-94`](src/components/MilkdownEditor.vue:92-94)
- 请求失败时返回空字符串而不是抛出错误
- 违反了"获取失败直接报错"的原则
### 🟡 中等问题(P1
4. **内存泄漏风险** - 组件卸载时未清理 debounceTimer
5. **不可靠的事件绑定** - 使用硬编码的 500ms 延迟
6. **代码重复** - fetchSuggestion 逻辑在两个文件中重复
7. **全局状态污染** - 插件使用模块级全局变量
### 🟢 轻微问题(P2
8. 大量调试日志影响性能
9. 缺少完整的类型定义
10. 没有加载状态指示器
11. 建议文本无长度限制
12. API URL 硬编码在前端
13. 后端缺少 CORS 配置
## 开发指南
### 代码规范
- **前端**:遵循 Vue 3 Composition API 最佳实践
- **后端**:遵循 FastAPI 异步编程模式
- **错误处理**:获取失败直接报错,不返回默认值
- **性能优化**:优先考虑降低延迟,避免冗余代码
### 调试
前端使用浏览器开发者工具,后端查看控制台输出。所有关键操作都有日志记录。
## 贡献指南
欢迎提交 Issue 和 Pull Request。在提交代码前,请确保:
1. 代码通过 ESLint 检查
2. 所有测试通过
3. 添加必要的注释和文档
4. 遵循项目的代码规范
## 许可证
MIT License
## 致谢
- [Milkdown](https://milkdown.dev/) - 优秀的 Markdown 编辑器框架
- [FastAPI](https://fastapi.tiangolo.com/) - 现代化的 Python Web 框架
- [OpenAI](https://openai.com/) - 大语言模型 API
+1 -1
View File
@@ -1,3 +1,3 @@
OPENAI_API_KEY=ollama
OLLAMA_BASE_URL=http://100.124.143.24:11434/v1/
OLLAMA_HOST=http://192.168.0.120:11434
OLLAMA_MODEL=gpt-oss:120b
Binary file not shown.
Binary file not shown.
+30 -47
View File
@@ -1,69 +1,52 @@
import os
from typing import AsyncGenerator
from openai import AsyncOpenAI
import json
import time
import ollama
from typing import AsyncGenerator
api_key = os.getenv('OPENAI_API_KEY', 'ollama')
base_url = os.getenv('OLLAMA_BASE_URL', 'http://192.168.0.120:11434/v1/')
model = os.getenv('OLLAMA_MODEL', 'gpt-oss:120b')
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:120b')
OLLAMA_HOST = os.getenv('OLLAMA_BASE_URL', 'http://192.168.0.120:11434')
print(f"[LLM] API key configured: {'Yes' if api_key else 'No'}")
print(f"[LLM] Base URL: {base_url}")
print(f"[LLM] Model: {model}")
# 移除 /v1/ 后缀(如果有的话),因为 Ollama Python 包使用原生 API
if OLLAMA_HOST.endswith('/v1/'):
OLLAMA_HOST = OLLAMA_HOST[:-4]
elif OLLAMA_HOST.endswith('/v1'):
OLLAMA_HOST = OLLAMA_HOST[:-3]
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
os.environ['OLLAMA_HOST'] = OLLAMA_HOST
print(f"[LLM] Ollama host: {OLLAMA_HOST}")
print(f"[LLM] Model: {OLLAMA_MODEL}")
client = ollama.AsyncClient(host=OLLAMA_HOST)
async def stream_openai(prompt: str) -> AsyncGenerator[str, None]:
"""
调用 OpenAI/Ollama API 并流式返回补全内容。
参考 completions-sample-code 的 streaming 逻辑。
"""
start_time = time.time()
print(f"[LLM] ========== API Call Start ==========")
print(f"[LLM] Prompt length: {len(prompt)}")
print(f"[LLM] Model: {model}")
print(f"[LLM] Calling Ollama API with prompt length: {len(prompt)}")
try:
print(f"[LLM] Creating streaming chat completion...")
stream = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
print(f"[LLM] Awaiting client.chat...")
stream = await client.chat(
model=OLLAMA_MODEL,
messages=[{'role': 'user', 'content': prompt}],
stream=True,
max_tokens=128,
temperature=0.2,
options={
'num_predict': 8192,
'temperature': 0.2,
}
)
print(f"[LLM] Got stream object, starting iteration...")
print(f"[LLM] Stream created successfully, iterating...")
chunk_count = 0
first_chunk_time = None
async for chunk in stream:
current_time = time.time()
if first_chunk_time is None:
first_chunk_time = current_time - start_time
if chunk['message'] and chunk['message']['content']:
content = chunk['message']['content']
chunk_count += 1
choice = chunk.choices[0] if chunk.choices else None
if choice and choice.delta.content:
content = choice.delta.content
print(f"[LLM] Chunk {chunk_count}: '{content}' (latency: {current_time - start_time:.3f}s)")
print(f"[LLM] Chunk {chunk_count}: {content}")
yield json.dumps({"content": content})
elif chunk.choices and hasattr(chunk.choices[0], 'finish_reason'):
finish_reason = chunk.choices[0].finish_reason
print(f"[LLM] Chunk {chunk_count}: finish_reason={finish_reason}")
if finish_reason:
break
else:
print(f"[LLM] Chunk {chunk_count}: empty or no content")
total_time = time.time() - start_time
print(f"[LLM] Stream complete - chunks: {chunk_count}, first chunk latency: {first_chunk_time:.3f}s, total time: {total_time:.3f}s")
print(f"[LLM] ========== API Call End ==========")
print(f"[LLM] Stream complete, total chunks: {chunk_count}")
except Exception as e:
error_msg = f"Error: {str(e)}"
print(f"[LLM] Error: {error_msg}")
import traceback
traceback.print_exc()
yield json.dumps({"error": str(e), "type": type(e).__name__})
yield json.dumps({"error": str(e)})
+118 -51
View File
@@ -1,75 +1,142 @@
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel
import os
import json
import time
import re
app = FastAPI()
print("[Main] Backend service starting...")
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
def extract_completion_from_thinking(thinking: str) -> str:
"""
从模型的 thinking 输出中提取实际的续写内容。
移除推理过程,保留实际的续写。
"""
if not thinking:
return ""
start_time = time.time()
print(f"[Main] ========== New Request ==========")
print(f"[Main] prefix length: {len(request.prefix)}, suffix length: {len(request.suffix)}")
print(f"[Main] languageId: {request.languageId}")
print(f"[Main] Prefix (last 200 chars): '{request.prefix[-200:]}'")
print(f"[Main] Suffix (first 200 chars): '{request.suffix[:200]}'")
# 尝试找到实际的续写内容
# 模型通常会在 thinking 中描述上下文,然后输出实际续写
# 常见的模式是:推理过程以描述开始,然后直接输出续写
try:
prompt = build_prompt(request.prefix, request.suffix)
print(f"[Main] Built prompt length: {len(prompt)}")
print(f"[Main] Prompt (first 300 chars): '{prompt[:300]}'")
print(f"[Main] Prompt (last 200 chars): '{prompt[-200:]}'")
# 查找 "Continuation:" 或类似标记之后的内容
continuation_match = re.search(r'Continuation[:\s]*([\s\S]*)', thinking, re.IGNORECASE)
if continuation_match:
result = continuation_match.group(1).strip()
# 移除可能的后续推理说明
result = re.sub(r'\s*It seems like.*$', '', result, flags=re.IGNORECASE)
return result.strip()
async def gen():
chunk_count = 0
first_chunk_time = None
try:
async for chunk in stream_openai(prompt):
current_time = time.time()
if first_chunk_time is None:
first_chunk_time = current_time - start_time
chunk_count += 1
chunk_data = json.loads(chunk) if isinstance(chunk, str) else chunk
content_preview = chunk_data.get('content', '')[:50] if chunk_data.get('content') else ''
print(f"[Main] Chunk {chunk_count}: '{content_preview}'...")
yield f"data: {json.dumps(chunk_data)}\n\n"
# 如果没有明确标记,尝试移除描述性内容
# 查找 "We need to continue" 或类似开头
continue_match = re.search(r'(?:We need to|Then we should|So we|I will|The|Thus)[,\s]+([A-Z][^.!?]*(?:[.!?]|$))', thinking)
if continue_match:
# 取找到的句子及其后续内容
start_idx = continue_match.start(1)
result = thinking[start_idx:].strip()
# 移除 "Probably " 开头及其后续内容
result = re.sub(r'^Probably\s+', '', result)
# 如果有 "It seems like" 或类似短语,截断
result = re.split(r'\s*It seems like\s', result, flags=re.IGNORECASE)[0]
return result.strip()
done_signal = {"done": True}
total_time = time.time() - start_time
print(f"[Main] Stream complete - total chunks: {chunk_count}, first chunk at: {first_chunk_time:.2f}s, total time: {total_time:.2f}s")
yield f"data: {json.dumps(done_signal)}\n\n"
except Exception as e:
error_msg = {"error": str(e), "type": type(e).__name__}
print(f"[Main] Generator error: {e}")
yield f"data: {json.dumps(error_msg)}\n\n"
return gen()
except Exception as e:
error_msg = {"error": str(e), "type": type(e).__name__}
print(f"[Main] Error building prompt or calling LLM: {e}")
yield f"data: {json.dumps(error_msg)}\n\n"
# 最后的策略:直接返回 thinking,移除末尾的推理说明
result = thinking.strip()
# 移除 "Probably" 及其后续内容
result = re.split(r'\s+Probably\s', result, flags=re.IGNORECASE, maxsplit=1)[0]
# 移除 "The instruction:" 及其后续内容
result = re.split(r'\s+The instruction:', result, flags=re.IGNORECASE, maxsplit=1)[0]
return result.strip()
@app.post("/v1/completions")
async def create_completion(request: CompletionRequest):
print(f"[Main] POST /v1/completions called at {time.time()}")
return StreamingResponse(generate_stream(request), media_type="text/event-stream")
from prompt import build_prompt
import ollama
@app.get("/health")
async def health_check():
return {"status": "healthy", "timestamp": time.time()}
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_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
print(f"[LLM] Using host: {OLLAMA_HOST}, model: {OLLAMA_MODEL}")
try:
prompt = build_prompt(request.prefix, request.suffix)
print(f"[Backend] Built prompt (first 100 chars): {prompt[:100]}...")
print(f"[LLM] Full prompt:\n{prompt}\n")
# 使用非流式 API 获取完整响应
print(f"[LLM] Calling Ollama API (non-streaming)...")
client = ollama.AsyncClient(host=OLLAMA_HOST)
response = await client.chat(
model=OLLAMA_MODEL,
messages=[{'role': 'user', 'content': prompt}],
stream=False,
options={
'num_predict': 8192,
'temperature': 0.2,
}
)
print(f"[LLM] Response type: {type(response)}")
# 提取 content 和 thinking
content = ""
thinking = ""
if hasattr(response, 'message') and response.message:
content = response.message.content or ""
thinking = getattr(response.message, 'thinking', '') or ""
elif isinstance(response, dict):
msg = response.get('message', {})
content = msg.get('content', '') or ""
thinking = msg.get('thinking', '') or ""
print(f"[LLM] Original content: {repr(content[:100] if content else '')}...")
print(f"[LLM] Thinking length: {len(thinking)}")
print(f"[LLM] Thinking (first 200): {thinking[:200]}...")
# 如果 content 为空,尝试从 thinking 中提取
if not content and thinking:
print(f"[LLM] Content is empty, extracting from thinking...")
content = extract_completion_from_thinking(thinking)
print(f"[LLM] Extracted completion: {repr(content[:100])}...")
print(f"[LLM] Final content length: {len(content)}")
# 返回完整内容
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"
return StreamingResponse(generate(), media_type="text/event-stream")
except Exception as e:
error_msg = f"{{\"error\": \"{str(e)}\"}}"
print(f"[Backend] Error: {e}")
import traceback
traceback.print_exc()
return JSONResponse(content={"error": str(e)}, status_code=500)
if __name__ == "__main__":
import uvicorn
port = int(os.getenv('PORT', 8000))
print(f"[Main] Starting server on http://0.0.0.0:{port}")
uvicorn.run(app, host="0.0.0.0", port=port)
print("[Backend] Starting server on http://0.0.0.0:8000")
uvicorn.run(app, host="0.0.0.0", port=8000)
+15 -9
View File
@@ -3,8 +3,8 @@ from typing import Tuple
def build_prompt(prefix: str, suffix: str) -> str:
"""
构建用于代码补全的 Prompt
参考 completions-sample-code 的 extractPrompt 逻辑简化实现
改进后的提示词构建函数
使用更明确的指令来引导模型生成高质量的续写内容
"""
MAX_CONTEXT_LINES = 30
@@ -14,15 +14,21 @@ def build_prompt(prefix: str, suffix: str) -> str:
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.
prompt = f"""You are an expert writing assistant. Continue the text naturally.
Context (before cursor):
{recent_prefix}
CONTEXT:
⟨CURSOR⟩ marks where to continue.
- Before ⟨CURSOR⟩: existing text
- After ⟨CURSOR⟩: following context (if any)
Complete this:
{suffix if suffix else '(cursor here)'}
RULES:
- Match existing style, tone, and terminology
- Maintain logical flow
- Write only the continuation, nothing else
Continue:"""
TEXT:
{recent_prefix}⟨CURSOR⟩{recent_suffix}
CONTINUATION:"""
return prompt.strip()
+2 -1
View File
@@ -1,5 +1,6 @@
fastapi
uvicorn
openai
ollama
pydantic
python-dotenv
httpx
-9
View File
@@ -353,7 +353,6 @@
"version": "6.12.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -425,7 +424,6 @@
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -445,7 +443,6 @@
"version": "6.39.11",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz",
"integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -3414,7 +3411,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3567,7 +3563,6 @@
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -3598,7 +3593,6 @@
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -3629,7 +3623,6 @@
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz",
"integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -3966,7 +3959,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -4041,7 +4033,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
+535 -21
View File
@@ -31,32 +31,485 @@ flowchart TB
### 1. 前端:创建 Inline Suggestion Plugin
**文件**: `src/plugins/inlineSuggestionPlugin.ts`
- 监听编辑器输入事件
- 防抖处理(150ms
- 调用后端 API 获取补全建议
- 管理 GhostText 显示状态
#### 核心实现要点
```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 = 150;
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<string> {
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` 或内联样式
- 在光标位置显示灰色虚影文本
- 处理 Tab 键接受补全
- ESC 键取消显示
**文件**: `src/components/GhostTextOverlay.vue`
#### 核心实现要点
```vue
<template>
<div v-if="visible" class="ghost-text-overlay" :style="overlayStyle"
@click="acceptSuggestion"
>
{{ truncatedSuggestion }}
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
suggestion: { type: String, default: '' },
position: { type: Object, required: true },
maxLength: { type: Number, default: 200 }, // 修复:添加建议文本长度限制
})
const emit = defineEmits(['accept', 'dismiss'])
const visible = computed(() => props.suggestion && props.position)
// 修复:截断过长的建议文本
const truncatedSuggestion = computed(() => {
if (props.suggestion.length > props.maxLength) {
return props.suggestion.slice(0, props.maxLength) + '...'
}
return props.suggestion
})
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>
```
### 3. 修改 MilkdownEditor 集成插件
**文件**: `src/components/MilkdownEditor.vue`
- 注册 InlineSuggestionPlugin 到 Crepe 实例
- 配置 API 地址
#### 集成要点
```vue
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { Crepe } from '@milkdown/crepe'
import GhostTextOverlay from './GhostTextOverlay.vue'
import { createInlineSuggestionPlugin } from '../plugins/inlineSuggestionPlugin'
const root = ref(null)
const containerRef = ref(null)
let crepe = null
const suggestion = ref('')
const cursorRect = ref(null)
const loading = ref(false)
// 修复:使用环境变量配置 API URL
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/v1/completions'
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()
// 注册 Inline Suggestion Plugin
const plugin = createInlineSuggestionPlugin({
apiUrl: API_URL,
onSuggestion: (text) => {
suggestion.value = text
updateCursorPosition()
},
onError: (error) => {
console.error('Suggestion error:', error)
suggestion.value = ''
}
})
crepe.ctx.get().updateState((state) => {
return state.reconfigure({
plugins: [...state.plugins, plugin]
})
})
})
// 修复:组件卸载时清理资源
onUnmounted(() => {
if (crepe) {
crepe.destroy()
}
})
const updateCursorPosition = async () => {
if (!crepe) return
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
cursorRect.value = {
left: coords.left - containerRect.left,
top: coords.top - containerRect.top + window.scrollY,
fontSize: 16,
fontFamily: 'monospace',
}
} catch (e) {
console.error('updateCursorPosition error:', e)
}
}
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 dismissSuggestion = () => {
suggestion.value = ''
}
</script>
<template>
<div class="editor-container" ref="containerRef">
<div ref="root" class="milkdown-editor"></div>
<!-- 修复正确的组件标签语法 -->
<GhostTextOverlay
v-if="suggestion && cursorRect"
:suggestion="suggestion"
:position="cursorRect"
@accept="acceptSuggestion"
@dismiss="dismissSuggestion"
/>
</div>
</template>
```
### 4. 后端:FastAPI 服务
**文件**: `backend/main.py`
- POST `/v1/completions` 流式接口
- 请求体验证和解析
#### 核心实现要点
```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(参考 completions-sample-code 的 extractPrompt
- OpenAI API 流式调用
- 返回 SSE 格式响应
#### 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 # 重新抛出异常
```
## 文件结构
@@ -66,13 +519,14 @@ llm-in-text/
│ ├── components/
│ │ └── MilkdownEditor.vue [修改]
│ ├── plugins/
│ │ ── inlineSuggestionPlugin.ts [新建]
│ │ ── inlineSuggestionPlugin.ts [修改]
│ │ └── types.ts [新建]
│ └── ...
└── backend/
├── main.py [新建]
├── prompt.py [新建]
├── llm.py [新建]
└── requirements.txt [新建]
├── main.py [修改]
├── prompt.py [修改]
├── llm.py [修改]
└── requirements.txt [修改]
```
## API 设计
@@ -95,9 +549,51 @@ data: {"content": "a te"}
data: {"content": "a test"}
data: [DONE]
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 | 本项目实现 |
@@ -107,5 +603,23 @@ data: [DONE]
| `networking.ts postRequest()` | 后端 API 接口 |
| `prompt/extractPrompt()` | 后端 Prompt 构建 |
## 最佳实践
### 错误处理
- 遵循"获取失败直接报错"原则
- 不返回默认值,不尝试隐藏报错信息
- 在前端和后端都实现完整的错误处理
### 性能优化
- 使用 150ms 防抖,避免频繁请求
- 流式传输(SSE),降低延迟
- 及时清理定时器和事件监听器
### 代码质量
- 避免全局变量,使用插件状态管理
- 提取共享逻辑,避免代码重复
- 添加完整的类型定义
- 移除调试日志或条件化输出
## 下一步
确认计划后切换到 Code 模式开始实现。
+354 -5
View File
@@ -24,6 +24,14 @@ graph TB
G[代码块高亮]
H[图片粘贴支持]
end
subgraph "集成功能"
I[GhostTextOverlay<br/>建议文本显示]
J[InlineSuggestionPlugin<br/>智能补全]
end
C --> I
C --> J
```
## 实施步骤
@@ -38,19 +46,280 @@ npm install @milkdown/crepe @milkdown/vue
**文件**: `src/components/MilkdownEditor.vue`
#### 核心实现要点
```vue
<template>
<MilkdownProvider>
<Milkdown />
</MilkdownProvider>
<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 { Milkdown, MilkdownProvider, useEditor } from '@milkdown/vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { Crepe } from '@milkdown/crepe'
import GhostTextOverlay from './GhostTextOverlay.vue'
const { get } = useEditor((root) => new Crepe({ root }))
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
@@ -67,8 +336,88 @@ 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. 内存泄漏测试(长时间使用)
+15 -26
View File
@@ -1,43 +1,35 @@
<template>
<div v-if="visible" class="ghost-text-overlay" :style="overlayStyle"
@click="acceptSuggestion"
>{{ suggestion }}
>{{ truncatedSuggestion }}
</div>
</template>
<script setup>
import { computed } from 'vue'
import { onMounted, onUnmounted, watch } from 'vue'
const props = defineProps({
suggestion: { type: String, default: '' },
position: { type: Object, required: true },
position: {
type: Object,
required: true,
validator: (value) => typeof value.left === 'number' && typeof value.top === 'number'
}
})
const emit = defineEmits(['accept', 'dismiss'])
onMounted(() => {
console.log('[GhostTextOverlay] Component mounted')
if (props.suggestion && props.position) {
console.log('[GhostTextOverlay] Suggestion visible:', props.suggestion.substring(0, 50))
console.log('[GhostTextOverlay] Position:', JSON.stringify(props.position))
}
})
onUnmounted(() => {
console.log('[GhostTextOverlay] Component unmounted')
})
watch([() => props.suggestion, () => props.position], ([newSuggestion, newPosition]) => {
console.log('[GhostTextOverlay] Props changed:', {
suggestionLength: newSuggestion?.length || 0,
hasPosition: !!newPosition,
positionKeys: newPosition ? Object.keys(newPosition) : []
})
}, { immediate: true })
const MAX_SUGGESTION_LENGTH = 200
const visible = computed(() => props.suggestion && props.position)
const truncatedSuggestion = computed(() => {
if (props.suggestion.length > MAX_SUGGESTION_LENGTH) {
return props.suggestion.slice(0, MAX_SUGGESTION_LENGTH) + '...'
}
return props.suggestion
})
const overlayStyle = computed(() => ({
position: 'absolute',
left: `${props.position.left}px`,
@@ -52,10 +44,7 @@ const overlayStyle = computed(() => ({
zIndex: 1000,
}))
const acceptSuggestion = () => {
console.log('[GhostTextOverlay] acceptSuggestion called')
emit('accept')
}
const acceptSuggestion = () => emit('accept')
</script>
<style scoped>
+161 -109
View File
@@ -4,178 +4,211 @@
<div ref="root" class="milkdown-editor"></div>
GhostTextOverlay
<GhostTextOverlay
v-if="suggestion && cursorRect"
:suggestion="suggestion"
:position="cursorRect"
@accept="acceptSuggestion"
@dismiss="dismissSuggestion"
/GhostTextOverlay
/>
<div v-if="isLoading" class="loading-indicator">正在获取建议...</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Crepe, rootCtx, defaultValueCtx } from '@milkdown/crepe'
import { onMounted, onUnmounted, ref } from 'vue'
import { Crepe } from '@milkdown/crepe'
import GhostTextOverlay from './GhostTextOverlay.vue'
import { createInlineSuggestionPlugin } from '../plugins/inlineSuggestionPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { DEBUG } from '../utils/config.js'
const root = ref(null)
const containerRef = ref(null)
let crepe = null
let editorElement = null
const suggestion = ref('')
const cursorRect = ref(null)
const isLoading = ref(false)
const lastFetchedContent = ref('')
let debounceTimer = null
const API_URL = 'http://localhost:8000/v1/completions'
const DEBOUNCE_MS = 500
onMounted(async () => {
console.log('[Debug] onMounted called')
if (!root.value) {
console.log('[Debug] root.value is null')
return
}
if (DEBUG) console.log('[Debug] onMounted called')
if (!root.value) throw new Error('root.value is null')
console.log('[Debug] Creating Crepe editor...')
const inlineSuggestionPlugin = createInlineSuggestionPlugin({ apiUrl: API_URL })
if (DEBUG) console.log('[Debug] Creating Crepe editor...')
crepe = new Crepe({
root: root.value,
defaultValue: '# Welcome to Milkdown\n\nStart writing your markdown content here...',
plugins: [inlineSuggestionPlugin],
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
})
await crepe.create()
console.log('[Debug] Crepe editor created')
if (DEBUG) console.log('[Debug] Crepe editor created')
observeEditor()
})
const getCursorPosition = async () => {
if (!crepe) {
console.log('[Debug] getCursorPosition: crepe is null')
return null
const observeEditor = () => {
if (!containerRef.value) throw new Error('containerRef.value is null')
const observer = new MutationObserver(() => {
const editorEl = containerRef.value?.querySelector('.milkdown .editor') ||
containerRef.value?.querySelector('.milkdown')
if (editorEl) {
editorElement = editorEl
bindEditorEvents(editorEl)
observer.disconnect()
if (DEBUG) console.log('[Debug] Editor element found and events bound')
}
})
observer.observe(containerRef.value, {
childList: true,
subtree: true
})
setTimeout(() => {
const existingEl = containerRef.value?.querySelector('.milkdown .editor') ||
containerRef.value?.querySelector('.milkdown')
if (existingEl) {
editorElement = existingEl
bindEditorEvents(existingEl)
observer.disconnect()
if (DEBUG) console.log('[Debug] Editor element found immediately')
}
}, 100)
}
try {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
const { from } = view.state.selection
console.log('[Debug] Cursor position:', from)
const bindEditorEvents = (editorEl) => {
editorEl.addEventListener('input', onInput)
editorEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault()
handleTab()
}
})
}
const coords = view.coordsAtPos(from)
const getEditorContent = () => {
if (!editorElement) throw new Error('editorElement is null')
return editorElement.innerText || ''
}
const getCursorPositionFromDOM = () => {
if (!editorElement) throw new Error('editorElement is null')
const selection = window.getSelection()
if (!selection.rangeCount) throw new Error('No selection')
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
const containerRect = containerRef.value?.getBoundingClientRect()
if (!containerRect) {
console.log('[Debug] containerRect is null')
return null
}
if (!containerRect) throw new Error('containerRect is null')
return {
left: coords.left - containerRect.left,
top: coords.top - containerRect.top + window.scrollY,
left: rect.left - containerRect.left,
top: rect.top - containerRect.top + window.scrollY,
fontSize: 16,
fontFamily: 'monospace',
}
} catch (e) {
console.error('[Debug] getCursorPosition error:', e)
return null
}
}
const fetchSuggestion = async (prefix, suffix) => {
console.log('[Debug] fetchSuggestion called with prefix length:', prefix.length, 'suffix length:', suffix.length)
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
})
console.log('[Debug] fetchSuggestion response status:', res.status)
if (!res.ok) {
console.log('[Debug] Response not ok')
return ''
}
const reader = res.body?.getReader()
if (!reader) {
console.log('[Debug] No reader available')
return ''
}
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = new TextDecoder().decode(value)
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
console.log('[Debug] Added content:', data.content)
}
if (data.done || data.error) break
} catch (e) {
console.warn('[Debug] JSON parse error:', e)
}
}
}
console.log('[Debug] Final suggestion text:', text.substring(0, 100))
return text
} catch (e) {
console.error('[Debug] fetchSuggestion error:', e)
return ''
}
const getCursorPosition = async () => {
return getCursorPositionFromDOM()
}
const onInput = async () => {
if (!crepe) {
console.log('[Debug] onInput: crepe is null')
if (!editorElement) throw new Error('editorElement is null')
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
const from = range.startOffset
const content = getEditorContent()
const prefix = content.slice(0, from)
const suffix = content.slice(from)
if (DEBUG) console.log('[Debug] onInput triggered at position:', from)
// 清除之前的定时器
if (debounceTimer) {
clearTimeout(debounceTimer)
}
// 设置新的定时器 - 只有停止输入后才触发
debounceTimer = setTimeout(async () => {
if (DEBUG) console.log('[Debug] Debounce timeout reached, fetching suggestion...')
// 检查是否已经有建议在显示,如果内容没变则跳过
if (suggestion.value && content === lastFetchedContent.value) {
if (DEBUG) console.log('[Debug] Content unchanged, skipping fetch')
return
}
isLoading.value = true
try {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
const { from } = view.state.selection
console.log('[Debug] onInput triggered at position:', from)
const prefix = view.state.doc.textBetween(0, from)
const suffix = view.state.doc.textBetween(from, view.state.doc.content.size)
cursorRect.value = await getCursorPosition()
suggestion.value = await fetchSuggestion(prefix, suffix)
console.log('[Debug] Suggestion updated:', suggestion.value ? 'yes' : 'no')
lastFetchedContent.value = content
if (DEBUG) console.log('[Debug] Suggestion updated:', suggestion.value ? 'yes' : 'no')
} catch (e) {
console.error('[Debug] onInput error:', e)
if (DEBUG) console.error('[Debug] Fetch error:', e)
} finally {
isLoading.value = false
debounceTimer = null
}
}, DEBOUNCE_MS)
}
const handleTab = () => {
if (suggestion.value) {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
view.dispatch(view.state.tr.insertText(suggestion.value))
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
range.deleteContents()
const textNode = document.createTextNode(suggestion.value)
range.insertNode(textNode)
range.setStartAfter(textNode)
range.setEndAfter(textNode)
selection.removeAllRanges()
selection.addRange(range)
suggestion.value = ''
console.log('[Debug] Tab pressed, accepted suggestion')
if (DEBUG) console.log('[Debug] Tab pressed, accepted suggestion')
}
}
const dismissSuggestion = () => {
suggestion.value = ''
console.log('[Debug] Suggestion dismissed')
if (DEBUG) console.log('[Debug] Suggestion dismissed')
}
const acceptSuggestion = () => {
if (suggestion.value) {
const ctx = crepe.ctx.get()
const view = ctx.get('view')
view.dispatch(view.state.tr.insertText(suggestion.value))
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
range.deleteContents()
const textNode = document.createTextNode(suggestion.value)
range.insertNode(textNode)
range.setStartAfter(textNode)
range.setEndAfter(textNode)
selection.removeAllRanges()
selection.addRange(range)
suggestion.value = ''
console.log('[Debug] Suggestion accepted via click')
if (DEBUG) console.log('[Debug] Suggestion accepted via click')
}
}
@@ -190,6 +223,13 @@ const exportMarkdown = async () => {
a.click()
URL.revokeObjectURL(url)
}
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
</script>
<style scoped>
@@ -197,7 +237,7 @@ const exportMarkdown = async () => {
position: relative;
}
export-btn {
.export-btn {
position: fixed;
top: 20px;
right: 20px;
@@ -210,7 +250,7 @@ export-btn {
z-index: 1000;
}
export-btn:hover {
.export-btn:hover {
background-color: #3a7bc8;
}
@@ -247,4 +287,16 @@ export-btn:hover {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.loading-indicator {
position: fixed;
bottom: 20px;
right: 20px;
padding: 8px 16px;
background-color: #4a90d9;
color: white;
border-radius: 4px;
font-size: 14px;
z-index: 1000;
}
</style>
+41 -102
View File
@@ -1,64 +1,58 @@
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';
const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion');
const DEBOUNCE_MS = 150;
let debounceTimer = null;
let currentSuggestion = '';
let suggestionPos = { from: 0, to: 0 };
interface InlineSuggestionOptions {
apiUrl?: string;
}
function createInlineSuggestionPlugin(options: InlineSuggestionOptions = {}) {
const apiUrl = options.apiUrl || 'http://localhost:8000/v1/completions';
console.log('[InlineSuggestion] Plugin initialized with API URL:', apiUrl);
interface InlineSuggestionState {
suggestion: string;
visible: boolean;
debounceTimer: ReturnType<typeof setTimeout> | null;
currentSuggestion: string;
suggestionPos: { from: number; to: number };
}
return new Plugin({
function createInlineSuggestionPlugin(options: InlineSuggestionOptions = {}) {
const apiUrl = options.apiUrl || API_URL;
return new Plugin<InlineSuggestionState>({
key: INLINE_SUGGESTION_KEY,
state: {
init: () => {
console.log('[InlineSuggestion] State initialized');
return { suggestion: '', visible: false };
},
init: () => ({
suggestion: '',
visible: false,
debounceTimer: null,
currentSuggestion: '',
suggestionPos: { from: 0, to: 0 }
}),
apply: (tr, value) => {
if (!tr.docChanged) {
console.log('[InlineSuggestion] No doc change in apply, returning same state');
return value;
}
if (!tr.docChanged) return value;
const { from, to } = tr.selection;
console.log('[InlineSuggestion] Apply called - selection changed:', { from, to }, 'current suggestionPos:', suggestionPos);
if (from === suggestionPos.from && to === suggestionPos.to) {
console.log('[InlineSuggestion] Selection matches suggestion position, keeping state');
if (from === value.suggestionPos.from && to === value.suggestionPos.to) {
return value;
}
const newState = { suggestion: '', visible: false };
console.log('[InlineSuggestion] Resetting suggestion state');
return newState;
return { ...value, suggestion: '', visible: false };
},
},
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
const currentState = INLINE_SUGGESTION_KEY.getState(view.state);
console.log('[InlineSuggestion] Key pressed:', event.key, 'suggestion visible:', currentState.visible);
if (event.key === 'Tab' && currentState.visible) {
const state = INLINE_SUGGESTION_KEY.getState(view.state);
if (event.key === 'Tab' && state.visible) {
event.preventDefault();
const { suggestion } = currentState;
console.log('[InlineSuggestion] Tab pressed - accepting suggestion:', suggestion.substring(0, 50));
if (suggestion) {
view.dispatch(view.state.tr.insertText(suggestion, view.state.selection.from));
currentSuggestion = '';
if (state.suggestion) {
view.dispatch(view.state.tr.insertText(state.suggestion, view.state.selection.from));
return true;
}
}
if (event.key === 'Escape') {
const state = INLINE_SUGGESTION_KEY.getState(view.state);
if (state.visible) {
console.log('[InlineSuggestion] Escape pressed - dismissing suggestion');
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, { suggestion: '', visible: false }));
currentSuggestion = '';
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, { ...state, suggestion: '', visible: false }));
return true;
}
}
@@ -67,85 +61,30 @@ function createInlineSuggestionPlugin(options: InlineSuggestionOptions = {}) {
},
appendTransaction: (transactions, oldState, newState) => {
const lastTr = transactions[transactions.length - 1];
if (!lastTr || !lastTr.docChanged) {
console.log('[InlineSuggestion] No document change in transaction');
return null;
}
if (!lastTr || !lastTr.docChanged) return null;
console.log('[InlineSuggestion] Document changed, setting up debounce for', DEBOUNCE_MS, 'ms');
const currentState = INLINE_SUGGESTION_KEY.getState(newState);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
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);
console.log('[InlineSuggestion] Debounce fired - position:', { from, to });
console.log('[InlineSuggestion] Prefix length:', prefix.length, 'Suffix length:', suffix.length);
console.log('[InlineSuggestion] Prefix (last 100):', prefix.slice(-100));
console.log('[InlineSuggestion] Suffix (first 100):', suffix.slice(0, 100));
try {
console.log('[InlineSuggestion] Fetching from:', apiUrl);
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
});
console.log('[InlineSuggestion] Response status:', res.status);
if (!res.ok) {
const errorText = await res.text();
console.error('[InlineSuggestion] API error:', errorText);
return;
}
const reader = res.body?.getReader();
if (!reader) {
console.error('[InlineSuggestion] No response body reader');
return;
}
let text = '';
let chunkCount = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunkCount++;
const chunk = new TextDecoder().decode(value);
console.log('[InlineSuggestion] Raw chunk', chunkCount, ':', chunk.substring(0, 200));
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;
console.log('[InlineSuggestion] Accumulated suggestion:', text.substring(0, 100));
}
if (data.done) {
console.log('[InlineSuggestion] Stream done signal received');
break;
}
} catch (e) {
console.error('[InlineSuggestion] JSON parse error:', e);
}
}
}
console.log('[InlineSuggestion] Total chunks received:', chunkCount, 'Total text length:', text.length);
const text = await fetchSuggestion(prefix, suffix, apiUrl);
if (text && newState.selection.from === from) {
currentSuggestion = text;
suggestionPos = { from, to: from + text.length };
const metaUpdate = { suggestion: text, visible: true };
console.log('[InlineSuggestion] Setting suggestion:', text.substring(0, 50), '...');
newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, metaUpdate));
} else {
console.log('[InlineSuggestion] Suggestion not applied - empty text or cursor moved');
newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, {
...currentState,
currentSuggestion: text,
suggestionPos: { from, to: from + text.length },
suggestion: text,
visible: true
}));
}
} catch (e) {
console.error('[InlineSuggestion] Error:', e);
if (DEBUG) console.error('Inline suggestion error:', e);
}
}, DEBOUNCE_MS);
+52
View File
@@ -0,0 +1,52 @@
import { DEBUG, API_URL } from './config.js'
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 (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) {
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
}
}
+2
View File
@@ -0,0 +1,2 @@
export const DEBUG = import.meta.env.DEV
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/v1/completions'