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 }}
+
@@ -67,6 +70,19 @@ import { ref, computed, watch } from 'vue' import { renderMarkdown } from '../utils/markdown' import { formatJson, truncate } from '../utils/format' import { useCodeEnhancement } from '../composables/useCodeEnhancement' +import { RESULT_PREVIEW_LIMIT } from '../constants' + +function buildResultFields(rawContent) { + const formatted = formatJson(rawContent) + const len = formatted.length + const truncated = len > RESULT_PREVIEW_LIMIT + return { + result: formatted, + resultPreview: truncated ? formatted.slice(0, RESULT_PREVIEW_LIMIT) + '\n...' : formatted, + resultTruncated: truncated, + resultLength: len, + } +} const props = defineProps({ toolCalls: { type: Array, default: () => [] }, @@ -75,10 +91,12 @@ const props = defineProps({ }) const expandedKeys = ref({}) +const expandedResultKeys = ref({}) // Auto-collapse all items when a new stream starts watch(() => props.streaming, (v) => { if (v) expandedKeys.value = {} + expandedResultKeys.value = {} }) const processRef = ref(null) @@ -87,6 +105,10 @@ function toggleItem(key) { expandedKeys.value[key] = !expandedKeys.value[key] } +function toggleResultExpand(key) { + expandedResultKeys.value[key] = !expandedResultKeys.value[key] +} + function getResultSummary(result) { try { const parsed = typeof result === 'string' ? JSON.parse(result) : result @@ -138,7 +160,7 @@ const processItems = computed(() => { const summary = getResultSummary(step.content) const match = items.findLast(it => it.type === 'tool_call' && it.id === toolId) if (match) { - match.result = formatJson(step.content) + Object.assign(match, buildResultFields(step.content)) match.resultSummary = summary.text match.isSuccess = summary.success match.loading = false @@ -165,7 +187,8 @@ const processItems = computed(() => { if (props.toolCalls && props.toolCalls.length > 0) { props.toolCalls.forEach((call, i) => { const toolName = call.function?.name || '未知工具' - const result = call.result ? getResultSummary(call.result) : null + const resultSummary = call.result ? getResultSummary(call.result) : null + const resultFields = call.result ? buildResultFields(call.result) : { result: null, resultPreview: null, resultTruncated: false, resultLength: 0 } items.push({ type: 'tool_call', toolName, @@ -174,9 +197,9 @@ const processItems = computed(() => { id: call.id, key: `tool_call-${call.id || i}`, loading: !call.result && props.streaming, - result: call.result ? formatJson(call.result) : null, - resultSummary: result ? result.text : null, - isSuccess: result ? result.success : undefined, + ...resultFields, + resultSummary: resultSummary ? resultSummary.text : null, + isSuccess: resultSummary ? resultSummary.success : undefined, }) }) } @@ -345,6 +368,23 @@ watch(() => props.processSteps?.length, () => { word-break: break-word; } +.btn-expand-result { + display: inline-block; + margin-top: 6px; + padding: 3px 10px; + font-size: 11px; + color: var(--tool-color); + background: var(--tool-bg); + border: 1px solid var(--tool-border); + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; +} + +.btn-expand-result:hover { + background: var(--tool-bg-hover); +} + /* Text content — rendered as markdown */ .text-content { padding: 0; diff --git a/frontend/src/components/SettingsPanel.vue b/frontend/src/components/SettingsPanel.vue index 186ac41..8b089e7 100644 --- a/frontend/src/components/SettingsPanel.vue +++ b/frontend/src/components/SettingsPanel.vue @@ -126,6 +126,7 @@ import { reactive, ref, watch, onMounted } from 'vue' import { conversationApi } from '../api' import { useTheme } from '../composables/useTheme' import { icons } from '../utils/icons' +import { SETTINGS_AUTO_SAVE_DEBOUNCE_MS } from '../constants' const props = defineProps({ visible: { type: Boolean, default: false }, @@ -218,7 +219,7 @@ let saveTimer = null watch(form, () => { if (props.visible && props.conversation && syncedConvId === props.conversation.id && !isSyncing) { if (saveTimer) clearTimeout(saveTimer) - saveTimer = setTimeout(saveChanges, 500) + saveTimer = setTimeout(saveChanges, SETTINGS_AUTO_SAVE_DEBOUNCE_MS) } }, { deep: true }) diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index 9a31bef..c5c3e00 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -113,6 +113,7 @@ import { computed, reactive } from 'vue' import { formatTime } from '../utils/format' import { icons } from '../utils/icons' +import { INFINITE_SCROLL_THRESHOLD_PX } from '../constants' const props = defineProps({ conversations: { type: Array, required: true }, @@ -171,7 +172,7 @@ function toggleGroup(id) { function onScroll(e) { const el = e.target - if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) { + if (el.scrollTop + el.clientHeight >= el.scrollHeight - INFINITE_SCROLL_THRESHOLD_PX) { emit('loadMore') } } diff --git a/frontend/src/composables/useCodeEnhancement.js b/frontend/src/composables/useCodeEnhancement.js index ffe53c7..0ffbb73 100644 --- a/frontend/src/composables/useCodeEnhancement.js +++ b/frontend/src/composables/useCodeEnhancement.js @@ -1,5 +1,6 @@ import { watch, onMounted, nextTick, onUnmounted } from 'vue' import { enhanceCodeBlocks } from '../utils/markdown' +import { CODE_ENHANCE_DEBOUNCE_MS } from '../constants' /** * Composable for enhancing code blocks in a container element. @@ -18,7 +19,7 @@ export function useCodeEnhancement(templateRef, dep, watchOpts) { function debouncedEnhance() { if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => nextTick(enhance), 150) + debounceTimer = setTimeout(() => nextTick(enhance), CODE_ENHANCE_DEBOUNCE_MS) } onMounted(enhance) diff --git a/frontend/src/composables/useToast.js b/frontend/src/composables/useToast.js index e380b04..b4afff7 100644 --- a/frontend/src/composables/useToast.js +++ b/frontend/src/composables/useToast.js @@ -1,4 +1,5 @@ import { reactive } from 'vue' +import { TOAST_DEFAULT_DURATION } from '../constants' const state = reactive({ toasts: [], @@ -6,7 +7,7 @@ const state = reactive({ }) export function useToast() { - function add(type, message, duration = 1500) { + function add(type, message, duration = TOAST_DEFAULT_DURATION) { const id = ++state._id state.toasts.push({ id, type, message }) setTimeout(() => { diff --git a/frontend/src/constants.js b/frontend/src/constants.js new file mode 100644 index 0000000..3724089 --- /dev/null +++ b/frontend/src/constants.js @@ -0,0 +1,37 @@ +/** + * Frontend constants + */ + +// === Tool Result Display === +/** Max characters shown in tool result preview before truncation */ +export const RESULT_PREVIEW_LIMIT = 2048 + +// === API === +export const API_BASE_URL = '/api' +export const CONTENT_TYPE_JSON = 'application/json' + +// === Pagination === +export const DEFAULT_CONVERSATION_PAGE_SIZE = 20 +export const DEFAULT_MESSAGE_PAGE_SIZE = 50 +export const DEFAULT_PROJECT_PAGE_SIZE = 20 + +// === Timers (ms) === +export const TOAST_DEFAULT_DURATION = 1500 +export const CODE_ENHANCE_DEBOUNCE_MS = 150 +export const SETTINGS_AUTO_SAVE_DEBOUNCE_MS = 500 +export const COPY_BUTTON_RESET_MS = 1500 + +// === Truncation === +export const DEFAULT_TRUNCATE_LENGTH = 60 + +// === UI Limits === +export const TEXTAREA_MAX_HEIGHT_PX = 200 +export const INFINITE_SCROLL_THRESHOLD_PX = 50 + +// === LocalStorage Keys === +export const LS_KEY_THEME = 'theme' +export const LS_KEY_TOOLS_ENABLED = 'tools_enabled' +export const LS_KEY_MODELS_CACHE = 'models_cache' + +// === File Upload === +export const ALLOWED_UPLOAD_EXTENSIONS = '.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.h,.hpp,.yaml,.yml,.toml,.ini,.csv,.sql,.sh,.bat,.log,.vue,.svelte,.go,.rs,.rb,.php,.swift,.kt,.scala,.lua,.r,.dart' diff --git a/frontend/src/main.js b/frontend/src/main.js index 7641951..a7ef148 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -3,9 +3,10 @@ import App from './App.vue' import './styles/global.css' import './styles/highlight.css' import 'katex/dist/katex.min.css' +import { LS_KEY_THEME } from './constants' // Initialize theme before app mounts to avoid flash when lazy-loading useTheme -const savedTheme = localStorage.getItem('theme') +const savedTheme = localStorage.getItem(LS_KEY_THEME) if (savedTheme === 'dark' || savedTheme === 'light') { document.documentElement.setAttribute('data-theme', savedTheme) } diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js index 6b83c49..478b68f 100644 --- a/frontend/src/utils/format.js +++ b/frontend/src/utils/format.js @@ -1,3 +1,5 @@ +import { DEFAULT_TRUNCATE_LENGTH } from '../constants' + /** * Format ISO date string to a short time string. * - Today: "14:30" @@ -36,7 +38,7 @@ export function formatJson(value) { /** * Truncate text to max characters with ellipsis. */ -export function truncate(text, max = 60) { +export function truncate(text, max = DEFAULT_TRUNCATE_LENGTH) { if (!text) return '' const str = text.replace(/\s+/g, ' ').trim() return str.length > max ? str.slice(0, max) + '\u2026' : str diff --git a/frontend/src/utils/markdown.js b/frontend/src/utils/markdown.js index ff6eded..5c72d7e 100644 --- a/frontend/src/utils/markdown.js +++ b/frontend/src/utils/markdown.js @@ -2,6 +2,7 @@ import { marked } from 'marked' import { markedHighlight } from 'marked-highlight' import katex from 'katex' import { highlightCode } from './highlight' +import { COPY_BUTTON_RESET_MS } from '../constants' function renderMath(text, displayMode) { try { @@ -108,7 +109,7 @@ export function enhanceCodeBlocks(container) { copyBtn.addEventListener('click', () => { const raw = code?.textContent || '' - const copy = () => { copyBtn.innerHTML = CHECK_SVG; setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, 1500) } + const copy = () => { copyBtn.innerHTML = CHECK_SVG; setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, COPY_BUTTON_RESET_MS) } if (navigator.clipboard) { navigator.clipboard.writeText(raw).then(copy) } else {