From ae73559fd2372e850fd476af9944f8a62e269388 Mon Sep 17 00:00:00 2001 From: ViperEkura <3081035982@qq.com> Date: Sat, 28 Mar 2026 17:09:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=8E=E7=AB=AF=E4=B8=8E=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=B5=81=E5=BC=8F=E9=80=82=E9=85=8D=E4=B8=8E=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/chat.py | 97 +++++++++++++------ frontend/src/App.vue | 13 ++- frontend/src/api/index.js | 29 +++--- frontend/src/components/MessageInput.vue | 5 +- frontend/src/components/MessageNav.vue | 3 +- frontend/src/components/ProcessBlock.vue | 52 ++++++++-- frontend/src/components/SettingsPanel.vue | 3 +- frontend/src/components/Sidebar.vue | 3 +- .../src/composables/useCodeEnhancement.js | 3 +- frontend/src/composables/useToast.js | 3 +- frontend/src/constants.js | 37 +++++++ frontend/src/main.js | 3 +- frontend/src/utils/format.js | 4 +- frontend/src/utils/markdown.js | 3 +- 14 files changed, 198 insertions(+), 60 deletions(-) create mode 100644 frontend/src/constants.js diff --git a/backend/services/chat.py b/backend/services/chat.py index c8bb3c3..ac1602f 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -90,8 +90,21 @@ class ChatService: total_prompt_tokens = 0 for iteration in range(MAX_ITERATIONS): + # Helper to parse stream_result event + def parse_stream_result(event_str): + """Parse stream_result SSE event and extract data dict.""" + # Format: "event: stream_result\ndata: {...}\n\n" + try: + for line in event_str.split('\n'): + if line.startswith('data: '): + return json.loads(line[6:]) + except Exception: + pass + return None + + # Collect SSE events and extract final stream_result try: - stream_result = self._stream_llm_response( + stream_gen = self._stream_llm_response( app, messages, tools, tool_choice, step_index, conv_model, conv_max_tokens, conv_temperature, conv_thinking_enabled, @@ -116,22 +129,37 @@ class ChatService: yield _sse_event("error", {"content": f"Internal error: {e}"}) return - if stream_result is None: - return # Client disconnected + result_data = None + try: + for event_str in stream_gen: + # Check if this is a stream_result event (final event) + if event_str.startswith("event: stream_result"): + result_data = parse_stream_result(event_str) + else: + # Forward process_step events to client in real-time + yield event_str + except Exception as e: + logger.exception("Error during streaming") + yield _sse_event("error", {"content": f"Stream error: {e}"}) + return - full_content, full_thinking, tool_calls_list, \ - thinking_step_id, thinking_step_idx, \ - text_step_id, text_step_idx, \ - completion_tokens, prompt_tokens, \ - sse_chunks = stream_result + if result_data is None: + return # Client disconnected or error + + # Extract data from stream_result + full_content = result_data["full_content"] + full_thinking = result_data["full_thinking"] + tool_calls_list = result_data["tool_calls_list"] + thinking_step_id = result_data["thinking_step_id"] + thinking_step_idx = result_data["thinking_step_idx"] + text_step_id = result_data["text_step_id"] + text_step_idx = result_data["text_step_idx"] + completion_tokens = result_data["completion_tokens"] + prompt_tokens = result_data["prompt_tokens"] total_prompt_tokens += prompt_tokens total_completion_tokens += completion_tokens - # Yield accumulated SSE chunks to frontend - for chunk in sse_chunks: - yield chunk - # Save thinking/text steps to all_steps for DB storage if thinking_step_id is not None: all_steps.append({ @@ -244,10 +272,16 @@ class ChatService: self, app, messages, tools, tool_choice, step_index, model, max_tokens, temperature, thinking_enabled, ): - """Call LLM streaming API and parse the response. + """Call LLM streaming API and yield SSE events in real-time. - Returns a tuple of parsed results, or None if the client disconnected. - Raises HTTPError / ConnectionError / Timeout for the caller to handle. + This is a generator that yields SSE event strings as they are received. + The final yield is a 'stream_result' event containing the accumulated data. + + Yields: + str: SSE event strings (process_step events, then stream_result) + + Raises: + HTTPError / ConnectionError / Timeout for the caller to handle. """ full_content = "" full_thinking = "" @@ -260,8 +294,6 @@ class ChatService: text_step_id = None text_step_idx = None - sse_chunks = [] # Collect SSE events to yield later - with app.app_context(): resp = self.llm.call( model=model, @@ -278,7 +310,7 @@ class ChatService: for line in resp.iter_lines(): if _client_disconnected(): resp.close() - return None + return # Client disconnected, stop generator if not line: continue @@ -304,37 +336,44 @@ class ChatService: delta = choices[0].get("delta", {}) + # Yield thinking content in real-time reasoning = delta.get("reasoning_content", "") if reasoning: full_thinking += reasoning if thinking_step_id is None: thinking_step_id = f"step-{step_index}" thinking_step_idx = step_index - sse_chunks.append(_sse_event("process_step", { + yield _sse_event("process_step", { "id": thinking_step_id, "index": thinking_step_idx, "type": "thinking", "content": full_thinking, - })) + }) + # Yield text content in real-time text = delta.get("content", "") if text: full_content += text 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}" - sse_chunks.append(_sse_event("process_step", { + yield _sse_event("process_step", { "id": text_step_id, "index": text_step_idx, "type": "text", "content": full_content, - })) + }) tool_calls_list = self._process_tool_calls_delta(delta, tool_calls_list) - return ( - full_content, full_thinking, tool_calls_list, - thinking_step_id, thinking_step_idx, - text_step_id, text_step_idx, - token_count, prompt_tokens, - sse_chunks, - ) + # Final yield: stream_result event with accumulated data + yield _sse_event("stream_result", { + "full_content": full_content, + "full_thinking": full_thinking, + "tool_calls_list": tool_calls_list, + "thinking_step_id": thinking_step_id, + "thinking_step_idx": thinking_step_idx, + "text_step_id": text_step_id, + "text_step_idx": text_step_idx, + "completion_tokens": token_count, + "prompt_tokens": prompt_tokens, + }) def _execute_tools_safe(self, app, executor, tool_calls_list, context): """Execute tool calls with top-level error wrapping. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index efc4ccd..602f537 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -121,6 +121,11 @@ import ModalDialog from './components/ModalDialog.vue' import ToastContainer from './components/ToastContainer.vue' import { icons } from './utils/icons' import { useModal } from './composables/useModal' +import { + DEFAULT_CONVERSATION_PAGE_SIZE, + DEFAULT_MESSAGE_PAGE_SIZE, + LS_KEY_TOOLS_ENABLED, +} from './constants' const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue')) const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue')) @@ -226,7 +231,7 @@ 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 toolsEnabled = ref(localStorage.getItem(LS_KEY_TOOLS_ENABLED) !== 'false') const currentProject = ref(null) const showFileExplorer = ref(false) const showCreateModal = ref(false) @@ -250,7 +255,7 @@ async function loadConversations(reset = true) { if (loadingConvs.value) return loadingConvs.value = true try { - const res = await conversationApi.list(reset ? null : nextConvCursor.value, 20) + const res = await conversationApi.list(reset ? null : nextConvCursor.value, DEFAULT_CONVERSATION_PAGE_SIZE) if (reset) { conversations.value = res.data.items } else { @@ -436,7 +441,7 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) { } } else { try { - const res = await messageApi.list(convId, null, 50) + const res = await messageApi.list(convId, null, DEFAULT_MESSAGE_PAGE_SIZE) const idx = conversations.value.findIndex(c => c.id === convId) if (idx >= 0) { const conv = conversations.value[idx] @@ -557,7 +562,7 @@ async function saveSettings(data) { // -- Update tools enabled -- function updateToolsEnabled(val) { toolsEnabled.value = val - localStorage.setItem('tools_enabled', String(val)) + localStorage.setItem(LS_KEY_TOOLS_ENABLED, String(val)) } // -- Browse project files -- diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index fa32b1c..669f236 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,4 +1,11 @@ -const BASE = '/api' +import { + API_BASE_URL, + CONTENT_TYPE_JSON, + LS_KEY_MODELS_CACHE, + DEFAULT_CONVERSATION_PAGE_SIZE, + DEFAULT_MESSAGE_PAGE_SIZE, + DEFAULT_PROJECT_PAGE_SIZE, +} from '../constants' // Cache for models list let modelsCache = null @@ -13,8 +20,8 @@ function buildQueryParams(params) { } async function request(url, options = {}) { - const res = await fetch(`${BASE}${url}`, { - headers: { 'Content-Type': 'application/json' }, + const res = await fetch(`${API_BASE_URL}${url}`, { + headers: { 'Content-Type': CONTENT_TYPE_JSON }, ...options, body: options.body ? JSON.stringify(options.body) : undefined, }) @@ -37,9 +44,9 @@ function createSSEStream(url, body, { onProcessStep, onDone, onError }) { const promise = (async () => { try { - const res = await fetch(`${BASE}${url}`, { + const res = await fetch(`${API_BASE_URL}${url}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': CONTENT_TYPE_JSON }, body: JSON.stringify(body), signal: controller.signal, }) @@ -107,7 +114,7 @@ export const modelApi = { } // Try localStorage cache first - const cached = localStorage.getItem('models_cache') + const cached = localStorage.getItem(LS_KEY_MODELS_CACHE) if (cached) { try { modelsCache = JSON.parse(cached) @@ -118,7 +125,7 @@ export const modelApi = { // Fetch from server const res = await this.list() modelsCache = res.data - localStorage.setItem('models_cache', JSON.stringify(modelsCache)) + localStorage.setItem(LS_KEY_MODELS_CACHE, JSON.stringify(modelsCache)) return res }, @@ -131,7 +138,7 @@ export const statsApi = { } export const conversationApi = { - list(cursor, limit = 20, projectId = null) { + list(cursor, limit = DEFAULT_CONVERSATION_PAGE_SIZE, projectId = null) { return request(`/conversations${buildQueryParams({ cursor, limit, project_id: projectId })}`) }, @@ -159,7 +166,7 @@ export const conversationApi = { } export const messageApi = { - list(convId, cursor, limit = 50) { + list(convId, cursor, limit = DEFAULT_MESSAGE_PAGE_SIZE) { return request(`/conversations/${convId}/messages${buildQueryParams({ cursor, limit })}`) }, @@ -186,7 +193,7 @@ export const messageApi = { } export const projectApi = { - list(cursor, limit = 20) { + list(cursor, limit = DEFAULT_PROJECT_PAGE_SIZE) { return request(`/projects${buildQueryParams({ cursor, limit })}`) }, @@ -210,7 +217,7 @@ export const projectApi = { }, readFileRaw(projectId, filepath) { - return fetch(`${BASE}/projects/${projectId}/files/${filepath}`).then(res => { + return fetch(`${API_BASE_URL}/projects/${projectId}/files/${filepath}`).then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`) return res }) diff --git a/frontend/src/components/MessageInput.vue b/frontend/src/components/MessageInput.vue index 9e90e3d..e00c2c6 100644 --- a/frontend/src/components/MessageInput.vue +++ b/frontend/src/components/MessageInput.vue @@ -24,7 +24,7 @@ @@ -65,6 +65,7 @@ diff --git a/frontend/src/components/ProcessBlock.vue b/frontend/src/components/ProcessBlock.vue index 17c0062..e09a04b 100644 --- a/frontend/src/components/ProcessBlock.vue +++ b/frontend/src/components/ProcessBlock.vue @@ -41,7 +41,10 @@
{{ item.result }}
+ {{ expandedResultKeys[item.key] ? item.result : item.resultPreview }}
+