fix: 后端与前端流式适配与优化
This commit is contained in:
parent
cc639a979a
commit
ae73559fd2
|
|
@ -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:
|
||||||
|
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:
|
try:
|
||||||
stream_result = self._stream_llm_response(
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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 --
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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/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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue