fix: 后端与前端流式适配与优化

This commit is contained in:
ViperEkura 2026-03-28 17:09:41 +08:00
parent cc639a979a
commit ae73559fd2
14 changed files with 198 additions and 60 deletions

View File

@ -90,8 +90,21 @@ class ChatService:
total_prompt_tokens = 0 total_prompt_tokens = 0
for iteration in range(MAX_ITERATIONS): 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: try:
stream_result = self._stream_llm_response( 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_gen = self._stream_llm_response(
app, messages, tools, tool_choice, step_index, app, messages, tools, tool_choice, step_index,
conv_model, conv_max_tokens, conv_temperature, conv_model, conv_max_tokens, conv_temperature,
conv_thinking_enabled, conv_thinking_enabled,
@ -116,22 +129,37 @@ class ChatService:
yield _sse_event("error", {"content": f"Internal error: {e}"}) yield _sse_event("error", {"content": f"Internal error: {e}"})
return return
if stream_result is None: result_data = None
return # Client disconnected 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, \ if result_data is None:
thinking_step_id, thinking_step_idx, \ return # Client disconnected or error
text_step_id, text_step_idx, \
completion_tokens, prompt_tokens, \ # Extract data from stream_result
sse_chunks = 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_prompt_tokens += prompt_tokens
total_completion_tokens += completion_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 # Save thinking/text steps to all_steps for DB storage
if thinking_step_id is not None: if thinking_step_id is not None:
all_steps.append({ all_steps.append({
@ -244,10 +272,16 @@ class ChatService:
self, app, messages, tools, tool_choice, step_index, self, app, messages, tools, tool_choice, step_index,
model, max_tokens, temperature, thinking_enabled, 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. This is a generator that yields SSE event strings as they are received.
Raises HTTPError / ConnectionError / Timeout for the caller to handle. 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_content = ""
full_thinking = "" full_thinking = ""
@ -260,8 +294,6 @@ class ChatService:
text_step_id = None text_step_id = None
text_step_idx = None text_step_idx = None
sse_chunks = [] # Collect SSE events to yield later
with app.app_context(): with app.app_context():
resp = self.llm.call( resp = self.llm.call(
model=model, model=model,
@ -278,7 +310,7 @@ class ChatService:
for line in resp.iter_lines(): for line in resp.iter_lines():
if _client_disconnected(): if _client_disconnected():
resp.close() resp.close()
return None return # Client disconnected, stop generator
if not line: if not line:
continue continue
@ -304,37 +336,44 @@ class ChatService:
delta = choices[0].get("delta", {}) delta = choices[0].get("delta", {})
# Yield thinking content in real-time
reasoning = delta.get("reasoning_content", "") reasoning = delta.get("reasoning_content", "")
if reasoning: if reasoning:
full_thinking += reasoning full_thinking += reasoning
if thinking_step_id is None: if thinking_step_id is None:
thinking_step_id = f"step-{step_index}" thinking_step_id = f"step-{step_index}"
thinking_step_idx = 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, "id": thinking_step_id, "index": thinking_step_idx,
"type": "thinking", "content": full_thinking, "type": "thinking", "content": full_thinking,
})) })
# Yield text content in real-time
text = delta.get("content", "") text = delta.get("content", "")
if text: if text:
full_content += text full_content += text
if text_step_id is None: if text_step_id is None:
text_step_idx = step_index + (1 if thinking_step_id is not None else 0) text_step_idx = step_index + (1 if thinking_step_id is not None else 0)
text_step_id = f"step-{text_step_idx}" 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, "id": text_step_id, "index": text_step_idx,
"type": "text", "content": full_content, "type": "text", "content": full_content,
})) })
tool_calls_list = self._process_tool_calls_delta(delta, tool_calls_list) tool_calls_list = self._process_tool_calls_delta(delta, tool_calls_list)
return ( # Final yield: stream_result event with accumulated data
full_content, full_thinking, tool_calls_list, yield _sse_event("stream_result", {
thinking_step_id, thinking_step_idx, "full_content": full_content,
text_step_id, text_step_idx, "full_thinking": full_thinking,
token_count, prompt_tokens, "tool_calls_list": tool_calls_list,
sse_chunks, "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): def _execute_tools_safe(self, app, executor, tool_calls_list, context):
"""Execute tool calls with top-level error wrapping. """Execute tool calls with top-level error wrapping.

View File

@ -121,6 +121,11 @@ import ModalDialog from './components/ModalDialog.vue'
import ToastContainer from './components/ToastContainer.vue' import ToastContainer from './components/ToastContainer.vue'
import { icons } from './utils/icons' import { icons } from './utils/icons'
import { useModal } from './composables/useModal' 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 SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue')) const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
@ -226,7 +231,7 @@ function updateStreamField(convId, field, ref, valueOrUpdater) {
// -- UI state -- // -- UI state --
const showSettings = ref(false) const showSettings = ref(false)
const showStats = 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 currentProject = ref(null)
const showFileExplorer = ref(false) const showFileExplorer = ref(false)
const showCreateModal = ref(false) const showCreateModal = ref(false)
@ -250,7 +255,7 @@ async function loadConversations(reset = true) {
if (loadingConvs.value) return if (loadingConvs.value) return
loadingConvs.value = true loadingConvs.value = true
try { 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) { if (reset) {
conversations.value = res.data.items conversations.value = res.data.items
} else { } else {
@ -436,7 +441,7 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
} }
} else { } else {
try { 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) const idx = conversations.value.findIndex(c => c.id === convId)
if (idx >= 0) { if (idx >= 0) {
const conv = conversations.value[idx] const conv = conversations.value[idx]
@ -557,7 +562,7 @@ async function saveSettings(data) {
// -- Update tools enabled -- // -- Update tools enabled --
function updateToolsEnabled(val) { function updateToolsEnabled(val) {
toolsEnabled.value = val toolsEnabled.value = val
localStorage.setItem('tools_enabled', String(val)) localStorage.setItem(LS_KEY_TOOLS_ENABLED, String(val))
} }
// -- Browse project files -- // -- Browse project files --

View File

@ -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 // Cache for models list
let modelsCache = null let modelsCache = null
@ -13,8 +20,8 @@ function buildQueryParams(params) {
} }
async function request(url, options = {}) { async function request(url, options = {}) {
const res = await fetch(`${BASE}${url}`, { const res = await fetch(`${API_BASE_URL}${url}`, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': CONTENT_TYPE_JSON },
...options, ...options,
body: options.body ? JSON.stringify(options.body) : undefined, body: options.body ? JSON.stringify(options.body) : undefined,
}) })
@ -37,9 +44,9 @@ function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
const promise = (async () => { const promise = (async () => {
try { try {
const res = await fetch(`${BASE}${url}`, { const res = await fetch(`${API_BASE_URL}${url}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': CONTENT_TYPE_JSON },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: controller.signal, signal: controller.signal,
}) })
@ -107,7 +114,7 @@ export const modelApi = {
} }
// Try localStorage cache first // Try localStorage cache first
const cached = localStorage.getItem('models_cache') const cached = localStorage.getItem(LS_KEY_MODELS_CACHE)
if (cached) { if (cached) {
try { try {
modelsCache = JSON.parse(cached) modelsCache = JSON.parse(cached)
@ -118,7 +125,7 @@ export const modelApi = {
// Fetch from server // Fetch from server
const res = await this.list() const res = await this.list()
modelsCache = res.data modelsCache = res.data
localStorage.setItem('models_cache', JSON.stringify(modelsCache)) localStorage.setItem(LS_KEY_MODELS_CACHE, JSON.stringify(modelsCache))
return res return res
}, },
@ -131,7 +138,7 @@ export const statsApi = {
} }
export const conversationApi = { 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 })}`) return request(`/conversations${buildQueryParams({ cursor, limit, project_id: projectId })}`)
}, },
@ -159,7 +166,7 @@ export const conversationApi = {
} }
export const messageApi = { export const messageApi = {
list(convId, cursor, limit = 50) { list(convId, cursor, limit = DEFAULT_MESSAGE_PAGE_SIZE) {
return request(`/conversations/${convId}/messages${buildQueryParams({ cursor, limit })}`) return request(`/conversations/${convId}/messages${buildQueryParams({ cursor, limit })}`)
}, },
@ -186,7 +193,7 @@ export const messageApi = {
} }
export const projectApi = { export const projectApi = {
list(cursor, limit = 20) { list(cursor, limit = DEFAULT_PROJECT_PAGE_SIZE) {
return request(`/projects${buildQueryParams({ cursor, limit })}`) return request(`/projects${buildQueryParams({ cursor, limit })}`)
}, },
@ -210,7 +217,7 @@ export const projectApi = {
}, },
readFileRaw(projectId, filepath) { 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}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res return res
}) })

View File

@ -24,7 +24,7 @@
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
accept=".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" accept=ALLOWED_UPLOAD_EXTENSIONS
@change="handleFileUpload" @change="handleFileUpload"
style="display: none" style="display: none"
/> />
@ -65,6 +65,7 @@
<script setup> <script setup>
import { ref, computed, nextTick } from 'vue' import { ref, computed, nextTick } from 'vue'
import { icons } from '../utils/icons' import { icons } from '../utils/icons'
import { TEXTAREA_MAX_HEIGHT_PX, ALLOWED_UPLOAD_EXTENSIONS } from '../constants'
const props = defineProps({ const props = defineProps({
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
@ -83,7 +84,7 @@ function autoResize() {
const el = textareaRef.value const el = textareaRef.value
if (!el) return if (!el) return
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 200) + 'px' el.style.height = Math.min(el.scrollHeight, TEXTAREA_MAX_HEIGHT_PX) + 'px'
} }
function onKeydown(e) { function onKeydown(e) {

View File

@ -17,6 +17,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { DEFAULT_TRUNCATE_LENGTH } from '../constants'
const props = defineProps({ const props = defineProps({
messages: { type: Array, required: true }, messages: { type: Array, required: true },
@ -30,7 +31,7 @@ const userMessages = computed(() => props.messages.filter(m => m.role === 'user'
function preview(msg) { function preview(msg) {
if (!msg.text) return '...' if (!msg.text) return '...'
const clean = msg.text.replace(/[#*`~>\-\[\]()]/g, '').replace(/\s+/g, ' ').trim() const clean = msg.text.replace(/[#*`~>\-\[\]()]/g, '').replace(/\s+/g, ' ').trim()
return clean.length > 60 ? clean.slice(0, 60) + '...' : clean return clean.length > DEFAULT_TRUNCATE_LENGTH ? clean.slice(0, DEFAULT_TRUNCATE_LENGTH) + '...' : clean
} }
</script> </script>

View File

@ -41,7 +41,10 @@
</div> </div>
<div v-if="item.result" class="tool-detail"> <div v-if="item.result" class="tool-detail">
<span class="detail-label">返回结果:</span> <span class="detail-label">返回结果:</span>
<pre>{{ item.result }}</pre> <pre>{{ expandedResultKeys[item.key] ? item.result : item.resultPreview }}</pre>
<button v-if="item.resultTruncated" class="btn-expand-result" @click.stop="toggleResultExpand(item.key)">
{{ expandedResultKeys[item.key] ? '收起' : `展开全部 (${item.resultLength} 字符)` }}
</button>
</div> </div>
</div> </div>
</div> </div>
@ -67,6 +70,19 @@ import { ref, computed, watch } from 'vue'
import { renderMarkdown } from '../utils/markdown' import { renderMarkdown } from '../utils/markdown'
import { formatJson, truncate } from '../utils/format' import { formatJson, truncate } from '../utils/format'
import { useCodeEnhancement } from '../composables/useCodeEnhancement' 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({ const props = defineProps({
toolCalls: { type: Array, default: () => [] }, toolCalls: { type: Array, default: () => [] },
@ -75,10 +91,12 @@ const props = defineProps({
}) })
const expandedKeys = ref({}) const expandedKeys = ref({})
const expandedResultKeys = ref({})
// Auto-collapse all items when a new stream starts // Auto-collapse all items when a new stream starts
watch(() => props.streaming, (v) => { watch(() => props.streaming, (v) => {
if (v) expandedKeys.value = {} if (v) expandedKeys.value = {}
expandedResultKeys.value = {}
}) })
const processRef = ref(null) const processRef = ref(null)
@ -87,6 +105,10 @@ function toggleItem(key) {
expandedKeys.value[key] = !expandedKeys.value[key] expandedKeys.value[key] = !expandedKeys.value[key]
} }
function toggleResultExpand(key) {
expandedResultKeys.value[key] = !expandedResultKeys.value[key]
}
function getResultSummary(result) { function getResultSummary(result) {
try { try {
const parsed = typeof result === 'string' ? JSON.parse(result) : result const parsed = typeof result === 'string' ? JSON.parse(result) : result
@ -138,7 +160,7 @@ const processItems = computed(() => {
const summary = getResultSummary(step.content) const summary = getResultSummary(step.content)
const match = items.findLast(it => it.type === 'tool_call' && it.id === toolId) const match = items.findLast(it => it.type === 'tool_call' && it.id === toolId)
if (match) { if (match) {
match.result = formatJson(step.content) Object.assign(match, buildResultFields(step.content))
match.resultSummary = summary.text match.resultSummary = summary.text
match.isSuccess = summary.success match.isSuccess = summary.success
match.loading = false match.loading = false
@ -165,7 +187,8 @@ const processItems = computed(() => {
if (props.toolCalls && props.toolCalls.length > 0) { if (props.toolCalls && props.toolCalls.length > 0) {
props.toolCalls.forEach((call, i) => { props.toolCalls.forEach((call, i) => {
const toolName = call.function?.name || '未知工具' 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({ items.push({
type: 'tool_call', type: 'tool_call',
toolName, toolName,
@ -174,9 +197,9 @@ const processItems = computed(() => {
id: call.id, id: call.id,
key: `tool_call-${call.id || i}`, key: `tool_call-${call.id || i}`,
loading: !call.result && props.streaming, loading: !call.result && props.streaming,
result: call.result ? formatJson(call.result) : null, ...resultFields,
resultSummary: result ? result.text : null, resultSummary: resultSummary ? resultSummary.text : null,
isSuccess: result ? result.success : undefined, isSuccess: resultSummary ? resultSummary.success : undefined,
}) })
}) })
} }
@ -345,6 +368,23 @@ watch(() => props.processSteps?.length, () => {
word-break: break-word; 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 — rendered as markdown */
.text-content { .text-content {
padding: 0; padding: 0;

View File

@ -126,6 +126,7 @@ import { reactive, ref, watch, onMounted } from 'vue'
import { conversationApi } from '../api' import { conversationApi } from '../api'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import { icons } from '../utils/icons' import { icons } from '../utils/icons'
import { SETTINGS_AUTO_SAVE_DEBOUNCE_MS } from '../constants'
const props = defineProps({ const props = defineProps({
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
@ -218,7 +219,7 @@ let saveTimer = null
watch(form, () => { watch(form, () => {
if (props.visible && props.conversation && syncedConvId === props.conversation.id && !isSyncing) { if (props.visible && props.conversation && syncedConvId === props.conversation.id && !isSyncing) {
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(saveChanges, 500) saveTimer = setTimeout(saveChanges, SETTINGS_AUTO_SAVE_DEBOUNCE_MS)
} }
}, { deep: true }) }, { deep: true })

View File

@ -113,6 +113,7 @@
import { computed, reactive } from 'vue' import { computed, reactive } from 'vue'
import { formatTime } from '../utils/format' import { formatTime } from '../utils/format'
import { icons } from '../utils/icons' import { icons } from '../utils/icons'
import { INFINITE_SCROLL_THRESHOLD_PX } from '../constants'
const props = defineProps({ const props = defineProps({
conversations: { type: Array, required: true }, conversations: { type: Array, required: true },
@ -171,7 +172,7 @@ function toggleGroup(id) {
function onScroll(e) { function onScroll(e) {
const el = e.target 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') emit('loadMore')
} }
} }

View File

@ -1,5 +1,6 @@
import { watch, onMounted, nextTick, onUnmounted } from 'vue' import { watch, onMounted, nextTick, onUnmounted } from 'vue'
import { enhanceCodeBlocks } from '../utils/markdown' import { enhanceCodeBlocks } from '../utils/markdown'
import { CODE_ENHANCE_DEBOUNCE_MS } from '../constants'
/** /**
* Composable for enhancing code blocks in a container element. * Composable for enhancing code blocks in a container element.
@ -18,7 +19,7 @@ export function useCodeEnhancement(templateRef, dep, watchOpts) {
function debouncedEnhance() { function debouncedEnhance() {
if (debounceTimer) clearTimeout(debounceTimer) if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => nextTick(enhance), 150) debounceTimer = setTimeout(() => nextTick(enhance), CODE_ENHANCE_DEBOUNCE_MS)
} }
onMounted(enhance) onMounted(enhance)

View File

@ -1,4 +1,5 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import { TOAST_DEFAULT_DURATION } from '../constants'
const state = reactive({ const state = reactive({
toasts: [], toasts: [],
@ -6,7 +7,7 @@ const state = reactive({
}) })
export function useToast() { export function useToast() {
function add(type, message, duration = 1500) { function add(type, message, duration = TOAST_DEFAULT_DURATION) {
const id = ++state._id const id = ++state._id
state.toasts.push({ id, type, message }) state.toasts.push({ id, type, message })
setTimeout(() => { setTimeout(() => {

37
frontend/src/constants.js Normal file
View File

@ -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'

View File

@ -3,9 +3,10 @@ import App from './App.vue'
import './styles/global.css' import './styles/global.css'
import './styles/highlight.css' import './styles/highlight.css'
import 'katex/dist/katex.min.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 // 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') { if (savedTheme === 'dark' || savedTheme === 'light') {
document.documentElement.setAttribute('data-theme', savedTheme) document.documentElement.setAttribute('data-theme', savedTheme)
} }

View File

@ -1,3 +1,5 @@
import { DEFAULT_TRUNCATE_LENGTH } from '../constants'
/** /**
* Format ISO date string to a short time string. * Format ISO date string to a short time string.
* - Today: "14:30" * - Today: "14:30"
@ -36,7 +38,7 @@ export function formatJson(value) {
/** /**
* Truncate text to max characters with ellipsis. * Truncate text to max characters with ellipsis.
*/ */
export function truncate(text, max = 60) { export function truncate(text, max = DEFAULT_TRUNCATE_LENGTH) {
if (!text) return '' if (!text) return ''
const str = text.replace(/\s+/g, ' ').trim() const str = text.replace(/\s+/g, ' ').trim()
return str.length > max ? str.slice(0, max) + '\u2026' : str return str.length > max ? str.slice(0, max) + '\u2026' : str

View File

@ -2,6 +2,7 @@ import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight' import { markedHighlight } from 'marked-highlight'
import katex from 'katex' import katex from 'katex'
import { highlightCode } from './highlight' import { highlightCode } from './highlight'
import { COPY_BUTTON_RESET_MS } from '../constants'
function renderMath(text, displayMode) { function renderMath(text, displayMode) {
try { try {
@ -108,7 +109,7 @@ export function enhanceCodeBlocks(container) {
copyBtn.addEventListener('click', () => { copyBtn.addEventListener('click', () => {
const raw = code?.textContent || '' 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) { if (navigator.clipboard) {
navigator.clipboard.writeText(raw).then(copy) navigator.clipboard.writeText(raw).then(copy)
} else { } else {