feat: 增加多对话流处理

This commit is contained in:
ViperEkura 2026-04-14 10:44:33 +08:00
parent 535eefa8de
commit 4bf59fe6e0
5 changed files with 533 additions and 168 deletions

View File

@ -3,3 +3,6 @@ import { createPinia } from 'pinia'
const pinia = createPinia() const pinia = createPinia()
export default pinia export default pinia
// 导出 store 供其他地方使用
export { useStreamStore } from './streamStore.js'

View File

@ -0,0 +1,217 @@
/**
* StreamManager - 管理多个并发 SSE
*
* 功能
* 1. 同时管理多个会话的流式请求
* 2. 支持取消重试等操作
* 3. Pinia store 集成
*/
import { useStreamStore } from './streamStore.js'
class StreamManager {
constructor() {
// 存储所有活跃的流:{ conversationId: { abort, promise } }
this.activeStreams = {}
// SSE 解码器
this.decoder = new TextDecoder()
}
/**
* 启动一个新的流
* @param {string} conversationId - 会话 ID
* @param {object} data - 请求数据
* @param {string} userMessageId - 用户消息 ID
*/
async startStream(conversationId, data, userMessageId) {
const streamStore = useStreamStore()
// 如果该会话已有活跃流,先取消
if (this.activeStreams[conversationId]) {
this.cancelStream(conversationId)
}
const controller = new AbortController()
this.activeStreams[conversationId] = { controller }
// 初始化 store 中的流状态
streamStore.initStream(conversationId, userMessageId)
const promise = this._executeStream(conversationId, data, controller.signal)
this.activeStreams[conversationId].promise = promise
return promise
}
/**
* 执行 SSE
*/
async _executeStream(conversationId, data, signal) {
const streamStore = useStreamStore()
const token = localStorage.getItem('access_token')
try {
const res = await fetch('/api/messages/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
conversation_id: data.conversation_id,
content: data.content,
thinking_enabled: data.thinking_enabled || false,
enabled_tools: data.enabled_tools || []
}),
signal
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.message || `HTTP ${res.status}`)
}
const reader = res.body.getReader()
let buffer = ''
let completed = false
while (true) {
const { done, value } = await reader.read()
if (value) {
buffer += this.decoder.decode(value, { stream: true })
}
if (done) {
// 处理 buffer 中剩余的数据
this._processBuffer(conversationId, buffer, streamStore, () => {
completed = true
})
if (!completed) {
streamStore.errorStream(conversationId, 'stream ended without done event')
}
break
}
const lines = buffer.split('\n')
buffer = lines.pop() || ''
this._processLines(conversationId, lines, streamStore)
}
// 流结束但没有收到 done 事件
if (!completed) {
streamStore.errorStream(conversationId, 'stream ended unexpectedly')
}
} catch (e) {
if (e.name !== 'AbortError') {
console.error('Stream error:', e)
streamStore.errorStream(conversationId, e.message)
}
} finally {
// 清理活跃流记录
delete this.activeStreams[conversationId]
}
}
/**
* 处理缓冲区
*/
_processBuffer(conversationId, buffer, streamStore, onComplete) {
const lines = buffer.split('\n')
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
this._handleEvent(conversationId, currentEvent, data, streamStore, onComplete)
} catch (e) {
console.error('SSE parse error:', e, 'line:', line)
}
}
}
}
/**
* 处理行
*/
_processLines(conversationId, lines, streamStore) {
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
this._handleEvent(conversationId, currentEvent, data, streamStore, null)
} catch (e) {
// 忽略解析错误
}
}
}
}
/**
* 处理事件
*/
_handleEvent(conversationId, eventType, data, streamStore, onComplete) {
switch (eventType) {
case 'process_step':
streamStore.updateStep(conversationId, data.step)
break
case 'done':
streamStore.completeStream(conversationId, data)
if (onComplete) onComplete()
break
case 'error':
streamStore.errorStream(conversationId, data.content)
if (onComplete) onComplete()
break
}
}
/**
* 取消指定会话的流
*/
cancelStream(conversationId) {
const stream = this.activeStreams[conversationId]
if (stream) {
stream.controller.abort()
delete this.activeStreams[conversationId]
}
// 清除 store 中的流状态
const streamStore = useStreamStore()
streamStore.clearStream(conversationId)
}
/**
* 取消所有流
*/
cancelAll() {
Object.keys(this.activeStreams).forEach(conversationId => {
this.cancelStream(conversationId)
})
}
/**
* 检查指定会话是否有活跃流
*/
hasActiveStream(conversationId) {
return !!this.activeStreams[conversationId]
}
/**
* 获取活跃流数量
*/
getActiveCount() {
return Object.keys(this.activeStreams).length
}
}
// 导出单例
export const streamManager = new StreamManager()
export default streamManager

