From 55e28b8d3bb3f7b7792f1a177bf49cb9207ad410 Mon Sep 17 00:00:00 2001 From: ViperEkura <3081035982@qq.com> Date: Thu, 26 Mar 2026 20:01:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BF=AE=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/projects.py | 202 ++++++- docs/Design.md | 31 +- frontend/package-lock.json | 4 +- frontend/package.json | 4 +- frontend/src/App.vue | 343 +++++++++-- frontend/src/api/index.js | 40 ++ frontend/src/components/ChatView.vue | 44 +- frontend/src/components/FileExplorer.vue | 637 +++++++++++++++++++++ frontend/src/components/FileTreeItem.vue | 172 ++++++ frontend/src/components/ProcessBlock.vue | 2 +- frontend/src/components/ProjectManager.vue | 432 -------------- frontend/src/components/Sidebar.vue | 442 ++++++++------ frontend/src/styles/global.css | 50 +- frontend/src/utils/fileTree.js | 18 + frontend/src/utils/highlight.js | 14 + frontend/src/utils/markdown.js | 20 +- 16 files changed, 1711 insertions(+), 744 deletions(-) create mode 100644 frontend/src/components/FileExplorer.vue create mode 100644 frontend/src/components/FileTreeItem.vue delete mode 100644 frontend/src/components/ProjectManager.vue create mode 100644 frontend/src/utils/fileTree.js create mode 100644 frontend/src/utils/highlight.js diff --git a/backend/routes/projects.py b/backend/routes/projects.py index 0af6bf7..3c835d0 100644 --- a/backend/routes/projects.py +++ b/backend/routes/projects.py @@ -11,7 +11,8 @@ from backend.utils.workspace import ( create_project_directory, delete_project_directory, get_project_path, - save_uploaded_files + save_uploaded_files, + validate_path_in_project, ) bp = Blueprint("projects", __name__) @@ -325,3 +326,202 @@ def list_project_files(project_id): "total_files": len(files), "total_dirs": len(directories) }) + + +# --- REST file operation endpoints --- + +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB read limit +TEXT_EXTENSIONS = { + ".py", ".js", ".ts", ".jsx", ".tsx", ".vue", ".html", ".css", ".scss", ".less", + ".json", ".yaml", ".yml", ".toml", ".xml", ".csv", ".md", ".txt", ".log", + ".sh", ".bash", ".zsh", ".bat", ".ps1", ".cmd", + ".c", ".h", ".cpp", ".hpp", ".java", ".go", ".rs", ".rb", ".php", + ".sql", ".r", ".swift", ".kt", ".dart", ".lua", ".pl", ".m", + ".ini", ".cfg", ".conf", ".env", ".gitignore", ".dockerignore", + ".dockerfile", ".makefile", ".cmake", ".gradle", ".properties", + ".proto", ".graphql", ".tf", ".hcl", +} + + +def _resolve_file_path(project_id, filepath): + """Resolve and validate a file path within a project directory.""" + project = Project.query.get(project_id) + if not project: + return None, None, err(404, "Project not found") + project_dir = get_project_path(project.id, project.path) + try: + target = validate_path_in_project(filepath, project_dir) + except ValueError: + return None, None, err(403, "Invalid path: outside project directory") + return project_dir, target, None + + +@bp.route("/api/projects//files/", methods=["GET"]) +def read_project_file(project_id, filepath): + """Read a single file's content (text only).""" + project_dir, target, error = _resolve_file_path(project_id, filepath) + if error: + return error + + if not target.exists(): + return err(404, "File not found") + if not target.is_file(): + return err(400, "Path is not a file") + + if target.stat().st_size > MAX_FILE_SIZE: + return err(400, "File too large (max 5 MB)") + + try: + content = target.read_text(encoding="utf-8") + except UnicodeDecodeError: + return err(400, "Binary file, cannot preview as text") + + return ok({ + "name": target.name, + "path": str(target.relative_to(project_dir)), + "size": target.stat().st_size, + "extension": target.suffix, + "content": content, + }) + + +@bp.route("/api/projects//files/", methods=["PUT"]) +def write_project_file(project_id, filepath): + """Create or overwrite a file.""" + data = request.get_json() + if not data or "content" not in data: + return err(400, "Missing 'content' in request body") + + project_dir, target, error = _resolve_file_path(project_id, filepath) + if error: + return error + + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(data["content"], encoding="utf-8") + except Exception as e: + return err(500, f"Failed to write file: {str(e)}") + + return ok({ + "name": target.name, + "path": str(target.relative_to(project_dir)), + "size": target.stat().st_size, + }) + + +@bp.route("/api/projects//files/", methods=["DELETE"]) +def delete_project_file(project_id, filepath): + """Delete a file or empty directory.""" + project_dir, target, error = _resolve_file_path(project_id, filepath) + if error: + return error + + if not target.exists(): + return err(404, "File not found") + + try: + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + except Exception as e: + return err(500, f"Failed to delete: {str(e)}") + + return ok({"message": f"Deleted '{filepath}'"}) + + +@bp.route("/api/projects//files/mkdir", methods=["POST"]) +def create_project_directory_endpoint(project_id): + """Create a directory in the project.""" + data = request.get_json() + if not data or "path" not in data: + return err(400, "Missing 'path' in request body") + + project_dir, target, error = _resolve_file_path(project_id, data["path"]) + if error: + return error + + try: + target.mkdir(parents=True, exist_ok=True) + except FileExistsError: + return err(400, "Directory already exists") + except Exception as e: + return err(500, f"Failed to create directory: {str(e)}") + + return ok({ + "path": str(target.relative_to(project_dir)), + }) + + +@bp.route("/api/projects//search", methods=["POST"]) +def search_project_files(project_id): + """Search file contents (grep-like).""" + data = request.get_json() + if not data or "query" not in data: + return err(400, "Missing 'query' in request body") + + query = data["query"] + subdir = data.get("path", "") + max_results = min(data.get("max_results", 50), 200) + case_sensitive = data.get("case_sensitive", False) + + project = Project.query.get(project_id) + if not project: + return err(404, "Project not found") + + project_dir = get_project_path(project.id, project.path) + target_dir = project_dir / subdir if subdir else project_dir + + try: + target_dir = target_dir.resolve() + target_dir.relative_to(project_dir.resolve()) + except ValueError: + return err(403, "Invalid path: outside project directory") + + if not target_dir.exists(): + return err(404, "Directory not found") + + import re + flags = 0 if case_sensitive else re.IGNORECASE + try: + pattern = re.compile(re.escape(query), flags) + except re.error: + return err(400, "Invalid search pattern") + + results = [] + try: + for file_path in target_dir.rglob("*"): + if len(results) >= max_results: + break + if not file_path.is_file(): + continue + if file_path.name.startswith("."): + continue + # Skip binary files by extension + if file_path.suffix.lower() not in TEXT_EXTENSIONS and file_path.suffix != "": + continue + + try: + text = file_path.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + + matches = [] + for i, line in enumerate(text.splitlines(), 1): + if pattern.search(line): + matches.append({"line": i, "content": line}) + if sum(len(m.get("content", "")) for m in matches) > 10000: + break + if matches: + results.append({ + "path": str(file_path.relative_to(project_dir)), + "matches": matches, + }) + except Exception as e: + return err(500, f"Search failed: {str(e)}") + + return ok({ + "query": query, + "results": results, + "total_matches": len(results), + }) diff --git a/docs/Design.md b/docs/Design.md index 68e015b..f22f8bf 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -200,8 +200,8 @@ classDiagram } class GLMClient { - -str api_url - -str api_key + -dict model_config + +_get_credentials(model) (api_url, api_key) +call(model, messages, kwargs) Response } @@ -381,7 +381,12 @@ def process_tool_calls(self, tool_calls, context=None): | `PUT` | `/api/projects/:id` | 更新项目 | | `DELETE` | `/api/projects/:id` | 删除项目 | | `POST` | `/api/projects/upload` | 上传文件夹作为项目 | -| `GET` | `/api/projects/:id/files` | 列出项目文件 | +| `GET` | `/api/projects/:id/files` | 列出项目文件(支持 `?path=subdir` 子目录) | +| `GET` | `/api/projects/:id/files/:filepath` | 读取文件内容(文本文件,最大 5 MB) | +| `PUT` | `/api/projects/:id/files/:filepath` | 创建或覆盖文件(Body: `{"content": "..."}`) | +| `DELETE` | `/api/projects/:id/files/:filepath` | 删除文件或目录 | +| `POST` | `/api/projects/:id/files/mkdir` | 创建目录(Body: `{"path": "src/utils"}`) | +| `POST` | `/api/projects/:id/search` | 搜索文件内容(Body: `{"query": "...", "path": "", "max_results": 50, "case_sensitive": false}`) | ### 其他 @@ -723,9 +728,23 @@ if name.startswith("file_") and context and "project_id" in context: backend_port: 3000 frontend_port: 4000 -# LLM API -api_key: your-api-key -api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions +# LLM API(全局默认值,每个 model 可单独覆盖) +default_api_key: your-api-key +default_api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions + +# 可用模型列表 +models: + - id: glm-5 + name: GLM-5 + # api_key: ... # 可选,不指定则用 default_api_key + # api_url: ... # 可选,不指定则用 default_api_url + - id: glm-5-turbo + name: GLM-5 Turbo + api_key: another-key # 该模型使用独立凭证 + api_url: https://other.api.com/chat/completions + +# 默认模型 +default_model: glm-5 # 工作区根目录 workspace_root: ./workspaces diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2b6fc93..20dbea4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,9 @@ "name": "nano-claw", "version": "0.1.0", "dependencies": { - "highlight.js": "^11.10.0", + "highlight.js": "^11.11.1", "katex": "^0.16.40", - "marked": "^15.0.0", + "marked": "^15.0.12", "marked-highlight": "^2.2.3", "vue": "^3.4.0" }, diff --git a/frontend/package.json b/frontend/package.json index e368977..9a369db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,9 +9,9 @@ "preview": "vite preview" }, "dependencies": { - "highlight.js": "^11.10.0", + "highlight.js": "^11.11.1", "katex": "^0.16.40", - "marked": "^15.0.0", + "marked": "^15.0.12", "marked-highlight": "^2.2.3", "vue": "^3.4.0" }, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 52e8af1..42e2a1f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,15 +5,39 @@ :current-id="currentConvId" :loading="loadingConvs" :has-more="hasMoreConvs" - :current-project="currentProject" @select="selectConversation" - @create="createConversation" @delete="deleteConversation" @load-more="loadMoreConversations" - @select-project="selectProject" + @create-project="showCreateModal = true" + @browse-project="browseProject" + @create-in-project="createConversationInProject" + @toggle-settings="togglePanel('settings')" + @toggle-stats="togglePanel('stats')" /> + +
+
+
浏览文件
+
{{ currentProject.name }}
+
+ +
+ +
+
+ + + +

