From 2abf276d107f12ad897619ee6fa8ba491734d36a Mon Sep 17 00:00:00 2001 From: ydy0615 Date: Sat, 7 Feb 2026 08:53:37 +0800 Subject: [PATCH] feat: switch from OpenAI API to native Ollama Python client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 10 + README.md | 211 +++++++- backend/.env | 2 +- backend/__pycache__/llm.cpython-313.pyc | Bin 0 -> 2609 bytes backend/__pycache__/prompt.cpython-313.pyc | Bin 0 -> 1327 bytes backend/llm.py | 81 ++- backend/main.py | 175 +++++-- backend/prompt.py | 24 +- backend/requirements.txt | 3 +- package-lock.json | 9 - plans/inline-suggestions-plan.md | 560 ++++++++++++++++++++- plans/milkdown-editor-plan.md | 363 ++++++++++++- src/components/GhostTextOverlay.vue | 41 +- src/components/MilkdownEditor.vue | 290 ++++++----- src/plugins/inlineSuggestionPlugin.ts | 145 ++---- src/utils/api.js | 52 ++ src/utils/config.js | 2 + 17 files changed, 1564 insertions(+), 404 deletions(-) create mode 100644 .env.example create mode 100644 backend/__pycache__/llm.cpython-313.pyc create mode 100644 backend/__pycache__/prompt.cpython-313.pyc create mode 100644 src/utils/api.js create mode 100644 src/utils/config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e392145 --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 1511959..8c8d0af 100644 --- a/README.md +++ b/README.md @@ -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 ` + + +``` ### 3. 修改 MilkdownEditor 集成插件 **文件**: `src/components/MilkdownEditor.vue` -- 注册 InlineSuggestionPlugin 到 Crepe 实例 -- 配置 API 地址 + +#### 集成要点 + +```vue + + + +``` ### 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 模式开始实现。 diff --git a/plans/milkdown-editor-plan.md b/plans/milkdown-editor-plan.md index 5f62877..44648b9 100644 --- a/plans/milkdown-editor-plan.md +++ b/plans/milkdown-editor-plan.md @@ -16,7 +16,7 @@ graph TB A[App.vue] --> B[MilkdownProvider] B --> C[Milkdown Editor - Crepe] - + subgraph "Crepe 核心功能" D[WYSWIYG 编辑体验] E[Markdown 语法即时渲染] @@ -24,6 +24,14 @@ graph TB G[代码块高亮] H[图片粘贴支持] end + + subgraph "集成功能" + I[GhostTextOverlay
建议文本显示] + J[InlineSuggestionPlugin
智能补全] + end + + C --> I + C --> J ``` ## 实施步骤 @@ -38,19 +46,280 @@ npm install @milkdown/crepe @milkdown/vue **文件**: `src/components/MilkdownEditor.vue` +#### 核心实现要点 + ```vue + + ``` ### 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 组件标签语法 `` 和 `` + +#### 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 -- 纯编辑器模式,无预览面板 \ No newline at end of file +- 纯编辑器模式,无预览面板 +- 自定义滚动条样式 + +## 性能优化建议 + +1. **防抖优化**: 保持 150ms 防抖,避免频繁请求 +2. **流式响应**: 使用 SSE 流式传输,降低延迟 +3. **上下文截取**: 智能截取上下文(光标前30行 + 后5行) +4. **内存管理**: 及时清理定时器和事件监听器 +5. **代码精简**: 移除冗余代码和注释 + +## 测试要点 + +1. 编辑器基本功能测试 +2. 建议功能测试(Tab 接受、Esc 取消、点击接受) +3. 错误处理测试(网络错误、API 错误) +4. 性能测试(大量文本输入) +5. 内存泄漏测试(长时间使用) diff --git a/src/components/GhostTextOverlay.vue b/src/components/GhostTextOverlay.vue index 1816c33..5ce31ef 100644 --- a/src/components/GhostTextOverlay.vue +++ b/src/components/GhostTextOverlay.vue @@ -1,43 +1,35 @@ diff --git a/src/plugins/inlineSuggestionPlugin.ts b/src/plugins/inlineSuggestionPlugin.ts index 06e613c..e15c3d4 100644 --- a/src/plugins/inlineSuggestionPlugin.ts +++ b/src/plugins/inlineSuggestionPlugin.ts @@ -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 | null; + currentSuggestion: string; + suggestionPos: { from: number; to: number }; +} - return new Plugin({ +function createInlineSuggestionPlugin(options: InlineSuggestionOptions = {}) { + const apiUrl = options.apiUrl || API_URL; + + return new Plugin({ 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'); - - clearTimeout(debounceTimer); - debounceTimer = setTimeout(async () => { + const currentState = INLINE_SUGGESTION_KEY.getState(newState); + + 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); diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..53a03dd --- /dev/null +++ b/src/utils/api.js @@ -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 + } +} diff --git a/src/utils/config.js b/src/utils/config.js new file mode 100644 index 0000000..6152c7e --- /dev/null +++ b/src/utils/config.js @@ -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'