From 65d4a57d3340b672c23ed90ae9a4b9de35dfdf13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cydy0615=E2=80=9D?= <“allenyuan410@gmail.com”> Date: Fri, 13 Feb 2026 09:24:50 +0800 Subject: [PATCH] refactor(editor): migrate to ProseMirror Mark-based ghost text system - Replace overlay-based GhostTextOverlay.vue with ProseMirror Mark system - Add AI toggle button with enable/disable functionality - Implement new copilotPlugin.ts using copilotGhostMark for inline suggestions - Fix cursor position offset in prompt.py by moving first suffix char to prefix - Improve API error handling with abort signal support and debug logging - Update model configuration from gpt-oss:120b to gpt-oss:20b - Add button tooltips and improve editor styling - Remove deprecated inlineSuggestionPlugin.ts - Update README with new architecture diagram and feature documentation --- .env | 1 + README.md | 204 ++++++------- backend/.env | 2 +- backend/__pycache__/prompt.cpython-313.pyc | Bin 1327 -> 1493 bytes backend/llm.py | 4 +- backend/main.py | 5 +- backend/prompt.py | 7 + src/components/GhostTextOverlay.vue | 57 ---- src/components/MilkdownEditor.vue | 337 ++++++++++++++------- src/plugins/copilotPlugin.ts | 234 ++++++++++++++ src/plugins/inlineSuggestionPlugin.ts | 124 -------- src/utils/api.js | 82 +++-- 12 files changed, 617 insertions(+), 440 deletions(-) create mode 100644 .env delete mode 100644 src/components/GhostTextOverlay.vue create mode 100644 src/plugins/copilotPlugin.ts delete mode 100644 src/plugins/inlineSuggestionPlugin.ts 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 0e26134bd6cf83e22cd922a92cfec8b37634d8ec..f7020a7b9da20f8cf3470d98875ecee5a5da881c 100644 GIT binary patch delta 523 zcmZ3_b(Nd%GcPX}0}$vQ>&=|aGLf&-goBZRVJbr~V;)l|qXtL{2om`i7(y9?nOK4B zJmv^yT@VctVGd?tVaQ{gxKNy%)fA`^1oBuW9#$2EbNw0h8S+?zStlzpiikuwX)%Db zz}2#WB}7$OgIR*vki^-8*+bd*7=k%M*?}adF>4-2D97YzMtMfA$<>UNqD4$Vn_hx! z^t;8#^%CT;(%#Mg8C@Bjbrn2)1N?(R9Q{J96oN| z`T~pSH#Pu|>Oxt9nS)u7)UgG#hO+W81ha**0ZDda z);#u5_8?Y-4U;D_3Nvy{UdmX>eT$LH?4m<#4Qe+-29Z%oK(9a>B;k1C1vFprDh0!U;t7J OlEKu9 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 @@ - - - - - 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 }