当前对话未关联项目

+
+
+ + + + @@ -60,10 +115,11 @@ import { ref, shallowRef, computed, onMounted, defineAsyncComponent } from 'vue' import Sidebar from './components/Sidebar.vue' import ChatView from './components/ChatView.vue' +import FileExplorer from './components/FileExplorer.vue' const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue')) const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue')) -import { conversationApi, messageApi } from './api' +import { conversationApi, messageApi, projectApi } from './api' // -- Conversations state -- const conversations = shallowRef([]) @@ -88,28 +144,14 @@ const streamProcessSteps = shallowRef([]) // 保存每个对话的流式状态 const streamStates = new Map() -// 重置当前流式状态(用于 sendMessage / regenerateMessage / onError) -function resetStreamState() { - streaming.value = false +function setStreamState(isActive) { + streaming.value = isActive streamContent.value = '' streamThinking.value = '' streamToolCalls.value = [] streamProcessSteps.value = [] } -// 初始化流式状态(用于 sendMessage / regenerateMessage 开始时) -function initStreamState() { - streaming.value = true - streamContent.value = '' - streamThinking.value = '' - streamToolCalls.value = [] - streamProcessSteps.value = [] -} - -// 辅助:更新当前对话或缓存的流式字段 -// field: streamStates 中保存的字段名 -// ref: 当前激活对话对应的 Vue ref -// valueOrUpdater: 静态值或 (current) => newValue function updateStreamField(convId, field, ref, valueOrUpdater) { const isCurrent = currentConvId.value === convId const current = isCurrent ? ref.value : (streamStates.get(convId) || {})[field] @@ -125,8 +167,13 @@ function updateStreamField(convId, field, ref, valueOrUpdater) { // -- UI state -- const showSettings = ref(false) const showStats = ref(false) -const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启 -const currentProject = ref(null) // Current selected project +const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') +const currentProject = ref(null) +const showFileExplorer = ref(false) +const showCreateModal = ref(false) +const newProjectName = ref('') +const newProjectDesc = ref('') +const creatingProject = ref(false) function togglePanel(panel) { if (panel === 'settings') { @@ -142,13 +189,12 @@ const currentConv = computed(() => conversations.value.find(c => c.id === currentConvId.value) || null ) -// -- Load conversations -- +// -- Load conversations (all, no project filter) -- async function loadConversations(reset = true) { if (loadingConvs.value) return loadingConvs.value = true try { - const projectId = currentProject.value?.id || null - const res = await conversationApi.list(reset ? null : nextConvCursor.value, 20, projectId) + const res = await conversationApi.list(reset ? null : nextConvCursor.value, 20) if (reset) { conversations.value = res.data.items } else { @@ -167,12 +213,18 @@ function loadMoreConversations() { if (hasMoreConvs.value) loadConversations(false) } -// -- Create conversation -- -async function createConversation() { +// -- Create conversation in specific project -- +async function createConversationInProject(project) { + showFileExplorer.value = false + if (project.id) { + currentProject.value = { id: project.id, name: project.name } + } else { + currentProject.value = null + } try { const res = await conversationApi.create({ title: '新对话', - project_id: currentProject.value?.id || null, + project_id: project.id || null, }) conversations.value = [res.data, ...conversations.value] await selectConversation(res.data.id) @@ -181,9 +233,22 @@ async function createConversation() { } } -// -- Select conversation -- + +// -- Select conversation (auto-set project context) -- async function selectConversation(id) { - // 保存当前对话的流式状态和消息列表(如果有) + showFileExplorer.value = false + + // Auto-set project context based on conversation + const conv = conversations.value.find(c => c.id === id) + if (conv?.project_id) { + if (!currentProject.value || currentProject.value.id !== conv.project_id) { + currentProject.value = { id: conv.project_id, name: conv.project_name || '' } + } + } else { + currentProject.value = null + } + + // Save current streaming state if (currentConvId.value && streaming.value) { streamStates.set(currentConvId.value, { streaming: true, @@ -191,7 +256,7 @@ async function selectConversation(id) { streamThinking: streamThinking.value, streamToolCalls: [...streamToolCalls.value], streamProcessSteps: [...streamProcessSteps.value], - messages: [...messages.value], // 保存消息列表(包括临时用户消息) + messages: [...messages.value], }) } @@ -199,7 +264,7 @@ async function selectConversation(id) { nextMsgCursor.value = null hasMoreMessages.value = false - // 恢复新对话的流式状态 + // Restore streaming state for new conversation const savedState = streamStates.get(id) if (savedState && savedState.streaming) { streaming.value = true @@ -207,13 +272,12 @@ async function selectConversation(id) { streamThinking.value = savedState.streamThinking streamToolCalls.value = savedState.streamToolCalls streamProcessSteps.value = savedState.streamProcessSteps - messages.value = savedState.messages || [] // 恢复消息列表 + messages.value = savedState.messages || [] } else { - resetStreamState() + setStreamState(false) messages.value = [] } - // 如果不是正在流式传输,从服务器加载消息 if (!streaming.value) { await loadMessages(true) } @@ -226,7 +290,6 @@ async function loadMessages(reset = true) { try { const res = await messageApi.list(currentConvId.value, reset ? null : nextMsgCursor.value) if (reset) { - // Filter out tool messages (they're merged into assistant messages) messages.value = res.data.items.filter(m => m.role !== 'tool') } else { messages.value = [...res.data.items.filter(m => m.role !== 'tool'), ...messages.value] @@ -295,7 +358,7 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) { token_count: data.token_count, created_at: new Date().toISOString(), }] - resetStreamState() + setStreamState(false) if (updateConvList) { const idx = conversations.value.findIndex(c => c.id === convId) @@ -329,7 +392,7 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) { onError(msg) { streamStates.delete(convId) if (currentConvId.value === convId) { - resetStreamState() + setStreamState(false) console.error('Stream error:', msg) } }, @@ -355,7 +418,7 @@ async function sendMessage(data) { } messages.value = [...messages.value, userMsg] - initStreamState() + setStreamState(true) messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, { toolsEnabled: toolsEnabled.value, @@ -384,7 +447,7 @@ async function regenerateMessage(msgId) { messages.value = messages.value.slice(0, msgIndex) - initStreamState() + setStreamState(true) messageApi.regenerate(convId, msgId, { toolsEnabled: toolsEnabled.value, @@ -404,6 +467,7 @@ async function deleteConversation(id) { await selectConversation(currentConvId.value) } else { messages.value = [] + currentProject.value = null } } } catch (e) { @@ -418,7 +482,7 @@ async function saveSettings(data) { const res = await conversationApi.update(currentConvId.value, data) const idx = conversations.value.findIndex(c => c.id === currentConvId.value) if (idx !== -1) { - conversations.value = conversations.value.map((c, i) => i === idx ? { ...c, ...res.data } : c) + conversations.value = conversations.value.map((c, i) => i === idx ? { ...c, ...res.data } : c) } } catch (e) { console.error('Failed to save settings:', e) @@ -431,12 +495,30 @@ function updateToolsEnabled(val) { localStorage.setItem('tools_enabled', String(val)) } -// -- Select project -- -function selectProject(project) { +// -- Browse project files -- +function browseProject(project) { currentProject.value = project - // Reload conversations filtered by the selected project - nextConvCursor.value = null - loadConversations(true) + showFileExplorer.value = true +} + +// -- Create project -- +async function createProject() { + if (!newProjectName.value.trim()) return + creatingProject.value = true + try { + await projectApi.create({ + user_id: 1, + name: newProjectName.value.trim(), + description: newProjectDesc.value.trim(), + }) + showCreateModal.value = false + newProjectName.value = '' + newProjectDesc.value = '' + } catch (e) { + console.error('Failed to create project:', e) + } finally { + creatingProject.value = false + } } // -- Init -- @@ -451,6 +533,65 @@ onMounted(() => { height: 100%; } +.file-explorer-wrap { + flex: 1 1 0; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + min-width: 0; +} + +.explorer-topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.topbar-label { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.topbar-project-name { + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-secondary); + padding: 4px 10px; + border-radius: 6px; +} + +.explorer-body { + flex: 1; + overflow: hidden; + min-height: 0; + display: flex; +} + +.explorer-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-tertiary); + font-size: 14px; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + .modal-content { border-radius: 16px; width: 90%; @@ -464,4 +605,114 @@ onMounted(() => { border: 1px solid var(--border-medium); box-shadow: 0 25px 60px rgba(0, 0, 0, 0.2); } + +/* -- Create project modal -- */ +.create-modal { + background: var(--bg-primary); + border: 1px solid var(--border-medium); + border-radius: 12px; + width: 90%; + max-width: 440px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.btn-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: none; + color: var(--text-tertiary); + cursor: pointer; + border-radius: 6px; + transition: all 0.15s; +} + +.btn-icon:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 8px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-light); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + outline: none; + box-sizing: border-box; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group textarea:focus { + border-color: var(--accent-primary); +} + +.form-group textarea { + resize: vertical; + min-height: 60px; + font-family: inherit; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 20px; + border-top: 1px solid var(--border-light); +} + +.btn-secondary { + padding: 8px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-light); + border-radius: 8px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.btn-secondary:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.btn-primary { + padding: 8px 16px; + background: var(--accent-primary); + border: none; + border-radius: 8px; + color: #fff; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary:hover { + opacity: 0.9; +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e6141ec..266152b 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -233,4 +233,44 @@ export const projectApi = { return json }) }, + + listFiles(projectId, path = '') { + return request(`/projects/${projectId}/files${buildQueryParams({ path })}`) + }, + + readFile(projectId, filepath) { + return request(`/projects/${projectId}/files/${filepath}`) + }, + + readFileRaw(projectId, filepath) { + return fetch(`${BASE}/projects/${projectId}/files/${filepath}`).then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res + }) + }, + + writeFile(projectId, filepath, content) { + return request(`/projects/${projectId}/files/${filepath}`, { + method: 'PUT', + body: { content }, + }) + }, + + deleteFile(projectId, filepath) { + return request(`/projects/${projectId}/files/${filepath}`, { method: 'DELETE' }) + }, + + mkdir(projectId, dirPath) { + return request(`/projects/${projectId}/files/mkdir`, { + method: 'POST', + body: { path: dirPath }, + }) + }, + + search(projectId, query, options = {}) { + return request(`/projects/${projectId}/search`, { + method: 'POST', + body: { query, ...options }, + }) + }, } diff --git a/frontend/src/components/ChatView.vue b/frontend/src/components/ChatView.vue index de33f8f..db651c0 100644 --- a/frontend/src/components/ChatView.vue +++ b/frontend/src/components/ChatView.vue @@ -1,5 +1,5 @@