View File

@ -0,0 +1,102 @@
/**
* 流状态管理 Store
* 管理多个会话的并发流式消息状态
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useStreamStore = defineStore('stream', () => {
// 存储所有活跃的流状态:{ conversationId: StreamState }
const streams = ref({})
// 获取指定会话的流状态
const getStreamState = (conversationId) => {
return streams.value[conversationId] || null
}
// 检查指定会话是否有活跃的流
const hasActiveStream = (conversationId) => {
const state = streams.value[conversationId]
return state && state.status === 'streaming'
}
// 获取所有有活跃流的会话 ID
const activeConversationIds = computed(() => {
return Object.keys(streams.value).filter(id =>
streams.value[id]?.status === 'streaming'
)
})
// 初始化流状态
const initStream = (conversationId, userMessageId) => {
streams.value[conversationId] = {
id: userMessageId,
status: 'streaming', // streaming, done, error
process_steps: [],
token_count: 0,
usage: null,
error: null,
started_at: new Date().toISOString(),
completed_at: null
}
}
// 更新步骤(追加或更新)
const updateStep = (conversationId, step) => {
const state = streams.value[conversationId]
if (!state) return
const idx = state.process_steps.findIndex(s => s.id === step.id)
if (idx >= 0) {
state.process_steps[idx] = step
} else {
state.process_steps.push(step)
}
}
// 完成流
const completeStream = (conversationId, data) => {
const state = streams.value[conversationId]
if (!state) return
state.status = 'done'
state.token_count = data.token_count || 0
state.usage = data.usage || null
state.completed_at = new Date().toISOString()
}
// 流错误
const errorStream = (conversationId, error) => {
const state = streams.value[conversationId]
if (!state) return
state.status = 'error'
state.error = error
state.completed_at = new Date().toISOString()
}
// 清除流状态
const clearStream = (conversationId) => {
delete streams.value[conversationId]
}
// 批量设置流状态(用于恢复)
const setStreamState = (conversationId, state) => {
streams.value[conversationId] = state
}
return {
streams,
getStreamState,
hasActiveStream,
activeConversationIds,
initStream,
updateStep,
completeStream,
errorStream,
clearStream,
setStreamState
}
})
export default useStreamStore

View File

@ -49,13 +49,13 @@
/> />
</div> </div>
<!-- 流式消息 --> <!-- 流式消息 - 使用 store 中的状态 -->
<div v-if="streamingMessage" class="message-bubble assistant streaming"> <div v-if="currentStreamState" class="message-bubble assistant streaming">
<div class="avatar">Luxx</div> <div class="avatar">Luxx</div>
<div class="message-body"> <div class="message-body">
<ProcessBlock <ProcessBlock
:process-steps="streamingMessage.process_steps" :process-steps="currentStreamState.process_steps"
:streaming="true" :streaming="currentStreamState.status === 'streaming'"
/> />
</div> </div>
</div> </div>
@ -92,28 +92,41 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { conversationsAPI, messagesAPI, toolsAPI } from '../utils/api.js' import { conversationsAPI, messagesAPI, toolsAPI } from '../utils/api.js'
import { streamManager } from '../utils/streamManager.js'
import { useStreamStore } from '../utils/streamStore.js'
import ProcessBlock from '../components/ProcessBlock.vue' import ProcessBlock from '../components/ProcessBlock.vue'
import MessageBubble from '../components/MessageBubble.vue' import MessageBubble from '../components/MessageBubble.vue'
import { renderMarkdown } from '../utils/markdown.js'
const route = useRoute() const route = useRoute()
const router = useRouter()
const streamStore = useStreamStore()
const messages = ref([]) const messages = ref([])
const inputMessage = ref('') const inputMessage = ref('')
const loading = ref(true) const loading = ref(true)
const sending = ref(false)
const streamingMessage = ref(null)
const messagesContainer = ref(null) const messagesContainer = ref(null)
const textareaRef = ref(null) const textareaRef = ref(null)
const autoScroll = ref(true) const autoScroll = ref(true)
const conversationId = ref(route.params.id) const conversationId = ref(route.params.id)
const conversationTitle = ref('') const conversationTitle = ref('')
const enabledTools = ref([]) // const enabledTools = ref([])
const canSend = computed(() => inputMessage.value.trim().length > 0) const canSend = computed(() => inputMessage.value.trim().length > 0)
// store
const currentStreamState = computed(() => {
return streamStore.getStreamState(conversationId.value)
})
// sending
const sending = computed(() => {
const state = currentStreamState.value
return state && state.status === 'streaming'
})
const sendIcon = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>` const sendIcon = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>`
function autoResize() { function autoResize() {
@ -138,16 +151,13 @@ const loadMessages = async () => {
if (res.success) { if (res.success) {
messages.value = res.data.messages || [] messages.value = res.data.messages || []
if (messages.value.length > 0) { if (messages.value.length > 0) {
// 使
if (res.data.title) { if (res.data.title) {
conversationTitle.value = res.data.title conversationTitle.value = res.data.title
} else if (res.data.first_message) { } else if (res.data.first_message) {
conversationTitle.value = res.data.first_message conversationTitle.value = res.data.first_message
} else { } else {
//
const userMsg = messages.value.find(m => m.role === 'user') const userMsg = messages.value.find(m => m.role === 'user')
if (userMsg) { if (userMsg) {
// 30
conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '') conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '')
} }
} }
@ -161,7 +171,6 @@ const loadMessages = async () => {
} }
} }
//
const loadEnabledTools = async () => { const loadEnabledTools = async () => {
try { try {
const res = await toolsAPI.list() const res = await toolsAPI.list()
@ -188,16 +197,15 @@ const sendMessage = async () => {
const content = inputMessage.value.trim() const content = inputMessage.value.trim()
inputMessage.value = '' inputMessage.value = ''
sending.value = true
//
nextTick(() => { nextTick(() => {
autoResize() autoResize()
}) })
// //
const userMsgId = 'user-' + Date.now()
messages.value.push({ messages.value.push({
id: Date.now(), id: userMsgId,
role: 'user', role: 'user',
content: content, content: content,
text: content, text: content,
@ -207,71 +215,15 @@ const sendMessage = async () => {
}) })
scrollToBottom() scrollToBottom()
// // 使 StreamManager
streamingMessage.value = { await streamManager.startStream(
id: Date.now() + 1, conversationId.value,
role: 'assistant',
process_steps: [],
created_at: new Date().toISOString()
}
// SSE
messagesAPI.sendStream(
{ {
conversation_id: conversationId.value, conversation_id: conversationId.value,
content, content,
enabled_tools: enabledTools.value // enabled_tools: enabledTools.value
}, },
{ userMsgId
onProcessStep: (step) => {
autoScroll.value = true //
if (!streamingMessage.value) return
// id
const idx = streamingMessage.value.process_steps.findIndex(s => s.id === step.id)
if (idx >= 0) {
streamingMessage.value.process_steps[idx] = step
} else {
streamingMessage.value.process_steps.push(step)
}
},
onDone: (data) => {
//
autoScroll.value = true
if (streamingMessage.value) {
messages.value.push({
...streamingMessage.value,
created_at: new Date().toISOString(),
token_count: data.token_count,
usage: data.usage
})
//
if (!conversationTitle.value || conversationTitle.value === '新对话') {
const userMsg = messages.value.find(m => m.role === 'user')
if (userMsg) {
conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '')
// API
conversationsAPI.update(conversationId.value, { title: conversationTitle.value })
}
}
streamingMessage.value = null
}
sending.value = false
},
onError: (error) => {
console.error('Stream error:', error)
if (streamingMessage.value) {
streamingMessage.value.process_steps.push({
id: 'error-' + Date.now(),
index: streamingMessage.value.process_steps.length,
type: 'error',
content: `错误: ${error}`
})
}
sending.value = false
}
}
) )
scrollToBottom() scrollToBottom()
@ -283,32 +235,28 @@ const scrollToBottom = () => {
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.scrollTo({ messagesContainer.value.scrollTo({
top: messagesContainer.value.scrollHeight, top: messagesContainer.value.scrollHeight,
behavior: streamingMessage.value ? 'instant' : 'smooth' behavior: 'instant'
}) })
} }
}) })
} }
//
const handleScroll = () => { const handleScroll = () => {
if (!messagesContainer.value) return if (!messagesContainer.value) return
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value
const distanceToBottom = scrollHeight - scrollTop - clientHeight const distanceToBottom = scrollHeight - scrollTop - clientHeight
// 50px
autoScroll.value = distanceToBottom < 50 autoScroll.value = distanceToBottom < 50
} }
// //
watch(() => streamingMessage.value?.process_steps?.length, () => { watch(
if (streamingMessage.value) { () => currentStreamState.value?.process_steps?.length,
() => {
if (currentStreamState.value) {
scrollToBottom() scrollToBottom()
} }
}) }
)
const formatTime = (time) => {
if (!time) return ''
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// ID // ID
watch(() => route.params.id, (newId) => { watch(() => route.params.id, (newId) => {
@ -316,13 +264,75 @@ watch(() => route.params.id, (newId) => {
conversationId.value = newId conversationId.value = newId
loadMessages() loadMessages()
loadEnabledTools() loadEnabledTools()
// watch
setupStreamWatch()
} }
}) })
//
let unwatchStream = null
const setupStreamWatch = () => {
//
if (unwatchStream) {
unwatchStream()
}
unwatchStream = watch(
() => streamStore.getStreamState(conversationId.value),
(state) => {
if (!state) return
if (state.status === 'done') {
//
autoScroll.value = true
const completedMessage = {
id: state.id,
role: 'assistant',
process_steps: state.process_steps,
token_count: state.token_count,
usage: state.usage,
created_at: new Date().toISOString()
}
messages.value.push(completedMessage)
//
if (!conversationTitle.value || conversationTitle.value === '新对话') {
const userMsg = messages.value.find(m => m.role === 'user')
if (userMsg) {
conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '')
conversationsAPI.update(conversationId.value, { title: conversationTitle.value })
}
}
//
streamStore.clearStream(conversationId.value)
} else if (state.status === 'error') {
//
console.error('Stream error:', state.error)
streamStore.clearStream(conversationId.value)
}
},
{ deep: true }
)
}
onMounted(() => { onMounted(() => {
loadMessages() loadMessages()
loadEnabledTools() loadEnabledTools()
setupStreamWatch()
}) })
//
onUnmounted(() => {
if (unwatchStream) {
unwatchStream()
}
})
const formatTime = (time) => {
if (!time) return ''
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
</script> </script>
<style scoped> <style scoped>

View File

@ -23,7 +23,11 @@
<span class="conv-item-time">{{ formatDate(c.updated_at) }}</span> <span class="conv-item-time">{{ formatDate(c.updated_at) }}</span>
</div> </div>
<div class="conv-item-meta"> <div class="conv-item-meta">
<span class="conv-item-model">{{ c.model || '-' }}</span> <span class="conv-item-model">
{{ c.model || '-' }}
<!-- 显示活跃流指示器 -->
<span v-if="hasActiveStream(c.id)" class="streaming-indicator" title="正在生成中"></span>
</span>
<div class="conv-item-actions" @click.stop> <div class="conv-item-actions" @click.stop>
<button @click="editTitle(c)" class="btn-icon" title="重命名"> <button @click="editTitle(c)" class="btn-icon" title="重命名">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -63,7 +67,7 @@
<div class="spinner-small"></div> <div class="spinner-small"></div>
<span>加载中...</span> <span>加载中...</span>
</div> </div>
<div v-else-if="convMessages.length || streamingMessage"> <div v-else-if="convMessages.length || currentStreamState">
<!-- 历史消息 --> <!-- 历史消息 -->
<div v-for="msg in convMessages" :key="msg.id" class="chat-message" :class="msg.role" :data-msg-id="msg.id"> <div v-for="msg in convMessages" :key="msg.id" class="chat-message" :class="msg.role" :data-msg-id="msg.id">
<div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div> <div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
@ -77,13 +81,13 @@
<div v-else class="message-text" v-html="renderMsgContent(msg)"></div> <div v-else class="message-text" v-html="renderMsgContent(msg)"></div>
</div> </div>
</div> </div>
<!-- 流式消息 --> <!-- 流式消息 - 使用 store 中的状态 -->
<div v-if="streamingMessage" class="chat-message assistant streaming"> <div v-if="currentStreamState" class="chat-message assistant streaming">
<div class="message-avatar">🤖</div> <div class="message-avatar">🤖</div>
<div class="message-content"> <div class="message-content">
<ProcessBlock <ProcessBlock
:process-steps="streamingMessage.process_steps" :process-steps="currentStreamState.process_steps"
:streaming="true" :streaming="currentStreamState.status === 'streaming'"
/> />
</div> </div>
</div> </div>
@ -160,11 +164,15 @@
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { conversationsAPI, providersAPI, messagesAPI, toolsAPI } from '../utils/api.js' import { conversationsAPI, providersAPI, messagesAPI, toolsAPI } from '../utils/api.js'
import { streamManager } from '../utils/streamManager.js'
import { useStreamStore } from '../utils/streamStore.js'
import { renderMarkdown } from '../utils/markdown.js' import { renderMarkdown } from '../utils/markdown.js'
import ProcessBlock from '../components/ProcessBlock.vue' import ProcessBlock from '../components/ProcessBlock.vue'
import MessageNav from '../components/MessageNav.vue' import MessageNav from '../components/MessageNav.vue'
const router = useRouter() const router = useRouter()
const streamStore = useStreamStore()
const list = ref([]) const list = ref([])
const providers = ref([]) const providers = ref([])
const page = ref(1) const page = ref(1)
@ -179,10 +187,20 @@ const selectedId = ref(null)
const selectedConv = ref(null) const selectedConv = ref(null)
const convMessages = ref([]) const convMessages = ref([])
const loadingMessages = ref(false) const loadingMessages = ref(false)
const enabledTools = ref([]) // const enabledTools = ref([])
const totalPages = computed(() => Math.ceil(total.value / pageSize)) const totalPages = computed(() => Math.ceil(total.value / pageSize))
//
const currentStreamState = computed(() => {
return streamStore.getStreamState(selectedId.value)
})
//
const hasActiveStream = (convId) => {
return streamStore.hasActiveStream(convId)
}
// //
const loadEnabledTools = async () => { const loadEnabledTools = async () => {
try { try {
@ -228,16 +246,22 @@ const selectConv = async (c) => {
selectedId.value = c.id selectedId.value = c.id
selectedConv.value = c selectedConv.value = c
await fetchConvMessages(c.id) await fetchConvMessages(c.id)
//
setupStreamWatch()
} }
const newMessage = ref('') const newMessage = ref('')
const sending = ref(false)
const messagesContainer = ref(null) const messagesContainer = ref(null)
const streamingMessage = ref(null)
const activeMessageId = ref(null) const activeMessageId = ref(null)
let scrollObserver = null let scrollObserver = null
const observedElements = new Set() const observedElements = new Set()
// sending
const sending = computed(() => {
const state = streamStore.getStreamState(selectedId.value)
return state && state.status === 'streaming'
})
// IntersectionObserver // IntersectionObserver
const initScrollObserver = () => { const initScrollObserver = () => {
if (!messagesContainer.value) return if (!messagesContainer.value) return
@ -320,20 +344,21 @@ const scrollToMessageById = (msgId) => {
}) })
} }
watch(convMessages, () => { //
const scrollToBottom = () => {
nextTick(() => { nextTick(() => {
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
} }
}) })
}
watch(convMessages, () => {
scrollToBottom()
}, { deep: true }) }, { deep: true })
watch(() => streamingMessage.value?.process_steps?.length, () => { watch(() => currentStreamState.value?.process_steps?.length, () => {
nextTick(() => { scrollToBottom()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}) })
// Markdown // Markdown
@ -343,77 +368,69 @@ const renderMsgContent = (msg) => {
return renderMarkdown(content) return renderMarkdown(content)
} }
//
let unwatchStream = null
const setupStreamWatch = () => {
//
if (unwatchStream) {
unwatchStream()
}
unwatchStream = watch(
() => streamStore.getStreamState(selectedId.value),
(state) => {
if (!state) return
if (state.status === 'done') {
//
const completedMessage = {
id: state.id,
role: 'assistant',
process_steps: state.process_steps,
token_count: state.token_count,
usage: state.usage,
created_at: new Date().toISOString()
}
convMessages.value.push(completedMessage)
//
streamStore.clearStream(selectedId.value)
} else if (state.status === 'error') {
//
console.error('Stream error:', state.error)
streamStore.clearStream(selectedId.value)
}
},
{ deep: true }
)
}
const sendMessage = async () => { const sendMessage = async () => {
if (!newMessage.value.trim() || !selectedConv.value || sending.value) return if (!newMessage.value.trim() || !selectedConv.value || sending.value) return
const content = newMessage.value.trim() const content = newMessage.value.trim()
newMessage.value = '' newMessage.value = ''
sending.value = true
// //
const userMsgId = 'user-' + Date.now()
const userMsg = { const userMsg = {
id: 'temp-' + Date.now(), id: userMsgId,
role: 'user', role: 'user',
content: content, content: content,
created_at: new Date().toISOString() created_at: new Date().toISOString()
} }
convMessages.value.push(userMsg) convMessages.value.push(userMsg)
// // 使 StreamManager
streamingMessage.value = { await streamManager.startStream(
id: Date.now() + 1, selectedConv.value.id,
role: 'assistant', {
process_steps: [],
content: '',
created_at: new Date().toISOString()
}
try {
await new Promise((resolve, reject) => {
messagesAPI.sendStream({
conversation_id: selectedConv.value.id, conversation_id: selectedConv.value.id,
content: content, content,
enabled_tools: enabledTools.value // enabled_tools: enabledTools.value
}, {
onProcessStep: (step) => {
if (!streamingMessage.value) return
// id
const idx = streamingMessage.value.process_steps.findIndex(s => s.id === step.id)
if (idx >= 0) {
streamingMessage.value.process_steps[idx] = step
} else {
streamingMessage.value.process_steps.push(step)
}
}, },
onDone: async (data) => { userMsgId
// )
if (streamingMessage.value) {
convMessages.value.push({
...streamingMessage.value,
created_at: new Date().toISOString()
})
streamingMessage.value = null
}
resolve()
},
onError: (error) => {
if (streamingMessage.value) {
streamingMessage.value.process_steps.push({
id: 'error-' + Date.now(),
index: streamingMessage.value.process_steps.length,
type: 'error',
content: error
})
}
reject(new Error(error))
}
})
})
} catch (e) {
//
} finally {
sending.value = false
}
} }
const createConv = async () => { const createConv = async () => {
@ -436,6 +453,11 @@ const createConv = async () => {
} }
const deleteConv = async (c) => { const deleteConv = async (c) => {
//
if (hasActiveStream(c.id)) {
streamManager.cancelStream(c.id)
}
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
await conversationsAPI.delete(c.id) await conversationsAPI.delete(c.id)
if (selectedId.value === c.id) { if (selectedId.value === c.id) {
@ -469,6 +491,9 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
scrollObserver?.disconnect() scrollObserver?.disconnect()
if (unwatchStream) {
unwatchStream()
}
}) })
</script> </script>
@ -493,7 +518,14 @@ onUnmounted(() => {
.conv-item-title { font-size: 0.8rem; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; flex: 1; } .conv-item-title { font-size: 0.8rem; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; flex: 1; }
.conv-item-time { font-size: 0.65rem; color: var(--text-tertiary); flex-shrink: 0; } .conv-item-time { font-size: 0.65rem; color: var(--text-tertiary); flex-shrink: 0; }
.conv-item-meta { display: flex; justify-content: space-between; align-items: center; } .conv-item-meta { display: flex; justify-content: space-between; align-items: center; }
.conv-item-model { font-size: 0.7rem; color: var(--text-secondary); } .conv-item-model { font-size: 0.7rem; color: var(--text-secondary); display: flex; align-items: center; gap: 4px; }
.streaming-indicator {
width: 6px;
height: 6px;
background: #4ade80;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
.conv-item-actions { display: flex; gap: 0.25rem; opacity: 1; } .conv-item-actions { display: flex; gap: 0.25rem; opacity: 1; }
.btn-icon { padding: 0.2rem; background: transparent; border: none; cursor: pointer; font-size: 0.75rem; opacity: 0.6; transition: opacity 0.15s; } .btn-icon { padding: 0.2rem; background: transparent; border: none; cursor: pointer; font-size: 0.75rem; opacity: 0.6; transition: opacity 0.15s; }
.btn-icon:hover { opacity: 1; } .btn-icon:hover { opacity: 1; }
@ -556,6 +588,7 @@ onUnmounted(() => {
.loading, .empty-sidebar { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; } .loading, .empty-sidebar { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; }
.error-msg { text-align: center; padding: 1rem; color: var(--danger-color); background: var(--danger-bg); border-radius: 8px; margin: 1rem; font-size: 0.85rem; } .error-msg { text-align: center; padding: 1rem; color: var(--danger-color); background: var(--danger-bg); border-radius: 8px; margin: 1rem; font-size: 0.85rem; }
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; } .spinner-small { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 模态框 */ /* 模态框 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }