diff --git a/.env b/.env
new file mode 100644
index 0000000..35460e3
--- /dev/null
+++ b/.env
@@ -0,0 +1 @@
+VITE_API_URL=http://localhost:8000/v1/completions
diff --git a/README.md b/README.md
index fe2e57c..9ace79e 100644
--- a/README.md
+++ b/README.md
@@ -1,46 +1,46 @@
# LLM in Text - 智能写作助手
-基于 Vue3 和 FastAPI 的智能写作助手,实现类似 GitHub Copilot 的 inline suggestions(行内建议)功能。
+基于 Vue3 和 FastAPI 的智能 Markdown 编辑器,集成大语言模型(LLM)实时补全建议功能。
-## 项目概述
+## 功能特性
-本项目是一个全屏 Markdown 编辑器,集成了大语言模型(LLM)的智能补全功能。当用户输入时,系统会根据上下文实时提供文本补全建议,用户可以通过 Tab 键接受建议或点击建议文本直接插入。
-
-## 技术栈
-
-### 前端
-- **Vue 3** - 渐进式 JavaScript 框架
-- **Vite** - 下一代前端构建工具
-- **Milkdown** - 基于 ProseMirror 的 WYSIWYG Markdown 编辑器
-- **Pinia** - Vue 状态管理
-- **Axios** - HTTP 客户端
-
-### 后端
-- **FastAPI** - 现代化的 Python Web 框架
-- **OpenAI API** - 大语言模型接口
-- **Ollama** - 本地 LLM 服务支持
-
-## 核心功能
-
-### 1. 全屏 Markdown 编辑器
+### Markdown 编辑器
- 基于 Milkdown Crepe 的所见即所得编辑体验
-- 支持完整的 Markdown 语法
-- 代码块高亮、图片粘贴等功能
-- **上传/导出 Markdown 文件**(底部图标按钮)
+- 支持完整 Markdown 语法和 LaTeX 公式
+- 导入/导出 Markdown 文件
-### 2. 智能行内建议
-- 实时监听用户输入
-- 基于上下文(光标前后文本)生成补全建议
-- 流式响应,实时显示建议内容
-- 支持多种交互方式:
+### AI 智能补全
+- 实时生成文本补全建议(灰色显示)
+- 流式响应,低延迟体验
+- 多种交互方式:
- **Tab 键**:接受建议
- - **Esc 键**:取消建议
- - **点击建议**:直接插入
+ - **Esc 键**:拒绝建议
+ - **点击灰色文本**:接受建议
+ - **继续输入**:自动拒绝建议
-### 3. 性能优化
-- 500ms 防抖机制,避免频繁请求
-- 流式传输(SSE),降低延迟
-- 上下文智能截取(光标前30行 + 后5行)
+### AI 开关控制
+- 右下角 AI 开关按钮
+- 白色 = AI 启用,黑色 = AI 禁用
+- 禁用时自动清除灰色文本并停止 API 调用
+
+## 技术架构
+
+```mermaid
+flowchart LR
+ subgraph Frontend
+ A[Vue3] --> B[Milkdown Editor]
+ B --> C[ProseMirror Plugin]
+ C --> D[Ghost Text Mark]
+ end
+
+ subgraph Backend
+ E[FastAPI] --> F[LLM API]
+ F --> G[Stream Response]
+ end
+
+ D -->|SSE| E
+ G -->|text| D
+```
## 项目结构
@@ -48,146 +48,116 @@
llm-in-text/
├── src/
│ ├── components/
-│ │ ├── MilkdownEditor.vue # 主编辑器组件
-│ │ ├── GhostTextOverlay.vue # 建议文本显示组件
-│ │ └── MarkdownEditor.vue # 备用编辑器
+│ │ └── MilkdownEditor.vue # 主编辑器组件
│ ├── plugins/
-│ │ ├── inlineSuggestionPlugin.ts # 行内建议插件
-│ │ └── types.ts # 类型定义
-│ ├── router/
-│ │ └── index.js # 路由配置
-│ ├── store/
-│ │ └── index.js # 状态管理
-│ ├── App.vue # 根组件
-│ └── main.js # 入口文件
+│ │ └── copilotPlugin.ts # ProseMirror AI 补全插件
+│ ├── utils/
+│ │ ├── api.js # API 调用封装
+│ │ └── config.js # 配置文件
+│ ├── App.vue
+│ └── main.js
├── backend/
-│ ├── main.py # FastAPI 服务器
-│ ├── llm.py # LLM API 调用
-│ ├── prompt.py # Prompt 构建
-│ ├── requirements.txt # Python 依赖
-│ └── .env # 环境变量配置
-├── plans/
-│ ├── milkdown-editor-plan.md # 编辑器实施计划
-│ └── inline-suggestions-plan.md # 建议功能实施计划
-├── index.html
-├── package.json
-├── vite.config.js
+│ ├── main.py # FastAPI 服务器
+│ ├── llm.py # LLM API 调用
+│ ├── prompt.py # Prompt 构建
+│ └── requirements.txt
└── README.md
```
## 快速开始
-### 前置要求
+### 环境要求
- Node.js 18+
- Python 3.8+
- OpenAI API Key 或 Ollama 服务
-### 安装依赖
+### 安装
-**前端:**
```bash
+# 前端
npm install
-```
-**后端:**
-```bash
+# 后端
cd backend
pip install -r requirements.txt
```
-### 配置环境变量
+### 配置
-在 `backend/.env` 文件中配置:
+在 `backend/.env` 中配置:
```env
-OPENAI_API_KEY=your_api_key_here
+OPENAI_API_KEY=your_api_key
OLLAMA_BASE_URL=http://localhost:11434/v1/
OLLAMA_MODEL=gpt-4
```
-### 启动服务
+### 启动
-**启动后端:**
```bash
+# 后端(端口 8000)
cd backend
python main.py
-```
-**启动前端:**
-```bash
+# 前端(端口 5173)
npm run dev
```
-访问 `http://localhost:5173` 开始使用。
+访问 http://localhost:5173
## API 接口
### POST /v1/completions
-获取文本补全建议(流式响应)
+流式获取补全建议
-**请求体:**
+**请求:**
```json
{
- "prefix": "# Hello\n\nThis is ",
+ "prefix": "# Title\n\nContent ",
"suffix": "",
"languageId": "markdown"
}
```
-**响应(SSE 流):**
+**响应(SSE):**
```
-data: {"content": "a "}
-
-data: {"content": "a te"}
-
-data: {"content": "a test"}
-
+data: {"content": "here"}
+data: {"content": "here is"}
data: {"done": true}
```
-## 已知问题
+## 核心实现
-### 待优化项(P1)
+### ProseMirror Mark 系统
-1. 代码重复 - fetchSuggestion 逻辑在两个文件中重复
-2. 全局状态污染 - 插件使用模块级全局变量
+使用 ProseMirror 的 Mark 系统实现灰色建议文本:
-### 轻微问题(P2)
+```typescript
+// 定义 ghost mark
+export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
+ excludes: '_',
+ inclusive: true,
+ toDOM: () => ['span', {
+ 'data-copilot-ghost': '',
+ class: 'copilot-ghost-text'
+ }, 0]
+}))
-3. 大量调试日志影响性能
-4. 缺少完整的类型定义
-5. 建议文本无长度限制
-6. API URL 硬编码在前端
+// CSS 样式
+.copilot-ghost-text {
+ color: #999;
+ opacity: 0.6;
+}
+```
-## 开发指南
+### 交互处理
-### 代码规范
-
-- **前端**:遵循 Vue 3 Composition API 最佳实践
-- **后端**:遵循 FastAPI 异步编程模式
-- **错误处理**:获取失败直接报错,不返回默认值
-- **性能优化**:优先考虑降低延迟,避免冗余代码
-
-### 调试
-
-前端使用浏览器开发者工具,后端查看控制台输出。所有关键操作都有日志记录。
-
-## 贡献指南
-
-欢迎提交 Issue 和 Pull Request。在提交代码前,请确保:
-
-1. 代码通过 ESLint 检查
-2. 所有测试通过
-3. 添加必要的注释和文档
-4. 遵循项目的代码规范
+- 点击灰色文本区域:接受建议(移除 mark,保留文本)
+- 点击其他区域:拒绝建议(删除灰色文本)
+- Tab 键:接受建议
+- Esc 键:拒绝建议
## 许可证
MIT License
-
-## 致谢
-
-- [Milkdown](https://milkdown.dev/) - 优秀的 Markdown 编辑器框架
-- [FastAPI](https://fastapi.tiangolo.com/) - 现代化的 Python Web 框架
-- [OpenAI](https://openai.com/) - 大语言模型 API
diff --git a/backend/.env b/backend/.env
index 07e900e..1431614 100644
--- a/backend/.env
+++ b/backend/.env
@@ -1,3 +1,3 @@
OPENAI_API_KEY=ollama
OLLAMA_HOST=http://192.168.0.120:11434
-OLLAMA_MODEL=gpt-oss:120b
+OLLAMA_MODEL=gpt-oss:20b
diff --git a/backend/__pycache__/prompt.cpython-313.pyc b/backend/__pycache__/prompt.cpython-313.pyc
index 0e26134..f7020a7 100644
Binary files a/backend/__pycache__/prompt.cpython-313.pyc and b/backend/__pycache__/prompt.cpython-313.pyc differ
diff --git a/backend/llm.py b/backend/llm.py
index 856167f..2b6b5d8 100644
--- a/backend/llm.py
+++ b/backend/llm.py
@@ -3,8 +3,8 @@ import json
import ollama
from typing import AsyncGenerator
-OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:120b')
-OLLAMA_HOST = os.getenv('OLLAMA_BASE_URL', 'http://192.168.0.120:11434')
+OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
+OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
# 移除 /v1/ 后缀(如果有的话),因为 Ollama Python 包使用原生 API
if OLLAMA_HOST.endswith('/v1/'):
diff --git a/backend/main.py b/backend/main.py
index f7c2504..2fad675 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -71,7 +71,7 @@ async def create_completion(request: CompletionRequest):
print(f"[Backend] POST /v1/completions called")
print(f"[Backend] Received request - prefix length: {len(request.prefix)}, suffix length: {len(request.suffix)}")
- OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:120b')
+ OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
print(f"[LLM] Using host: {OLLAMA_HOST}, model: {OLLAMA_MODEL}")
@@ -123,8 +123,9 @@ async def create_completion(request: CompletionRequest):
# 返回完整内容
async def generate():
if content:
+ print(f"[LLM] Yielding full content: {repr(content)}")
yield f"data: {json.dumps({'content': content})}\n\n"
- yield f"data: {json.dumps({'done': true})}\n\n"
+ yield f"data: {json.dumps({'done': True})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
diff --git a/backend/prompt.py b/backend/prompt.py
index 84fc525..e19d2da 100644
--- a/backend/prompt.py
+++ b/backend/prompt.py
@@ -8,6 +8,12 @@ def build_prompt(prefix: str, suffix: str) -> str:
"""
MAX_CONTEXT_LINES = 30
+ # 修正:把suffix的第一个字符移到prefix末尾(解决光标位置偏差)
+ if suffix:
+ first_char = suffix[0]
+ prefix = prefix + first_char
+ suffix = suffix[1:]
+
prefix_lines = prefix.split('\n')
suffix_lines = suffix.split('\n') if suffix else []
@@ -25,6 +31,7 @@ RULES:
- Match existing style, tone, and terminology
- Maintain logical flow
- Write only the continuation, nothing else
+- IMPORTANT: Start continuation directly from ⟨CURSOR⟩ position
TEXT:
{recent_prefix}⟨CURSOR⟩{recent_suffix}
diff --git a/src/components/GhostTextOverlay.vue b/src/components/GhostTextOverlay.vue
deleted file mode 100644
index 2a35c8b..0000000
--- a/src/components/GhostTextOverlay.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
- {{ displayText }}
-
-
-
-
-
-
diff --git a/src/components/MilkdownEditor.vue b/src/components/MilkdownEditor.vue
index 69f5352..c5679d0 100644
--- a/src/components/MilkdownEditor.vue
+++ b/src/components/MilkdownEditor.vue
@@ -9,6 +9,7 @@
+ 导入 Markdown
@@ -18,133 +19,247 @@
+ 导出 Markdown
+
+
+
diff --git a/src/plugins/copilotPlugin.ts b/src/plugins/copilotPlugin.ts
new file mode 100644
index 0000000..3c62e3b
--- /dev/null
+++ b/src/plugins/copilotPlugin.ts
@@ -0,0 +1,234 @@
+import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
+import { $prose, $ctx, $markSchema } from '@milkdown/kit/utils'
+import type { Ctx } from '@milkdown/kit/core'
+import type { EditorView } from '@milkdown/prose/view'
+
+const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
+const DEBOUNCE_MS = 500
+
+let enabled = true
+
+interface CopilotState {
+ from: number
+ to: number
+ suggestion: string
+}
+
+interface CopilotConfig {
+ fetchSuggestion: (prefix: string, suffix: string, signal?: AbortSignal) => Promise
+ debounceMs?: number
+}
+
+const initialState: CopilotState = {
+ from: 0,
+ to: 0,
+ suggestion: ''
+}
+
+export const copilotConfigCtx = $ctx({
+ fetchSuggestion: async () => '',
+ debounceMs: DEBOUNCE_MS
+}, 'copilotConfig')
+
+export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
+ excludes: '_',
+ inclusive: true,
+ parseDOM: [{ tag: 'span[data-copilot-ghost]' }],
+ toDOM: () => ['span', { 'data-copilot-ghost': '', class: 'copilot-ghost-text' }, 0]
+}))
+
+let debounceTimer: ReturnType | null = null
+let abortController: AbortController | null = null
+let currentCtx: Ctx | null = null
+
+function clearGhostText(view: EditorView) {
+ const state = COPILOT_PLUGIN_KEY.getState(view.state)
+ if (state && state.suggestion && state.from < state.to) {
+ const tr = view.state.tr
+ .delete(state.from, state.to)
+ .setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
+ view.dispatch(tr)
+ }
+}
+
+function insertGhostText(view: EditorView, suggestion: string, from: number) {
+ if (!currentCtx || !suggestion) return
+
+ const schema = view.state.schema
+ const markType = schema.marks.copilot_ghost
+
+ if (!markType) {
+ console.error('[Copilot] copilot_ghost mark not found in schema')
+ return
+ }
+
+ const tr = view.state.tr
+ tr.insertText(suggestion, from)
+ const endPos = from + suggestion.length
+ tr.addMark(from, endPos, markType.create())
+ tr.setMeta(COPILOT_PLUGIN_KEY, { from, to: endPos, suggestion })
+ view.dispatch(tr)
+}
+
+function doFetchSuggestion(view: EditorView, pos: number, prefix: string, suffix: string) {
+ if (!currentCtx) return
+
+ const config = currentCtx.get(copilotConfigCtx.key)
+
+ if (abortController) {
+ abortController.abort()
+ abortController = null
+ }
+
+ abortController = new AbortController()
+
+ config.fetchSuggestion(prefix, suffix, abortController.signal)
+ .then(suggestion => {
+ if (view.state.selection.from !== pos) return
+
+ if (suggestion) {
+ insertGhostText(view, suggestion, pos)
+ }
+ })
+ .catch(e => {
+ if (e.name !== 'AbortError') {
+ console.error('[Copilot] Error:', e)
+ }
+ })
+ .finally(() => {
+ abortController = null
+ })
+}
+
+function scheduleFetch(view: EditorView, pos: number, prefix: string, suffix: string) {
+ if (!enabled) return
+
+ if (debounceTimer) {
+ clearTimeout(debounceTimer)
+ debounceTimer = null
+ }
+
+ debounceTimer = setTimeout(() => {
+ debounceTimer = null
+ doFetchSuggestion(view, pos, prefix, suffix)
+ }, DEBOUNCE_MS)
+}
+
+function acceptSuggestion(view: EditorView) {
+ const state = COPILOT_PLUGIN_KEY.getState(view.state)
+ if (!state?.suggestion || state.from >= state.to) return false
+
+ const tr = view.state.tr.removeMark(state.from, state.to, view.state.schema.marks.copilot_ghost)
+ const endPos = Math.min(state.to, tr.doc.content.size)
+ tr.setSelection(Selection.near(tr.doc.resolve(endPos)))
+ tr.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
+ view.dispatch(tr)
+ return true
+}
+
+function rejectSuggestion(view: EditorView) {
+ const state = COPILOT_PLUGIN_KEY.getState(view.state)
+ if (!state?.suggestion) return false
+
+ clearGhostText(view)
+ return true
+}
+
+export const copilotPlugin = $prose((ctx) => {
+ currentCtx = ctx
+
+ return new Plugin({
+ key: COPILOT_PLUGIN_KEY,
+ state: {
+ init: () => ({ ...initialState }),
+ apply: (tr, value) => {
+ const meta = tr.getMeta(COPILOT_PLUGIN_KEY)
+ if (meta !== undefined) {
+ return meta
+ }
+
+ if (tr.docChanged && value.suggestion) {
+ return { ...initialState }
+ }
+
+ return value
+ }
+ },
+ props: {
+ handleKeyDown: (view, event) => {
+ const state = COPILOT_PLUGIN_KEY.getState(view.state)
+
+ if (event.key === 'Tab' && state?.suggestion) {
+ event.preventDefault()
+ return acceptSuggestion(view)
+ }
+
+ if (event.key === 'Escape' && state?.suggestion) {
+ event.preventDefault()
+ return rejectSuggestion(view)
+ }
+
+ if (state?.suggestion && event.key !== 'Shift' && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Meta') {
+ clearGhostText(view)
+ }
+
+ return false
+ },
+ handleClick: (view, pos) => {
+ const state = COPILOT_PLUGIN_KEY.getState(view.state)
+ if (!state?.suggestion) return false
+
+ if (pos >= state.from && pos < state.to) {
+ return acceptSuggestion(view)
+ }
+
+ clearGhostText(view)
+ return false
+ }
+ },
+ view: () => ({
+ update: (view, prevState) => {
+ if (view.state.doc.eq(prevState.doc) && view.state.selection.eq(prevState.selection)) {
+ return
+ }
+
+ const state = COPILOT_PLUGIN_KEY.getState(view.state)
+ if (state?.suggestion) {
+ return
+ }
+
+ if (!view.state.doc.eq(prevState.doc)) {
+ const { from, to } = view.state.selection
+ if (from !== to) return
+
+ const doc = view.state.doc
+ const prefix = doc.textBetween(0, from)
+ const suffix = doc.textBetween(to, doc.content.size)
+
+ scheduleFetch(view, from, prefix, suffix)
+ }
+ }
+ })
+ })
+})
+
+export { COPILOT_PLUGIN_KEY }
+
+export function isCopilotEnabled(): boolean {
+ return enabled
+}
+
+export function setCopilotEnabled(value: boolean): void {
+ enabled = value
+
+ if (!value) {
+ if (debounceTimer) {
+ clearTimeout(debounceTimer)
+ debounceTimer = null
+ }
+ if (abortController) {
+ abortController.abort()
+ abortController = null
+ }
+ }
+}
diff --git a/src/plugins/inlineSuggestionPlugin.ts b/src/plugins/inlineSuggestionPlugin.ts
deleted file mode 100644
index f4cfdb1..0000000
--- a/src/plugins/inlineSuggestionPlugin.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { Plugin, PluginKey, Decoration, DecorationSet } from '@milkdown/prose/state'
-import { EditorView } from '@milkdown/prose/view'
-import { fetchSuggestion } from '../utils/api.js'
-import { API_URL } from '../utils/config.js'
-
-const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion')
-const DEBOUNCE_MS = 300
-
-interface InlineSuggestionState {
- suggestion: string
- suggestionPos: { from: number; to: number }
-}
-
-function createGhostTextDecoration(from: number, to: number, text: string) {
- return Decoration.inline(from, to, {
- class: 'ghost-text-decoration',
- 'data-suggestion': text,
- }, { side: 1 })
-}
-
-export function createInlineSuggestionPlugin(options: { apiUrl?: string } = {}) {
- const apiUrl = options.apiUrl || API_URL
-
- return new Plugin({
- key: INLINE_SUGGESTION_KEY,
- state: {
- init: (): InlineSuggestionState => ({
- suggestion: '',
- suggestionPos: { from: 0, to: 0 }
- }),
- apply: (tr, value): InlineSuggestionState => {
- if (!tr.docChanged) return value
-
- const { from, to } = tr.selection
- if (from === value.suggestionPos.from && to === value.suggestionPos.to) {
- return value
- }
- return { suggestion: '', suggestionPos: { from: 0, to: 0 } }
- },
- },
- props: {
- decorations: (state) => {
- const pluginState = INLINE_SUGGESTION_KEY.getState(state)
- if (!pluginState.suggestion) {
- return DecorationSet.empty
- }
- return DecorationSet.create(state.doc, [
- createGhostTextDecoration(
- pluginState.suggestionPos.from,
- pluginState.suggestionPos.from + pluginState.suggestion.length,
- pluginState.suggestion
- )
- ])
- },
- handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
- const state = INLINE_SUGGESTION_KEY.getState(view.state)
-
- if (event.key === 'Tab' && state.suggestion) {
- event.preventDefault()
- const { state: currentState, dispatch } = view
- dispatch(currentState.tr.insertText(state.suggestion, currentState.selection.from))
- dispatch(currentState.tr.setMeta(INLINE_SUGGESTION_KEY, {
- suggestion: '',
- suggestionPos: { from: 0, to: 0 }
- }))
- return true
- }
-
- if (event.key === 'Escape' && state.suggestion) {
- event.preventDefault()
- view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, {
- suggestion: '',
- suggestionPos: { from: 0, to: 0 }
- }))
- return true
- }
-
- if (state.suggestion) {
- view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, {
- suggestion: '',
- suggestionPos: { from: 0, to: 0 }
- }))
- }
-
- return false
- },
- },
- appendTransaction: (transactions, oldState, newState) => {
- const lastTr = transactions[transactions.length - 1]
- if (!lastTr || !lastTr.docChanged) return null
-
- const currentState = INLINE_SUGGESTION_KEY.getState(newState)
- const { from, to } = newState.selection
-
- if (currentState.suggestion && from === currentState.suggestionPos.from) {
- return null
- }
-
- setTimeout(async () => {
- const prefix = newState.doc.textBetween(0, from)
- const suffix = newState.doc.textBetween(to, newState.doc.content.size)
-
- try {
- const text = await fetchSuggestion(prefix, suffix, apiUrl)
-
- if (text && newState.selection.from === from) {
- const newPluginState = {
- suggestion: text,
- suggestionPos: { from, to: from + text.length }
- }
-
- newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, newPluginState))
- }
- } catch (e) {
- console.error('Inline suggestion error:', e)
- }
- }, DEBOUNCE_MS)
-
- return null
- },
- })
-}
-
-export { INLINE_SUGGESTION_KEY }
diff --git a/src/utils/api.js b/src/utils/api.js
index cc4cb23..903bdf9 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -1,30 +1,62 @@
-export async function fetchSuggestion(prefix, suffix, apiUrl = 'http://localhost:8000/v1/completions') {
- const res = await fetch(apiUrl, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
- })
+import { DEBUG, API_URL } from './config.js'
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`)
- }
+export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) {
+ if (DEBUG) console.log('[Debug] fetchSuggestion called with prefix length:', prefix.length, 'suffix length:', suffix.length)
+ try {
+ const res = await fetch(apiUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
+ signal
+ })
- const reader = res.body?.getReader()
- if (!reader) throw new Error('No reader available')
+ if (DEBUG) console.log('[Debug] fetchSuggestion response status:', res.status)
+ if (!res.ok) {
+ const errorText = await res.text()
+ throw new Error(`HTTP ${res.status}: ${errorText}`)
+ }
- let text = ''
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- const chunk = new TextDecoder().decode(value)
- const lines = chunk.split('\n').filter(l => l.startsWith('data: '))
- for (const line of lines) {
- try {
- const data = JSON.parse(line.slice(6))
- if (data.content) text += data.content
- if (data.done || data.error) break
- } catch (e) {}
+ const reader = res.body?.getReader()
+ if (!reader) {
+ if (DEBUG) console.log('[Debug] No reader available')
+ throw new Error('No reader available')
+ }
+
+ let text = ''
+ let buffer = ''
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += new TextDecoder().decode(value)
+
+ const lines = buffer.split('\n')
+ buffer = lines.pop() || ''
+
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue
+ const jsonStr = line.slice(6).trim()
+ if (!jsonStr) continue
+ try {
+ const data = JSON.parse(jsonStr)
+ if (data.content) {
+ text += data.content
+ if (DEBUG) console.log('[Debug] Added content:', data.content)
+ }
+ if (data.done || data.error) break
+ } catch (e) {
+ if (DEBUG) console.warn('[Debug] JSON parse error for:', jsonStr.substring(0, 50))
+ }
+ }
+ }
+
+ if (DEBUG) console.log('[Debug] Final suggestion text:', text.substring(0, 100))
+ return text
+ } catch (e) {
+ if (e.name === 'AbortError') {
+ if (DEBUG) console.log('[Debug] Request aborted')
+ } else {
+ if (DEBUG) console.error('[Debug] fetchSuggestion error:', e)
+ }
+ throw e
}
- }
- return text
}