diff --git a/README.md b/README.md index cd19642..1213fb5 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ ### 1. 安装依赖 ```bash +conda create -n claw python=3.12 +conda activate claw pip install -e . ``` @@ -50,19 +52,13 @@ db_type: sqlite db_sqlite_file: nano_claw.db ``` -### 3. 数据库迁移(首次运行或升级) - -```bash -python -m backend.migrations.add_project_support -``` - -### 4. 启动后端 +### 3. 启动后端 ```bash python -m backend.run ``` -### 5. 启动前端 +### 4. 启动前端 ```bash cd frontend @@ -124,7 +120,7 @@ frontend/ | `POST` | `/api/conversations` | 创建会话 | | `GET` | `/api/conversations` | 会话列表 | | `GET` | `/api/conversations/:id/messages` | 消息列表 | -| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE) | +| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) | | `GET` | `/api/projects` | 项目列表 | | `POST` | `/api/projects` | 创建项目 | | `POST` | `/api/projects/upload` | 上传文件夹 | diff --git a/backend/models.py b/backend/models.py index d5fb24c..3f05f1b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,20 +1,10 @@ from backend import db from datetime import datetime, timezone -from flask import current_app from sqlalchemy import Text from sqlalchemy.dialects.mysql import LONGTEXT as MYSQL_LONGTEXT -def get_longtext_type(): - """Get appropriate text type for long content based on database dialect.""" - db_uri = current_app.config.get("SQLALCHEMY_DATABASE_URI", "") - if db_uri.startswith("mysql"): - return MYSQL_LONGTEXT - return Text # SQLite and PostgreSQL use Text - - -# For model definitions, we'll use a callable that returns the right type class LongText(db.TypeDecorator): """Cross-database LONGTEXT type that works with MySQL, SQLite, and PostgreSQL.""" impl = Text diff --git a/backend/routes/messages.py b/backend/routes/messages.py index ca03591..2e08404 100644 --- a/backend/routes/messages.py +++ b/backend/routes/messages.py @@ -68,11 +68,9 @@ def message_list(conv_id): db.session.commit() tools_enabled = d.get("tools_enabled", True) + project_id = d.get("project_id") - if d.get("stream", False): - return _chat_service.stream_response(conv, tools_enabled, project_id) - - return _chat_service.sync_response(conv, tools_enabled, project_id) + return _chat_service.stream_response(conv, tools_enabled, project_id) @bp.route("/api/conversations//messages/", methods=["DELETE"]) diff --git a/backend/services/chat.py b/backend/services/chat.py index 3bf8d67..7ce3943 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -9,9 +9,6 @@ from backend.utils.helpers import ( get_or_create_default_user, record_token_usage, build_messages, - ok, - err, - to_dict, ) from backend.services.glm_client import GLMClient @@ -26,114 +23,6 @@ class ChatService: self.executor = ToolExecutor(registry=registry) - def sync_response(self, conv: Conversation, tools_enabled: bool = True, project_id: str = None): - """Sync response with tool call support - - Args: - conv: Conversation object - tools_enabled: Whether to enable tools - project_id: Project ID for workspace isolation - """ - tools = registry.list_all() if tools_enabled else None - messages = build_messages(conv, project_id) - - # Clear tool call history for new request - self.executor.clear_history() - - # Build context for tool execution - context = {"project_id": project_id} if project_id else None - - all_tool_calls = [] - all_tool_results = [] - - for _ in range(self.MAX_ITERATIONS): - try: - resp = self.glm_client.call( - model=conv.model, - messages=messages, - max_tokens=conv.max_tokens, - temperature=conv.temperature, - thinking_enabled=conv.thinking_enabled, - tools=tools, - ) - resp.raise_for_status() - result = resp.json() - except Exception as e: - return err(500, f"upstream error: {e}") - - choice = result["choices"][0] - message = choice["message"] - - # No tool calls - return final result - if not message.get("tool_calls"): - usage = result.get("usage", {}) - prompt_tokens = usage.get("prompt_tokens", 0) - completion_tokens = usage.get("completion_tokens", 0) - - # Build content JSON - content_json = { - "text": message.get("content", ""), - } - if message.get("reasoning_content"): - content_json["thinking"] = message["reasoning_content"] - if all_tool_calls: - content_json["tool_calls"] = self._build_tool_calls_json(all_tool_calls, all_tool_results) - - # Create message - msg = Message( - id=str(uuid.uuid4()), - conversation_id=conv.id, - role="assistant", - content=json.dumps(content_json, ensure_ascii=False), - token_count=completion_tokens, - ) - db.session.add(msg) - db.session.commit() - - user = get_or_create_default_user() - record_token_usage(user.id, conv.model, prompt_tokens, completion_tokens) - - # Set title if needed (first message) - suggested_title = None - if not conv.title or conv.title == "新对话": - user_msg = Message.query.filter_by( - conversation_id=conv.id, role="user" - ).order_by(Message.created_at.asc()).first() - if user_msg and user_msg.content: - # Parse content JSON to get text - try: - content_data = json.loads(user_msg.content) - title_text = content_data.get("text", "")[:30] - except (json.JSONDecodeError, TypeError): - title_text = user_msg.content.strip()[:30] - if title_text: - suggested_title = title_text - else: - suggested_title = "新对话" - conv.title = suggested_title - db.session.commit() - - return ok({ - "message": self._message_to_dict(msg), - "usage": { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": usage.get("total_tokens", 0) - }, - "suggested_title": suggested_title, - }) - - # Process tool calls - tool_calls = message["tool_calls"] - all_tool_calls.extend(tool_calls) - messages.append(message) - - tool_results = self.executor.process_tool_calls(tool_calls, context) - all_tool_results.extend(tool_results) - messages.extend(tool_results) - - return err(500, "exceeded maximum tool call iterations") - def stream_response(self, conv: Conversation, tools_enabled: bool = True, project_id: str = None): """Stream response with tool call support @@ -379,32 +268,7 @@ class ChatService: }) return result - def _message_to_dict(self, msg: Message) -> dict: - """Convert message to dict, parsing JSON content""" - result = to_dict(msg) - # Parse content JSON - if msg.content: - try: - content_data = json.loads(msg.content) - if isinstance(content_data, dict): - result["text"] = content_data.get("text", "") - if content_data.get("attachments"): - result["attachments"] = content_data["attachments"] - if content_data.get("thinking"): - result["thinking"] = content_data["thinking"] - if content_data.get("tool_calls"): - result["tool_calls"] = content_data["tool_calls"] - else: - result["text"] = msg.content - except (json.JSONDecodeError, TypeError): - result["text"] = msg.content - - if "text" not in result: - result["text"] = "" - - return result - def _process_tool_calls_delta(self, delta: dict, tool_calls_list: list) -> list: """Process tool calls from streaming delta""" tool_calls_delta = delta.get("tool_calls", []) diff --git a/backend/tools/executor.py b/backend/tools/executor.py index 73b9b2c..85a0585 100644 --- a/backend/tools/executor.py +++ b/backend/tools/executor.py @@ -12,18 +12,12 @@ class ToolExecutor: def __init__( self, registry: Optional[ToolRegistry] = None, - api_url: Optional[str] = None, - api_key: Optional[str] = None, enable_cache: bool = True, cache_ttl: int = 300, # 5 minutes - max_retries: int = 2, # Max retries per tool ): self.registry = registry or ToolRegistry() - self.api_url = api_url - self.api_key = api_key self.enable_cache = enable_cache self.cache_ttl = cache_ttl - self.max_retries = max_retries self._cache: Dict[str, tuple] = {} # key -> (result, timestamp) self._call_history: List[dict] = [] # Track calls in current session @@ -124,7 +118,7 @@ class ToolExecutor: continue # Execute tool with retry - result = self._execute_with_retry(name, args) + result = self._execute_tool(name, args) # Cache the result (only cache successful results) if result.get("success"): @@ -141,23 +135,13 @@ class ToolExecutor: return results - def _execute_with_retry( + def _execute_tool( self, name: str, arguments: dict, ) -> dict: - """ - Execute tool without automatic retry. - - If the tool fails, return the error to let the model decide - whether to retry with the same tool or try a different approach. - - Returns: - Result dict with success status. Failed tool returns: - {"success": False, "error": "..."} - """ - result = self.registry.execute(name, arguments) - return result + """Execute a tool and return the result.""" + return self.registry.execute(name, arguments) def _create_tool_result( self, diff --git a/docs/Design.md b/docs/Design.md index d9a8b59..afe7f93 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -194,10 +194,8 @@ classDiagram -GLMClient glm_client -ToolExecutor executor +Integer MAX_ITERATIONS - +sync_response(conv, tools_enabled, project_id) Response +stream_response(conv, tools_enabled, project_id) Response -_build_tool_calls_json(calls, results) list - -_message_to_dict(msg) dict -_process_tool_calls_delta(delta, list) list } @@ -369,7 +367,7 @@ def process_tool_calls(self, tool_calls, context=None): | 方法 | 路径 | 说明 | |------|------|------| | `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) | -| `POST` | `/api/conversations/:id/messages` | 发送消息(支持 SSE 流式) | +| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) | | `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 | | `POST` | `/api/conversations/:id/regenerate/:mid` | 重新生成消息 | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8fb912b..ceef20e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1188,6 +1188,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1300,6 +1301,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -1374,6 +1376,7 @@ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 35f21f8..5e74dd1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -188,35 +188,9 @@ function loadMoreMessages() { if (hasMoreMessages.value) loadMessages(false) } -// -- Send message (streaming) -- -async function sendMessage(data) { - if (!currentConvId.value || streaming.value) return - - const convId = currentConvId.value // 保存当前对话ID - const text = data.text || '' - const attachments = data.attachments || null - - // Add user message optimistically - const userMsg = { - id: 'temp_' + Date.now(), - conversation_id: convId, - role: 'user', - text, - attachments: attachments ? attachments.map(a => ({ name: a.name, extension: a.extension })) : null, - token_count: 0, - created_at: new Date().toISOString(), - } - messages.value.push(userMsg) - - streaming.value = true - streamContent.value = '' - streamThinking.value = '' - streamToolCalls.value = [] - streamProcessSteps.value = [] - - currentStreamPromise = messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, { - stream: true, - toolsEnabled: toolsEnabled.value, +// -- Helpers: create stream callbacks for a conversation -- +function createStreamCallbacks(convId, { updateConvList = true } = {}) { + return { onThinkingStart() { if (currentConvId.value === convId) { streamThinking.value = '' @@ -242,7 +216,6 @@ async function sendMessage(data) { } }, onToolCalls(calls) { - console.log('🔧 Tool calls received:', calls) if (currentConvId.value === convId) { streamToolCalls.value.push(...calls.map(c => ({ ...c, result: null }))) } else { @@ -252,7 +225,6 @@ async function sendMessage(data) { } }, onToolResult(result) { - console.log('✅ Tool result received:', result) if (currentConvId.value === convId) { const call = streamToolCalls.value.find(c => c.id === result.id) if (call) call.result = result.content @@ -266,11 +238,8 @@ async function sendMessage(data) { onProcessStep(step) { const idx = step.index if (currentConvId.value === convId) { - // 创建新数组确保响应式更新 const newSteps = [...streamProcessSteps.value] - while (newSteps.length <= idx) { - newSteps.push(null) - } + while (newSteps.length <= idx) newSteps.push(null) newSteps[idx] = step streamProcessSteps.value = newSteps } else { @@ -282,13 +251,11 @@ async function sendMessage(data) { } }, async onDone(data) { - // 清除保存的状态 streamStates.delete(convId) if (currentConvId.value === convId) { streaming.value = false currentStreamPromise = null - // 添加助手消息(保留临时用户消息) messages.value.push({ id: data.message_id, conversation_id: convId, @@ -305,35 +272,27 @@ async function sendMessage(data) { streamToolCalls.value = [] streamProcessSteps.value = [] - // Update conversation in list (move to top) - const idx = conversations.value.findIndex(c => c.id === convId) - if (idx > 0) { - const [conv] = conversations.value.splice(idx, 1) - conv.message_count = (conv.message_count || 0) + 2 - if (data.suggested_title) { - conv.title = data.suggested_title - } - conversations.value.unshift(conv) - } else if (idx === 0) { - conversations.value[0].message_count = (conversations.value[0].message_count || 0) + 2 - if (data.suggested_title) { - conversations.value[0].title = data.suggested_title + if (updateConvList) { + const idx = conversations.value.findIndex(c => c.id === convId) + if (idx > 0) { + const [conv] = conversations.value.splice(idx, 1) + conv.message_count = (conv.message_count || 0) + 2 + if (data.suggested_title) conv.title = data.suggested_title + conversations.value.unshift(conv) + } else if (idx === 0) { + conversations.value[0].message_count = (conversations.value[0].message_count || 0) + 2 + if (data.suggested_title) conversations.value[0].title = data.suggested_title } } } else { - // 后台完成,重新加载该对话的消息 try { const res = await messageApi.list(convId, null, 50) - // 更新对话列表中的消息计数和标题 const idx = conversations.value.findIndex(c => c.id === convId) if (idx >= 0) { conversations.value[idx].message_count = res.data.items.length - // 从服务器获取最新标题 if (res.data.items.length > 0) { const convRes = await conversationApi.get(convId) - if (convRes.data.title) { - conversations.value[idx].title = convRes.data.title - } + if (convRes.data.title) conversations.value[idx].title = convRes.data.title } } } catch (_) {} @@ -351,6 +310,37 @@ async function sendMessage(data) { console.error('Stream error:', msg) } }, + } +} + +// -- Send message (streaming) -- +async function sendMessage(data) { + if (!currentConvId.value || streaming.value) return + + const convId = currentConvId.value + const text = data.text || '' + const attachments = data.attachments || null + + const userMsg = { + id: 'temp_' + Date.now(), + conversation_id: convId, + role: 'user', + text, + attachments: attachments ? attachments.map(a => ({ name: a.name, extension: a.extension })) : null, + token_count: 0, + created_at: new Date().toISOString(), + } + messages.value.push(userMsg) + + streaming.value = true + streamContent.value = '' + streamThinking.value = '' + streamToolCalls.value = [] + streamProcessSteps.value = [] + + currentStreamPromise = messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, { + toolsEnabled: toolsEnabled.value, + ...createStreamCallbacks(convId, { updateConvList: true }), }) } @@ -370,12 +360,9 @@ async function regenerateMessage(msgId) { if (!currentConvId.value || streaming.value) return const convId = currentConvId.value - - // 找到要重新生成的消息索引 const msgIndex = messages.value.findIndex(m => m.id === msgId) if (msgIndex === -1) return - // 移除该消息及其后面的所有消息 messages.value = messages.value.slice(0, msgIndex) streaming.value = true @@ -387,75 +374,7 @@ async function regenerateMessage(msgId) { currentStreamPromise = messageApi.regenerate(convId, msgId, { toolsEnabled: toolsEnabled.value, projectId: currentProject.value?.id, - onThinkingStart() { - if (currentConvId.value === convId) { - streamThinking.value = '' - } - }, - onThinking(text) { - if (currentConvId.value === convId) { - streamThinking.value += text - } - }, - onMessage(text) { - if (currentConvId.value === convId) { - streamContent.value += text - } - }, - onToolCalls(calls) { - if (currentConvId.value === convId) { - streamToolCalls.value.push(...calls.map(c => ({ ...c, result: null }))) - } - }, - onToolResult(result) { - if (currentConvId.value === convId) { - const call = streamToolCalls.value.find(c => c.id === result.id) - if (call) call.result = result.content - } - }, - onProcessStep(step) { - const idx = step.index - if (currentConvId.value === convId) { - const newSteps = [...streamProcessSteps.value] - while (newSteps.length <= idx) { - newSteps.push(null) - } - newSteps[idx] = step - streamProcessSteps.value = newSteps - } - }, - async onDone(data) { - if (currentConvId.value === convId) { - streaming.value = false - currentStreamPromise = null - messages.value.push({ - id: data.message_id, - conversation_id: convId, - role: 'assistant', - text: streamContent.value, - thinking: streamThinking.value || null, - tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null, - process_steps: streamProcessSteps.value.filter(Boolean), - token_count: data.token_count, - created_at: new Date().toISOString(), - }) - streamContent.value = '' - streamThinking.value = '' - streamToolCalls.value = [] - streamProcessSteps.value = [] - } - }, - onError(msg) { - if (currentConvId.value === convId) { - streaming.value = false - currentStreamPromise = null - streamContent.value = '' - streamThinking.value = '' - streamToolCalls.value = [] - streamProcessSteps.value = [] - console.error('Regenerate error:', msg) - } - }, + ...createStreamCallbacks(convId, { updateConvList: false }), }) } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 7e7eee4..815faff 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -16,6 +16,79 @@ async function request(url, options = {}) { return data } +/** + * 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: { onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } + * @returns {{ abort: () => void }} + */ +function createSSEStream(url, body, { onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError }) { + const controller = new AbortController() + + const promise = (async () => { + try { + const res = await fetch(`${BASE}${url}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }) + + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.message || `HTTP ${res.status}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + let currentEvent = '' + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7).trim() + } else if (line.startsWith('data: ')) { + const data = JSON.parse(line.slice(6)) + if (currentEvent === 'thinking_start' && onThinkingStart) { + onThinkingStart() + } else if (currentEvent === 'thinking' && onThinking) { + onThinking(data.content) + } else if (currentEvent === 'message' && onMessage) { + onMessage(data.content) + } else if (currentEvent === 'tool_calls' && onToolCalls) { + onToolCalls(data.calls) + } else if (currentEvent === 'tool_result' && onToolResult) { + onToolResult(data) + } else if (currentEvent === 'process_step' && onProcessStep) { + onProcessStep(data) + } else if (currentEvent === 'done' && onDone) { + onDone(data) + } else if (currentEvent === 'error' && onError) { + onError(data.content) + } + } + } + } + } catch (e) { + if (e.name !== 'AbortError' && onError) { + onError(e.message) + } + } + })() + + promise.abort = () => controller.abort() + return promise +} + export const modelApi = { list() { return request('/models') @@ -95,149 +168,25 @@ export const messageApi = { return request(`/conversations/${convId}/messages?${params}`) }, - send(convId, data, { stream = true, toolsEnabled = true, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) { - if (!stream) { - return request(`/conversations/${convId}/messages`, { - method: 'POST', - body: { text: data.text, attachments: data.attachments, stream: false, tools_enabled: toolsEnabled, project_id: data.projectId }, - }) - } - - const controller = new AbortController() - - const promise = (async () => { - try { - const res = await fetch(`${BASE}/conversations/${convId}/messages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: data.text, attachments: data.attachments, stream: true, tools_enabled: toolsEnabled, project_id: data.projectId }), - signal: controller.signal, - }) - - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.message || `HTTP ${res.status}`) - } - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - let currentEvent = '' - for (const line of lines) { - if (line.startsWith('event: ')) { - currentEvent = line.slice(7).trim() - } else if (line.startsWith('data: ')) { - const data = JSON.parse(line.slice(6)) - if (currentEvent === 'thinking_start' && onThinkingStart) { - onThinkingStart() - } else if (currentEvent === 'thinking' && onThinking) { - onThinking(data.content) - } else if (currentEvent === 'message' && onMessage) { - onMessage(data.content) - } else if (currentEvent === 'tool_calls' && onToolCalls) { - onToolCalls(data.calls) - } else if (currentEvent === 'tool_result' && onToolResult) { - onToolResult(data) - } else if (currentEvent === 'process_step' && onProcessStep) { - onProcessStep(data) - } else if (currentEvent === 'done' && onDone) { - onDone(data) - } else if (currentEvent === 'error' && onError) { - onError(data.content) - } - } - } - } - } catch (e) { - if (e.name !== 'AbortError' && onError) { - onError(e.message) - } - } - })() - - promise.abort = () => controller.abort() - - return promise + send(convId, data, callbacks) { + return createSSEStream(`/conversations/${convId}/messages`, { + text: data.text, + attachments: data.attachments, + stream: true, + tools_enabled: callbacks.toolsEnabled !== false, + project_id: data.projectId, + }, callbacks) }, delete(convId, msgId) { return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' }) }, - regenerate(convId, msgId, { toolsEnabled = true, projectId, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) { - const controller = new AbortController() - - const promise = (async () => { - try { - const res = await fetch(`${BASE}/conversations/${convId}/regenerate/${msgId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tools_enabled: toolsEnabled, project_id: projectId }), - signal: controller.signal, - }) - - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.message || `HTTP ${res.status}`) - } - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - let currentEvent = '' - for (const line of lines) { - if (line.startsWith('event: ')) { - currentEvent = line.slice(7).trim() - } else if (line.startsWith('data: ')) { - const data = JSON.parse(line.slice(6)) - if (currentEvent === 'thinking_start' && onThinkingStart) { - onThinkingStart() - } else if (currentEvent === 'thinking' && onThinking) { - onThinking(data.content) - } else if (currentEvent === 'message' && onMessage) { - onMessage(data.content) - } else if (currentEvent === 'tool_calls' && onToolCalls) { - onToolCalls(data.calls) - } else if (currentEvent === 'tool_result' && onToolResult) { - onToolResult(data) - } else if (currentEvent === 'process_step' && onProcessStep) { - onProcessStep(data) - } else if (currentEvent === 'done' && onDone) { - onDone(data) - } else if (currentEvent === 'error' && onError) { - onError(data.content) - } - } - } - } - } catch (e) { - if (e.name !== 'AbortError' && onError) { - onError(e.message) - } - } - })() - - promise.abort = () => controller.abort() - - return promise + regenerate(convId, msgId, callbacks) { + return createSSEStream(`/conversations/${convId}/regenerate/${msgId}`, { + tools_enabled: callbacks.toolsEnabled !== false, + project_id: callbacks.projectId, + }, callbacks) }, } diff --git a/frontend/src/components/ChatView.vue b/frontend/src/components/ChatView.vue index 56e226a..29b1fa1 100644 --- a/frontend/src/components/ChatView.vue +++ b/frontend/src/components/ChatView.vue @@ -56,7 +56,7 @@ :process-steps="streamingProcessSteps" :streaming="streaming" /> -
+
@@ -370,10 +370,6 @@ defineExpose({ scrollToBottom }) } .streaming-content { - font-size: 15px; - line-height: 1.7; - color: var(--text-primary); - word-break: break-word; } .streaming-indicator { @@ -387,85 +383,8 @@ defineExpose({ scrollToBottom }) color: var(--text-tertiary); } -.streaming-content :deep(p) { - margin: 0 0 8px; -} - -.streaming-content :deep(p:last-child) { - margin-bottom: 0; -} - -.streaming-content :deep(pre) { - background: var(--bg-code); - border: 1px solid var(--border-light); - border-radius: 8px; - padding: 16px; - overflow-x: auto; - margin: 8px 0; - max-width: 100%; -} - -.streaming-content :deep(pre code) { - font-family: 'JetBrains Mono', 'Fira Code', monospace; - font-size: 13px; - line-height: 1.5; -} - -.streaming-content :deep(code) { - background: var(--accent-primary-light); - color: var(--accent-primary); - padding: 2px 6px; - border-radius: 4px; - font-size: 13px; - font-family: 'JetBrains Mono', 'Fira Code', monospace; -} - -.streaming-content :deep(pre code) { - background: none; - color: inherit; - padding: 0; -} - -.streaming-content :deep(ul), -.streaming-content :deep(ol) { - padding-left: 20px; - margin: 8px 0; -} - -.streaming-content :deep(blockquote) { - border-left: 3px solid rgba(59, 130, 246, 0.4); - padding-left: 12px; - color: var(--text-secondary); - margin: 8px 0; -} - -.streaming-content :deep(table) { - border-collapse: collapse; - margin: 8px 0; - width: 100%; -} - -.streaming-content :deep(th), -.streaming-content :deep(td) { - border: 1px solid var(--border-medium); - padding: 8px 12px; - text-align: left; -} - -.streaming-content :deep(th) { - background: var(--bg-code); -} - .streaming-content :deep(.placeholder) { color: var(--text-tertiary); } -.streaming-content :deep(.math-block), -.message-content :deep(.math-block) { - display: block; - text-align: center; - padding: 12px 0; - margin: 8px 0; - overflow-x: auto; -} diff --git a/frontend/src/components/MessageBubble.vue b/frontend/src/components/MessageBubble.vue index 78bec0d..854029c 100644 --- a/frontend/src/components/MessageBubble.vue +++ b/frontend/src/components/MessageBubble.vue @@ -21,7 +21,7 @@
工具返回结果: {{ toolName }}
{{ content }}
-
+