From ea425cf9a686f55fc8a5676a3e556168fad7680f Mon Sep 17 00:00:00 2001 From: ViperEkura <3081035982@qq.com> Date: Fri, 27 Mar 2026 11:12:07 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=20=E4=BC=98=E5=8C=96SSE=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config.py | 3 + backend/routes/projects.py | 124 +++++++--- backend/services/chat.py | 54 ++-- docs/Design.md | 67 ++--- frontend/src/App.vue | 44 +--- frontend/src/api/index.js | 23 +- frontend/src/components/ChatView.vue | 6 +- frontend/src/components/FileExplorer.vue | 13 - frontend/src/components/FileTreeItem.vue | 298 +++++++++++++++++++++-- frontend/src/components/ProcessBlock.vue | 45 +--- 10 files changed, 466 insertions(+), 211 deletions(-) diff --git a/backend/config.py b/backend/config.py index 0cd03b9..6df0e52 100644 --- a/backend/config.py +++ b/backend/config.py @@ -21,3 +21,6 @@ for _m in MODELS: } DEFAULT_MODEL = _cfg.get("default_model", "glm-5") + +# Max agentic loop iterations (tool call rounds) +MAX_ITERATIONS = _cfg.get("max_iterations", 5) diff --git a/backend/routes/projects.py b/backend/routes/projects.py index a984739..7d0f7e8 100644 --- a/backend/routes/projects.py +++ b/backend/routes/projects.py @@ -2,6 +2,7 @@ import os import uuid import shutil +from datetime import datetime, timezone from flask import Blueprint, request, g from backend import db @@ -18,26 +19,46 @@ from backend.utils.workspace import ( bp = Blueprint("projects", __name__) +def _get_project(project_id, check_owner=True): + """Get project with optional ownership check.""" + project = db.session.get(Project, project_id) + if not project: + return None + if check_owner and project.user_id != g.current_user.id: + return None + return project + + @bp.route("/api/projects", methods=["GET"]) def list_projects(): """List all projects for current user""" user = g.current_user - projects = Project.query.filter_by(user_id=user.id).order_by(Project.updated_at.desc()).all() - + cursor = request.args.get("cursor") + limit = min(int(request.args.get("limit", 20)), 100) + q = Project.query.filter_by(user_id=user.id) + + if cursor: + q = q.filter(Project.updated_at < ( + db.session.query(Project.updated_at).filter_by(id=cursor).scalar() or datetime.now(timezone.utc))) + rows = q.order_by(Project.updated_at.desc()).limit(limit + 1).all() + + items = [ + { + "id": p.id, + "name": p.name, + "path": p.path, + "description": p.description, + "created_at": p.created_at.isoformat() if p.created_at else None, + "updated_at": p.updated_at.isoformat() if p.updated_at else None, + "conversation_count": p.conversations.count(), + } + for p in rows[:limit] + ] return ok({ - "projects": [ - { - "id": p.id, - "name": p.name, - "path": p.path, - "description": p.description, - "created_at": p.created_at.isoformat() if p.created_at else None, - "updated_at": p.updated_at.isoformat() if p.updated_at else None, - "conversation_count": p.conversations.count() - } - for p in projects - ], - "total": len(projects) + "items": items, + "next_cursor": items[-1]["id"] if len(rows) > limit else None, + "has_more": len(rows) > limit, + "total": Project.query.filter_by(user_id=user.id).count(), }) @@ -91,8 +112,9 @@ def create_project(): @bp.route("/api/projects/", methods=["GET"]) def get_project(project_id): """Get project details""" - project = Project.query.get(project_id) - + user = g.current_user + project = _get_project(project_id) + if not project: return err(404, "Project not found") @@ -124,7 +146,8 @@ def get_project(project_id): @bp.route("/api/projects/", methods=["PUT"]) def update_project(project_id): """Update project details""" - project = Project.query.get(project_id) + user = g.current_user + project = _get_project(project_id) if not project: return err(404, "Project not found") @@ -169,10 +192,9 @@ def update_project(project_id): @bp.route("/api/projects/", methods=["DELETE"]) def delete_project(project_id): """Delete a project""" - user = g.current_user - project = Project.query.get(project_id) - - if not project or project.user_id != user.id: + project = _get_project(project_id) + + if not project: return err(404, "Project not found") # Delete project directory @@ -247,8 +269,8 @@ def upload_project_folder(): @bp.route("/api/projects//files", methods=["GET"]) def list_project_files(project_id): """List files in a project directory""" - project = Project.query.get(project_id) - + project = _get_project(project_id) + if not project: return err(404, "Project not found") @@ -326,7 +348,7 @@ TEXT_EXTENSIONS = { def _resolve_file_path(project_id, filepath): """Resolve and validate a file path within a project directory.""" - project = Project.query.get(project_id) + project = _get_project(project_id) if not project: return None, None, err(404, "Project not found") project_dir = get_project_path(project.id, project.path) @@ -390,9 +412,43 @@ def write_project_file(project_id, filepath): }) +@bp.route("/api/projects//files/", methods=["PATCH"]) +def rename_project_file(project_id, filepath): + """Rename or move a file/directory.""" + data = request.get_json() + if not data or "new_path" not in data: + return err(400, "Missing 'new_path' in request body") + + 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: + new_target = validate_path_in_project(data["new_path"], project_dir) + except ValueError: + return err(403, "Invalid path: outside project directory") + + if new_target.exists(): + return err(400, "Destination already exists") + + try: + new_target.parent.mkdir(parents=True, exist_ok=True) + target.rename(new_target) + except Exception as e: + return err(500, f"Failed to rename: {str(e)}") + + return ok({ + "name": new_target.name, + "path": str(new_target.relative_to(project_dir)), + }) + + @bp.route("/api/projects//files/", methods=["DELETE"]) def delete_project_file(project_id, filepath): - """Delete a file or empty directory.""" + """Delete a file or directory.""" project_dir, target, error = _resolve_file_path(project_id, filepath) if error: return error @@ -411,16 +467,22 @@ def delete_project_file(project_id, filepath): return ok({"message": f"Deleted '{filepath}'"}) -@bp.route("/api/projects//files/mkdir", methods=["POST"]) +@bp.route("/api/projects//directories", methods=["POST"]) def create_project_directory_endpoint(project_id): """Create a directory in the project.""" + project = _get_project(project_id) + if not project: + return err(404, "Project not found") + 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 + project_dir = get_project_path(project.id, project.path) + try: + target = validate_path_in_project(data["path"], project_dir) + except ValueError: + return err(403, "Invalid path: outside project directory") try: target.mkdir(parents=True, exist_ok=True) @@ -446,7 +508,7 @@ def search_project_files(project_id): max_results = min(data.get("max_results", 50), 200) case_sensitive = data.get("case_sensitive", False) - project = Project.query.get(project_id) + project = _get_project(project_id) if not project: return err(404, "Project not found") diff --git a/backend/services/chat.py b/backend/services/chat.py index 50fa1ef..44e24c2 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -10,13 +10,12 @@ from backend.utils.helpers import ( build_messages, ) from backend.services.glm_client import GLMClient +from backend.config import MAX_ITERATIONS class ChatService: """Chat completion service with tool support""" - MAX_ITERATIONS = 5 - def __init__(self, glm_client: GLMClient): self.glm_client = glm_client self.executor = ToolExecutor(registry=registry) @@ -60,15 +59,19 @@ class ChatService: # (each iteration re-sends the full context, so earlier # prompts are strict subsets of the final one) - for iteration in range(self.MAX_ITERATIONS): + for iteration in range(MAX_ITERATIONS): full_content = "" full_thinking = "" token_count = 0 msg_id = str(uuid.uuid4()) tool_calls_list = [] - # Clear state for new iteration - # (frontend resets via onProcessStep when first step arrives) + # Streaming step tracking — step ID is assigned on first chunk arrival. + # thinking always precedes text in GLM's streaming order, so text gets step_index+1. + thinking_step_id = None + thinking_step_idx = None + text_step_id = None + text_step_idx = None try: with app.app_context(): @@ -115,13 +118,19 @@ class ChatService: reasoning = delta.get("reasoning_content", "") if reasoning: full_thinking += reasoning - yield f"event: thinking\ndata: {json.dumps({'content': reasoning}, ensure_ascii=False)}\n\n" + if thinking_step_id is None: + thinking_step_id = f'step-{step_index}' + thinking_step_idx = step_index + yield f"event: process_step\ndata: {json.dumps({'id': thinking_step_id, 'index': thinking_step_idx, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n" # Accumulate text content for this iteration text = delta.get("content", "") if text: full_content += text - yield f"event: message\ndata: {json.dumps({'content': text}, ensure_ascii=False)}\n\n" + if text_step_id is None: + text_step_idx = step_index + (1 if thinking_step_id is not None else 0) + text_step_id = f'step-{text_step_idx}' + yield f"event: process_step\ndata: {json.dumps({'id': text_step_id, 'index': text_step_idx, 'type': 'text', 'content': full_content}, ensure_ascii=False)}\n\n" # Accumulate tool calls from streaming deltas tool_calls_list = self._process_tool_calls_delta(delta, tool_calls_list) @@ -130,27 +139,20 @@ class ChatService: yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n" return - # --- Finalize thinking/text steps for this iteration (common to both paths) --- - if full_thinking: - step_data = { - 'id': f'step-{step_index}', - 'index': step_index, - 'type': 'thinking', - 'content': full_thinking, - } - all_steps.append(step_data) - yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" + # --- Finalize: save thinking/text steps to all_steps for DB storage --- + # No need to yield to frontend — incremental process_step events already sent. + if thinking_step_id is not None: + all_steps.append({ + 'id': thinking_step_id, 'index': thinking_step_idx, + 'type': 'thinking', 'content': full_thinking, + }) step_index += 1 - if full_content: - step_data = { - 'id': f'step-{step_index}', - 'index': step_index, - 'type': 'text', - 'content': full_content, - } - all_steps.append(step_data) - yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" + if text_step_id is not None: + all_steps.append({ + 'id': text_step_id, 'index': text_step_idx, + 'type': 'text', 'content': full_content, + }) step_index += 1 # --- Branch: tool calls vs final --- diff --git a/docs/Design.md b/docs/Design.md index b2dc5b6..b7404e3 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -252,7 +252,6 @@ classDiagram class ChatService { -GLMClient glm_client -ToolExecutor executor - +Integer MAX_ITERATIONS +stream_response(conv, tools_enabled, project_id) Response -_build_tool_calls_json(calls, results) list -_process_tool_calls_delta(delta, list) list @@ -445,7 +444,7 @@ def process_tool_calls(self, tool_calls, context=None): | 方法 | 路径 | 说明 | | -------- | ----------------------------------- | ---------------------------------------------------------------------------------------- | -| `GET` | `/api/projects` | 获取项目列表 | +| `GET` | `/api/projects` | 获取项目列表(支持 `?cursor=&limit=` 分页) | | `POST` | `/api/projects` | 创建项目 | | `GET` | `/api/projects/:id` | 获取项目详情 | | `PUT` | `/api/projects/:id` | 更新项目 | @@ -454,8 +453,9 @@ def process_tool_calls(self, tool_calls, context=None): | `GET` | `/api/projects/:id/files` | 列出项目文件(支持 `?path=subdir` 子目录) | | `GET` | `/api/projects/:id/files/:filepath` | 读取文件内容(文本文件,最大 5 MB) | | `PUT` | `/api/projects/:id/files/:filepath` | 创建或覆盖文件(Body: `{"content": "..."}`) | +| `PATCH` | `/api/projects/:id/files/:filepath` | 重命名或移动文件/目录(Body: `{"new_path": "..."}`) | | `DELETE` | `/api/projects/:id/files/:filepath` | 删除文件或目录 | -| `POST` | `/api/projects/:id/files/mkdir` | 创建目录(Body: `{"path": "src/utils"}`) | +| `POST` | `/api/projects/:id/directories` | 创建目录(Body: `{"path": "src/utils"}`) | | `POST` | `/api/projects/:id/search` | 搜索文件内容(Body: `{"query": "...", "path": "", "max_results": 50, "case_sensitive": false}`) | ### 其他 @@ -472,48 +472,24 @@ def process_tool_calls(self, tool_calls, context=None): | 事件 | 说明 | | -------------- | ------------------------------------------------------------------------- | -| `thinking` | 思考过程的增量片段(实时流式输出) | -| `message` | 回复内容的增量片段(实时流式输出) | -| `process_step` | 有序处理步骤(thinking/text/tool_call/tool_result),支持穿插显示。携带 `id`、`index` 确保渲染顺序 | +| `process_step` | 有序处理步骤(thinking/text/tool_call/tool_result),支持穿插显示和实时流式更新。携带 `id`、`index` 确保渲染顺序 | | `error` | 错误信息 | | `done` | 回复结束,携带 message_id、token_count 和 suggested_title | -> **注意**:`thinking` 和 `message` 事件提供实时流式体验,每条 chunk 立即推送到前端。`process_step` 事件在每次迭代结束后发送完整内容,用于确定渲染顺序和 DB 存储。 - -### thinking / message 事件格式 - -实时流式事件,每条携带一个增量片段: - -```json -// 思考增量片段 -{"content": "正在分析用户需求..."} - -// 文本增量片段 -{"content": "根据分析结果"} -``` - -字段说明: - -| 字段 | 说明 | -| --------- | ---------------------------- | -| `content` | 增量文本片段(前端累积拼接为完整内容) | +> **注意**:`process_step` 是唯一的内容传输事件。thinking/text 步骤在每个 LLM chunk 到达时**增量发送**(前端按 `id` 原地更新),tool_call/tool_result 步骤在工具执行时**追加发送**。所有步骤在迭代结束时存入 DB。 ### process_step 事件格式 每个 `process_step` 事件携带一个带 `id`、`index` 和 `type` 的步骤对象。步骤按 `index` 顺序排列,确保前端可以正确渲染穿插的思考、文本和工具调用。 ```json -// 思考过程 {"id": "step-0", "index": 0, "type": "thinking", "content": "完整思考内容..."} -// 回复文本(可穿插在任意步骤之间) {"id": "step-1", "index": 1, "type": "text", "content": "回复文本内容..."} -// 工具调用(id_ref 存储工具调用 ID,用于与 tool_result 匹配) {"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_abc123", "name": "web_search", "arguments": "{\"query\": \"...\"}"} -// 工具返回(id_ref 与 tool_call 的 id_ref 匹配) {"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_abc123", "name": "web_search", "content": "{\"success\": true, ...}", "skipped": false} ``` @@ -555,6 +531,36 @@ def process_tool_calls(self, tool_calls, context=None): | `token_count` | 总输出 token 数(跨所有迭代累积) | | `suggested_title` | 建议会话标题(从首条用户消息提取,无标题时为 `"新对话"`,已有标题时为 `null`) | +### error 事件格式 + +```json +{"content": "exceeded maximum tool call iterations"} +``` + +| 字段 | 说明 | +| --------- | --------------------- | +| `content` | 错误信息字符串,前端展示给用户或打印到控制台 | + +### 前端 SSE 解析机制 + +前端不使用浏览器原生 `EventSource`(仅支持 GET),而是通过 `fetch` + `ReadableStream` 实现 POST 请求的 SSE 解析(`frontend/src/api/index.js`): + +1. **读取**:通过 `response.body.getReader()` 获取可读流,循环 `reader.read()` 读取二进制 chunk +2. **解码拼接**:`TextDecoder` 将二进制解码为 UTF-8 字符串,追加到 `buffer`(处理跨 chunk 的不完整行) +3. **切行**:按 `\n` 分割,最后一段保留在 `buffer` 中(可能是不完整的 SSE 行) +4. **解析分发**:逐行匹配 `event: xxx` 设置事件类型,`data: {...}` 解析 JSON 后分发到对应回调(`onThinking` / `onMessage` / `onProcessStep` / `onDone` / `onError`) + +``` +后端 yield: event: thinking\ndata: {"content":"..."}\n\n + ↓ TCP(可能跨多个网络包) +reader.read(): [二进制片段1] → [二进制片段2] → ... + ↓ +buffer 拼接: "event: thinking\ndata: {\"content\":\"...\"}\n\n" + ↓ split('\n') +逐行解析: event: → "thinking" + data: → JSON.parse → onThinking(data.content) +``` + --- ## 数据模型 @@ -935,6 +941,9 @@ models: # 默认模型 default_model: glm-5 +# 智能体循环最大迭代次数(工具调用轮次上限,默认 5) +max_iterations: 5 + # 工作区根目录 workspace_root: ./workspaces diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 12192c2..01fa6ae 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -43,8 +43,6 @@ :conversation="currentConv" :messages="messages" :streaming="streaming" - :streaming-content="streamContent" - :streaming-thinking="streamThinkingContent" :streaming-process-steps="streamProcessSteps" :has-more-messages="hasMoreMessages" :loading-more="loadingMessages" @@ -135,13 +133,11 @@ const loadingMessages = ref(false) const nextMsgCursor = ref(null) // -- Streaming state -- -// These refs hold the real-time streaming data for the current conversation. -// When switching conversations, the current state is saved to streamStates Map -// and restored when switching back. On stream completion (onDone), the finalized -// processSteps are stored in the message object and later persisted to DB. +// processSteps is the single source of truth for all streaming content. +// thinking/text steps are sent incrementally via process_step events and +// updated in-place by id. tool_call/tool_result steps are appended on arrival. +// On stream completion (onDone), the finalized steps are stored in the message object. const streaming = ref(false) -const streamContent = ref('') // Accumulated text content during current iteration -const streamThinkingContent = ref('') // Accumulated thinking content during current iteration const streamProcessSteps = shallowRef([]) // Ordered steps: thinking/text/tool_call/tool_result // 保存每个对话的流式状态 @@ -149,8 +145,6 @@ const streamStates = new Map() function setStreamState(isActive) { streaming.value = isActive - streamContent.value = '' - streamThinkingContent.value = '' streamProcessSteps.value = [] } @@ -251,8 +245,6 @@ async function selectConversation(id) { if (currentConvId.value && streaming.value) { streamStates.set(currentConvId.value, { streaming: true, - streamContent: streamContent.value, - streamThinkingContent: streamThinkingContent.value, streamProcessSteps: [...streamProcessSteps.value], messages: [...messages.value], }) @@ -266,8 +258,6 @@ async function selectConversation(id) { const savedState = streamStates.get(id) if (savedState && savedState.streaming) { streaming.value = true - streamContent.value = savedState.streamContent - streamThinkingContent.value = savedState.streamThinkingContent || '' streamProcessSteps.value = savedState.streamProcessSteps messages.value = savedState.messages || [] } else { @@ -307,30 +297,16 @@ function loadMoreMessages() { // -- Helpers: create stream callbacks for a conversation -- function createStreamCallbacks(convId, { updateConvList = true } = {}) { return { - onMessage(text) { - updateStreamField(convId, 'streamContent', streamContent, prev => (prev || '') + text) - }, - onThinking(text) { - updateStreamField(convId, 'streamThinkingContent', streamThinkingContent, prev => (prev || '') + text) - }, onProcessStep(step) { - // Insert step at its index position to preserve ordering. - // Uses sparse array strategy: fills gaps with null. - // Each step carries { id, index, type, content, ... } — - // these are the same steps that get stored to DB as the 'steps' array. + // Update or insert step by index position. + // thinking/text steps are sent incrementally with the same id — each update + // replaces the previous content at that index. tool_call/tool_result are appended. updateStreamField(convId, 'streamProcessSteps', streamProcessSteps, prev => { const steps = prev ? [...prev] : [] while (steps.length <= step.index) steps.push(null) steps[step.index] = step return steps }) - // When a step is finalized, reset the corresponding streaming content - // to prevent duplication (the content is now rendered via processSteps). - if (step.type === 'text') { - updateStreamField(convId, 'streamContent', streamContent, () => '') - } else if (step.type === 'thinking') { - updateStreamField(convId, 'streamThinkingContent', streamThinkingContent, () => '') - } }, async onDone(data) { streamStates.delete(convId) @@ -341,11 +317,9 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) { // Build the final message object. // process_steps is the primary ordered data for rendering (thinking/text/tool_call/tool_result). // When page reloads, these steps are loaded from DB via the 'steps' field in content JSON. - // NOTE: streamContent is already '' at this point (reset by process_step text event), - // so extract text from the last text step in processSteps. const steps = streamProcessSteps.value.filter(Boolean) const textSteps = steps.filter(s => s.type === 'text') - const lastText = textSteps.length > 0 ? textSteps[textSteps.length - 1].content : streamContent.value + const lastText = textSteps.length > 0 ? textSteps[textSteps.length - 1].content : '' // Derive legacy tool_calls from processSteps (backward compat for DB and MessageBubble fallback) const toolCallSteps = steps.filter(s => s && s.type === 'tool_call') @@ -539,7 +513,7 @@ async function createProject() { async function loadProjects() { try { const res = await projectApi.list() - projects.value = res.data.projects || [] + projects.value = res.data.items || [] } catch (e) { console.error('Failed to load projects:', e) } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 0fd92a5..ea4f9d4 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -29,10 +29,10 @@ async function request(url, options = {}) { * Shared SSE stream processor - parses SSE events and dispatches to callbacks * @param {string} url - API URL (without BASE prefix) * @param {object} body - Request body - * @param {object} callbacks - Event handlers: { onMessage, onThinking, onProcessStep, onDone, onError } + * @param {object} callbacks - Event handlers: { onProcessStep, onDone, onError } * @returns {{ abort: () => void }} */ -function createSSEStream(url, body, { onMessage, onThinking, onProcessStep, onDone, onError }) { +function createSSEStream(url, body, { onProcessStep, onDone, onError }) { const controller = new AbortController() const promise = (async () => { @@ -67,11 +67,7 @@ function createSSEStream(url, body, { onMessage, onThinking, onProcessStep, onDo currentEvent = line.slice(7).trim() } else if (line.startsWith('data: ')) { const data = JSON.parse(line.slice(6)) - if (currentEvent === 'thinking' && onThinking) { - onThinking(data.content) - } else if (currentEvent === 'message' && onMessage) { - onMessage(data.content) - } else if (currentEvent === 'process_step' && onProcessStep) { + if (currentEvent === 'process_step' && onProcessStep) { onProcessStep(data) } else if (currentEvent === 'done' && onDone) { onDone(data) @@ -183,8 +179,8 @@ export const messageApi = { } export const projectApi = { - list() { - return request('/projects') + list(cursor, limit = 20) { + return request(`/projects${buildQueryParams({ cursor, limit })}`) }, create(data) { @@ -220,12 +216,19 @@ export const projectApi = { }) }, + renameFile(projectId, filepath, newPath) { + return request(`/projects/${projectId}/files/${filepath}`, { + method: 'PATCH', + body: { new_path: newPath }, + }) + }, + deleteFile(projectId, filepath) { return request(`/projects/${projectId}/files/${filepath}`, { method: 'DELETE' }) }, mkdir(projectId, dirPath) { - return request(`/projects/${projectId}/files/mkdir`, { + return request(`/projects/${projectId}/directories`, { method: 'POST', body: { path: dirPath }, }) diff --git a/frontend/src/components/ChatView.vue b/frontend/src/components/ChatView.vue index a59719f..8b3c977 100644 --- a/frontend/src/components/ChatView.vue +++ b/frontend/src/components/ChatView.vue @@ -48,8 +48,6 @@
@@ -87,8 +85,6 @@ const props = defineProps({ conversation: { type: Object, default: null }, messages: { type: Array, required: true }, streaming: { type: Boolean, default: false }, - streamingContent: { type: String, default: '' }, - streamingThinking: { type: String, default: '' }, streamingProcessSteps: { type: Array, default: () => [] }, hasMoreMessages: { type: Boolean, default: false }, loadingMore: { type: Boolean, default: false }, @@ -163,7 +159,7 @@ function scrollToMessage(msgId) { } // 流式时使用 instant 滚动,避免 smooth 动画与内容增长互相打架造成抖动 -watch([() => props.messages.length, () => props.streamingContent, () => props.streamingThinking], () => { +watch([() => props.messages.length, () => props.streamingProcessSteps], () => { nextTick(() => { const el = scrollContainer.value if (!el) return diff --git a/frontend/src/components/FileExplorer.vue b/frontend/src/components/FileExplorer.vue index 6af512b..22473e0 100644 --- a/frontend/src/components/FileExplorer.vue +++ b/frontend/src/components/FileExplorer.vue @@ -60,19 +60,6 @@
- - + +
+ + +
+
+ + + + + @@ -46,7 +95,7 @@