diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..7877999 --- /dev/null +++ b/backend/.env @@ -0,0 +1,3 @@ +OPENAI_API_KEY=ollama +OLLAMA_BASE_URL=http://192.168.0.120:11434/v1/ +OLLAMA_MODEL=gpt-oss:120b diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..7877999 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,3 @@ +OPENAI_API_KEY=ollama +OLLAMA_BASE_URL=http://192.168.0.120:11434/v1/ +OLLAMA_MODEL=gpt-oss:120b diff --git a/backend/llm.py b/backend/llm.py new file mode 100644 index 0000000..7aea698 --- /dev/null +++ b/backend/llm.py @@ -0,0 +1,44 @@ +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://192.168.0.120:11434/v1/') +model = os.getenv('OLLAMA_MODEL', 'gpt-oss:120b') + +print(f"[LLM] API key configured: {'Yes' if api_key else 'No'}") +print(f"[LLM] Base URL: {base_url}") +print(f"[LLM] Model: {model}") + +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 逻辑。 + """ + print(f"[LLM] Calling API with prompt length: {len(prompt)}") + + 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 + print(f"[LLM] Chunk {chunk_count}: {content}") + yield json.dumps({"content": content}) + + 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}") + yield json.dumps({"error": str(e)}) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..3e8001a --- /dev/null +++ b/backend/main.py @@ -0,0 +1,47 @@ +from fastapi import FastAPI, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +import os +import json + +app = FastAPI() + +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 + + print(f"[Backend] Received request - prefix length: {len(request.prefix)}, suffix length: {len(request.suffix)}") + + try: + prompt = build_prompt(request.prefix, request.suffix) + print(f"[Backend] Built prompt (first 100 chars): {prompt[:100]}...") + + async def gen(): + chunk_count = 0 + async for chunk in stream_openai(prompt): + chunk_count += 1 + yield f"data: {chunk}\n\n" + if chunk_count % 5 == 0: + print(f"[Backend] Sent chunk {chunk_count}") + yield "data: {\"done\": true}\n\n" + print(f"[Backend] Stream complete, total chunks: {chunk_count}") + return gen() + except Exception as e: + error_msg = f"{{\"error\": \"{str(e)}\"}}" + print(f"[Backend] Error: {e}") + yield f"data: {error_msg}\n\n" + +@app.post("/v1/completions") +async def create_completion(request: CompletionRequest): + print(f"[Backend] POST /v1/completions called") + return StreamingResponse(generate_stream(request), media_type="text/event-stream") + +if __name__ == "__main__": + import uvicorn + print("[Backend] Starting server on http://0.0.0.0:8000") + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/prompt.py b/backend/prompt.py new file mode 100644 index 0000000..ad9fb71 --- /dev/null +++ b/backend/prompt.py @@ -0,0 +1,28 @@ +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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0d26e78 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +openai +pydantic +python-dotenv diff --git a/plans/inline-suggestions-plan.md b/plans/inline-suggestions-plan.md new file mode 100644 index 0000000..ac07fd7 --- /dev/null +++ b/plans/inline-suggestions-plan.md @@ -0,0 +1,111 @@ +# 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` +- 监听编辑器输入事件 +- 防抖处理(150ms) +- 调用后端 API 获取补全建议 +- 管理 GhostText 显示状态 + +### 2. 前端:GhostText 渲染组件 +**文件**: `src/components/GhostTextOverlay.vue` 或内联样式 +- 在光标位置显示灰色虚影文本 +- 处理 Tab 键接受补全 +- ESC 键取消显示 + +### 3. 修改 MilkdownEditor 集成插件 +**文件**: `src/components/MilkdownEditor.vue` +- 注册 InlineSuggestionPlugin 到 Crepe 实例 +- 配置 API 地址 + +### 4. 后端:FastAPI 服务 +**文件**: `backend/main.py` +- POST `/v1/completions` 流式接口 +- 请求体验证和解析 + +### 5. 后端:Prompt 构建和 LLM 调用 +**文件**: `backend/prompt.py`, `backend/llm.py` +- 构建补全 Prompt(参考 completions-sample-code 的 extractPrompt) +- OpenAI API 流式调用 +- 返回 SSE 格式响应 + +## 文件结构 + +``` +llm-in-text/ +├── src/ +│ ├── components/ +│ │ └── MilkdownEditor.vue [修改] +│ ├── plugins/ +│ │ └── inlineSuggestionPlugin.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] +``` + +## 参考代码映射 + +| completions-sample-code | 本项目实现 | +|------------------------|-----------| +| `ghostText.ts getGhostText()` | 后端 LLM 调用逻辑 | +| `inlineCompletion.ts GhostText` | 前端 Plugin 核心逻辑 | +| `networking.ts postRequest()` | 后端 API 接口 | +| `prompt/extractPrompt()` | 后端 Prompt 构建 | + +## 下一步 +确认计划后切换到 Code 模式开始实现。 diff --git a/src/components/GhostTextOverlay.vue b/src/components/GhostTextOverlay.vue new file mode 100644 index 0000000..599c90c --- /dev/null +++ b/src/components/GhostTextOverlay.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/components/MilkdownEditor.vue b/src/components/MilkdownEditor.vue index ae9675b..5e2bb9c 100644 --- a/src/components/MilkdownEditor.vue +++ b/src/components/MilkdownEditor.vue @@ -1,97 +1,286 @@ diff --git a/src/plugins/inlineSuggestionPlugin.ts b/src/plugins/inlineSuggestionPlugin.ts new file mode 100644 index 0000000..dc4060c --- /dev/null +++ b/src/plugins/inlineSuggestionPlugin.ts @@ -0,0 +1,104 @@ +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; +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'; + + return new Plugin({ + key: INLINE_SUGGESTION_KEY, + state: { + init: () => ({ suggestion: '', visible: false }), + apply: (tr, value) => { + if (!tr.docChanged) return value; + const { from, to } = tr.selection; + if (from === suggestionPos.from && to === suggestionPos.to) { + return value; + } + return { suggestion: '', visible: false }; + }, + }, + props: { + handleKeyDown: (view: EditorView, event: KeyboardEvent) => { + if (event.key === 'Tab' && INLINE_SUGGESTION_KEY.getState(view.state).visible) { + event.preventDefault(); + const { suggestion } = INLINE_SUGGESTION_KEY.getState(view.state); + if (suggestion) { + view.dispatch(view.state.tr.insertText(suggestion, view.state.selection.from)); + currentSuggestion = ''; + return true; + } + } + if (event.key === 'Escape') { + const state = INLINE_SUGGESTION_KEY.getState(view.state); + if (state.visible) { + view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, { suggestion: '', visible: false })); + currentSuggestion = ''; + return true; + } + } + return false; + }, + }, + appendTransaction: (transactions, oldState, newState) => { + const lastTr = transactions[transactions.length - 1]; + if (!lastTr || !lastTr.docChanged) return null; + + clearTimeout(debounceTimer); + 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); + + try { + const res = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }), + }); + + if (!res.ok) return; + + const reader = res.body?.getReader(); + if (!reader) return; + + 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) break; + } catch {} + } + } + + if (text && newState.selection.from === from) { + currentSuggestion = text; + suggestionPos = { from, to: from + text.length }; + newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, { suggestion: text, visible: true })); + } + } catch (e) { + console.error('Inline suggestion error:', e); + } + }, DEBOUNCE_MS); + + return null; + }, + }); +} + +export { createInlineSuggestionPlugin, INLINE_SUGGESTION_KEY };