fix: 后端与前端流式适配与优化
This commit is contained in:
parent
cc639a979a
commit
ae73559fd2
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 --
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<input
|
||||
ref="fileInputRef"
|
||||
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"
|
||||
style="display: none"
|
||||
/>
|
||||
|
|
@ -65,6 +65,7 @@
|
|||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { icons } from '../utils/icons'
|
||||
import { TEXTAREA_MAX_HEIGHT_PX, ALLOWED_UPLOAD_EXTENSIONS } from '../constants'
|
||||
|
||||
const props = defineProps({
|
||||
disabled: { type: Boolean, default: false },
|
||||
|
|
@ -83,7 +84,7 @@ function autoResize() {
|
|||
const el = textareaRef.value
|
||||
if (!el) return
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { DEFAULT_TRUNCATE_LENGTH } from '../constants'
|
||||
|
||||
const props = defineProps({
|
||||
messages: { type: Array, required: true },
|
||||
|
|
@ -30,7 +31,7 @@ const userMessages = computed(() => props.messages.filter(m => m.role === 'user'
|
|||
function preview(msg) {
|
||||
if (!msg.text) return '...'
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@
|
|||
</div>
|
||||
<div v-if="item.result" class="tool-detail">
|
||||
<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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue