feat: AgentChat streaming UX — answer_chunk typewriter, token/cost display, stop marker, session manage, date dividers

- Backend: token-level streaming via on_delta callback + asyncio.Queue bridge, answer_chunk SSE events; final event carries token_usage with cost_yuan; kimi pricing in cost_estimator
- Frontend: typewriter rendering of answer chunks; token/cost in message meta; abort marks message '已停止' and no longer triggers duplicate non-stream fallback; session pin/delete in dropdown (pinned first); date dividers across days
This commit is contained in:
2026-07-26 20:57:11 +08:00
parent cf2a9c7695
commit fe37f8be5c
4 changed files with 284 additions and 47 deletions

View File

@@ -33,14 +33,33 @@
<span style="color: var(--el-color-primary); font-weight: 500">+ 新对话</span>
</el-option>
<el-option
v-for="s in sessions"
v-for="s in sortedSessions"
:key="s.session_id"
:label="s.title || '未命名对话'"
:value="s.session_id"
>
<div style="display:flex;flex-direction:column;width:100%">
<span class="session-option-title">{{ s.title || '未命名对话' }}</span>
<span class="session-option-meta">{{ s.message_count }} 条消息 · {{ relativeTimeText(s.updated_at) }}</span>
<div class="session-option-row">
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
<span class="session-option-title">
<el-icon v-if="s.is_pinned" :size="12" style="color:var(--el-color-warning);vertical-align:-1px"><Top /></el-icon>
{{ s.title || '未命名对话' }}
</span>
<span class="session-option-meta">{{ s.message_count }} 条消息 · {{ relativeTimeText(s.updated_at) }}</span>
</div>
<span class="session-option-actions">
<el-icon
:size="14"
:style="{ color: s.is_pinned ? 'var(--el-color-warning)' : 'var(--el-text-color-placeholder)' }"
:title="s.is_pinned ? '取消置顶' : '置顶'"
@click.stop.prevent="togglePinSession(s)"
><Top /></el-icon>
<el-icon
:size="14"
style="color:var(--el-color-danger)"
title="删除会话"
@click.stop.prevent="deleteSession(s)"
><Delete /></el-icon>
</span>
</div>
</el-option>
</el-select>
@@ -170,24 +189,26 @@
</template>
</div>
<ChatMessageBubble
v-for="(msg, i) in displayMessages"
:key="i"
:role="msg.role"
:content-html="renderMarkdown(msg.orchestrateResult?.final_answer || msg.content)"
:timestamp="msg.timestamp"
:status="msg.status"
:iterations="msg.iterations"
:tool-calls-made="msg.tool_calls_made"
>
<template v-if="msg.orchestrateResult" #extra>
<template v-for="item in displayItems" :key="item.key">
<div v-if="item._divider" class="date-divider"><span class="date-divider-text">{{ item.label }}</span></div>
<ChatMessageBubble
v-else
:role="item.msg.role"
:content-html="renderMarkdown(item.msg.orchestrateResult?.final_answer || item.msg.content)"
:timestamp="item.msg.timestamp"
:status="item.msg.status"
:iterations="item.msg.iterations"
:tool-calls-made="item.msg.tool_calls_made"
:token-usage="item.msg.token_usage"
>
<template v-if="item.msg.orchestrateResult" #extra>
<div class="orchestrate-result">
<div class="orch-header">
<el-tag size="small" type="info">{{ msg.orchestrateResult.mode }}</el-tag>
<span class="orch-agent-count">{{ msg.orchestrateResult.steps.length }} 个 Agent</span>
<el-tag size="small" type="info">{{ item.msg.orchestrateResult.mode }}</el-tag>
<span class="orch-agent-count">{{ item.msg.orchestrateResult.steps.length }} 个 Agent</span>
</div>
<div class="orch-steps">
<div v-for="(step, si) in msg.orchestrateResult.steps" :key="si" class="orch-step" :class="{ expanded: step._open }">
<div v-for="(step, si) in item.msg.orchestrateResult.steps" :key="si" class="orch-step" :class="{ expanded: step._open }">
<div class="orch-step-header" @click="step._open = !step._open">
<el-icon><CaretRight :style="{ transform: step._open ? 'rotate(90deg)' : '' }" /></el-icon>
<el-tag size="small" :type="step.error ? 'danger' : 'success'" round>{{ step.agent_name }}</el-tag>
@@ -201,21 +222,21 @@
</div>
</template>
<template v-if="!msg.orchestrateResult" #extra>
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls">
<div class="tool-calls-header"><el-icon><Tools /></el-icon> 工具调用 ({{ msg.tool_calls.length }})</div>
<div v-for="(tc, j) in msg.tool_calls" :key="j" class="tool-call-item">
<template v-if="!item.msg.orchestrateResult" #extra>
<div v-if="item.msg.tool_calls && item.msg.tool_calls.length > 0" class="tool-calls">
<div class="tool-calls-header"><el-icon><Tools /></el-icon> 工具调用 ({{ item.msg.tool_calls.length }})</div>
<div v-for="(tc, j) in item.msg.tool_calls" :key="j" class="tool-call-item">
<span class="tool-name">{{ tc.function?.name || '?' }}</span>
<el-tag size="small" type="info">{{ safeParseArgCount(tc.function?.arguments) }} 个参数</el-tag>
</div>
</div>
<div v-if="msg.steps && msg.steps.length > 0" class="thinking-trace">
<div class="trace-header" @click="toggleTrace(msg)">
<el-icon><CaretRight :style="{ transform: msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
<span>思考链 ({{ msg.steps.length }} 步)</span>
<div v-if="item.msg.steps && item.msg.steps.length > 0" class="thinking-trace">
<div class="trace-header" @click="toggleTrace(item.msg)">
<el-icon><CaretRight :style="{ transform: item.msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
<span>思考链 ({{ item.msg.steps.length }} 步)</span>
</div>
<div v-show="msg._traceOpen" class="trace-steps">
<div v-for="(step, si) in msg.steps" :key="si" class="trace-step" :class="'step-' + step.type">
<div v-show="item.msg._traceOpen" class="trace-steps">
<div v-for="(step, si) in item.msg.steps" :key="si" class="trace-step" :class="'step-' + step.type">
<div class="step-icon">
<el-icon v-if="step.type === 'think'"><ChatDotSquare /></el-icon>
<el-icon v-else-if="step.type === 'tool_result'"><Tools /></el-icon>
@@ -247,16 +268,17 @@
</template>
<template #actions>
<el-button v-if="msg.role === 'assistant' && msg.content" link size="small" @click="isSpeaking ? stopSpeak() : speakMessage(msg.orchestrateResult?.final_answer || msg.content)" :title="isSpeaking ? '停止朗读' : '朗读'">
<el-button v-if="item.msg.role === 'assistant' && item.msg.content" link size="small" @click="isSpeaking ? stopSpeak() : speakMessage(item.msg.orchestrateResult?.final_answer || item.msg.content)" :title="isSpeaking ? '停止朗读' : '朗读'">
<el-icon><Headset v-if="!isSpeaking" /><VideoPause v-else /></el-icon>
</el-button>
<el-button v-if="msg.role === 'user'" link size="small" @click="editMessage(i)" title="编辑"><el-icon><Edit /></el-icon></el-button>
<el-button v-if="msg.role === 'assistant'" link size="small" @click="copyMessage(msg)" title="复制"><el-icon><DocumentCopy /></el-icon></el-button>
<el-button link size="small" @click="createBranch(i)" title="从此分支"><el-icon><Share /></el-icon></el-button>
<el-button v-if="msg.role === 'assistant' && !loading" link :type="msg.status === 'error' ? 'danger' : undefined" size="small" @click="retryMessage(i)" :title="msg.status === 'error' ? '重试' : '重新生成'"><el-icon><Refresh /></el-icon></el-button>
<el-button link size="small" @click="deleteMessage(i)" title="删除"><el-icon style="color:var(--el-color-danger)"><Delete /></el-icon></el-button>
<el-button v-if="item.msg.role === 'user'" link size="small" @click="editMessage(item.idx)" title="编辑"><el-icon><Edit /></el-icon></el-button>
<el-button v-if="item.msg.role === 'assistant'" link size="small" @click="copyMessage(item.msg)" title="复制"><el-icon><DocumentCopy /></el-icon></el-button>
<el-button link size="small" @click="createBranch(item.idx)" title="从此分支"><el-icon><Share /></el-icon></el-button>
<el-button v-if="item.msg.role === 'assistant' && !loading" link :type="item.msg.status === 'error' ? 'danger' : undefined" size="small" @click="retryMessage(item.idx)" :title="item.msg.status === 'error' ? '重试' : '重新生成'"><el-icon><Refresh /></el-icon></el-button>
<el-button link size="small" @click="deleteMessage(item.idx)" title="删除"><el-icon style="color:var(--el-color-danger)"><Delete /></el-icon></el-button>
</template>
</ChatMessageBubble>
</ChatMessageBubble>
</template>
<div v-if="loading && !streamingActive" class="message assistant">
<div class="message-avatar"><el-avatar :size="36" icon="Promotion" /></div>
@@ -449,7 +471,7 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ChatLineSquare, UserFilled, Promotion, Tools, CaretRight, ChatDotSquare, Select, DocumentCopy, Refresh, VideoPause, Edit, Delete, Headset, Setting, Search, Download, Share, MagicStick } from '@element-plus/icons-vue'
import { ChatLineSquare, UserFilled, Promotion, Tools, CaretRight, ChatDotSquare, Select, DocumentCopy, Refresh, VideoPause, Edit, Delete, Headset, Setting, Search, Download, Share, MagicStick, Top } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import ChatMessageBubble from '@/components/ChatMessageBubble.vue'
import ChatInputArea from '@/components/ChatInputArea.vue'
@@ -481,6 +503,7 @@ interface OrchestrateResult {
interface ChatMessage {
role: 'user' | 'assistant'; content: string; tool_calls?: any[]; timestamp: number
iterations?: number; tool_calls_made?: number; status?: string; steps?: AgentStep[]
token_usage?: any
_traceOpen?: boolean; orchestrateResult?: OrchestrateResult
}
interface OrchestrateAgentForm {
@@ -612,6 +635,33 @@ const currentAgentKey = computed(() => {
})
const displayMessages = computed(() => messages.value[currentAgentKey.value] || [])
// 日期分割线:消息列表中跨天时插入分隔项
interface DisplayItem {
_divider?: boolean; label?: string; key: string
msg?: ChatMessage; idx?: number
}
function _dayStart(ts: number): number { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime() }
function dayLabelText(ts: number): string {
const days = Math.round((_dayStart(Date.now()) - _dayStart(ts)) / 86400000)
if (days <= 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days} 天前`
return new Date(ts).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
const displayItems = computed<DisplayItem[]>(() => {
const items: DisplayItem[] = []
let lastDay = -1
displayMessages.value.forEach((msg, idx) => {
const day = _dayStart(msg.timestamp || Date.now())
if (day !== lastDay) {
lastDay = day
items.push({ _divider: true, label: dayLabelText(msg.timestamp || Date.now()), key: `divider-${day}` })
}
items.push({ key: `msg-${idx}-${msg.timestamp}`, msg, idx })
})
return items
})
// 编排模式
const chatMode = ref<'single' | 'orchestrate'>('single')
const orchestrateMode = ref('debate')
@@ -891,6 +941,38 @@ function newSession() {
nextTick(scrollToBottom)
}
// 置顶优先,其余按更新时间倒序
const sortedSessions = computed(() =>
[...sessions.value].sort((a, b) => {
if (!!a.is_pinned !== !!b.is_pinned) return a.is_pinned ? -1 : 1
return new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime()
})
)
async function togglePinSession(s: SessionInfo) {
try {
await api.patch(`/api/v1/agent-chat/${currentAgentId.value}/sessions/${s.session_id}`, { is_pinned: !s.is_pinned })
s.is_pinned = !s.is_pinned
ElMessage.success(s.is_pinned ? '已置顶' : '已取消置顶')
} catch { ElMessage.error('操作失败') }
}
async function deleteSession(s: SessionInfo) {
try {
await ElMessageBox.confirm(
`确定删除会话「${s.title || '未命名对话'}」吗?该操作不可恢复。`,
'删除会话',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch { return }
try {
await api.delete(`/api/v1/agent-chat/${currentAgentId.value}/sessions/${s.session_id}`)
sessions.value = sessions.value.filter(x => x.session_id !== s.session_id)
if (currentSessionId.value === s.session_id) newSession()
ElMessage.success('会话已删除')
} catch { ElMessage.error('删除失败') }
}
// ── 预设问题:LLM 按 Agent 描述生成,localStorage 缓存(Agent 更新后自动失效)──
const presetQuestions = ref<string[]>([])
const presetLoading = ref(false)
@@ -1044,7 +1126,11 @@ async function sendMessage() {
const data = JSON.parse(dataStr)
if (!receivedFirstEvent) { receivedFirstEvent = true; streamingActive.value = true }
if (eventType === 'think') {
currentMsg.content = ''
currentMsg.steps!.push({ iteration: data.iteration, type: 'think', content: data.content || '思考中...', reasoning: data.reasoning, tool_name: data.tool_names?.[0] })
} else if (eventType === 'answer_chunk') {
currentMsg.content = (currentMsg.content || '') + (data.content || '')
scrollToBottom()
} else if (eventType === 'tool_call') {
currentMsg.steps!.push({ iteration: data.iteration, type: 'tool_call', content: `调用工具: ${data.name}`, tool_name: data.name, tool_input: data.input })
} else if (eventType === 'tool_result') {
@@ -1055,6 +1141,7 @@ async function sendMessage() {
} else if (eventType === 'final') {
clearTimeout(streamTimeout)
currentMsg.content = data.content || ''; currentMsg.iterations = data.iterations_used || 0; currentMsg.tool_calls_made = data.tool_calls_made || 0
if (data.token_usage) currentMsg.token_usage = data.token_usage
if (data.session_id) { sessionId.value[key] = data.session_id; if (!currentSessionId.value) { currentSessionId.value = data.session_id; loadSessions() } }
streamingActive.value = false; loading.value = false
} else if (eventType === 'error') {
@@ -1066,7 +1153,19 @@ async function sendMessage() {
}
clearTimeout(streamTimeout)
}
} catch { clearTimeout(streamTimeout); usedStreaming = false; streamingActive.value = false }
} catch {
clearTimeout(streamTimeout); streamingActive.value = false
if (abortController.value?.signal.aborted) {
// 用户主动停止:标记「已停止」,阻止非流式 fallback 重复请求
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) {
const m = messages.value[key][placeholderIdx]
if (m.content || (m.steps && m.steps.length)) { m.status = 'stopped' } else { messages.value[key].splice(placeholderIdx, 1) }
}
loading.value = false; saveState(); nextTick(scrollToBottom)
return
}
usedStreaming = false
}
if (!usedStreaming) {
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) messages.value[key].splice(placeholderIdx, 1)
@@ -1231,6 +1330,26 @@ const hasMoreHistory = ref(false)
/* Session selector */
.session-option-title { font-size: 13px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
.session-option-row { display: flex; align-items: center; width: 100%; gap: 8px; }
.session-option-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.session-option-actions .el-icon { cursor: pointer; padding: 2px; }
.session-option-actions .el-icon:hover { transform: scale(1.15); }
.date-divider {
display: flex; align-items: center; justify-content: center;
margin: 16px 0 8px; width: 100%;
position: relative;
}
.date-divider::before {
content: ''; position: absolute; left: 0; right: 0; top: 50%;
border-top: 1px solid var(--el-border-color-lighter);
}
.date-divider-text {
position: relative; z-index: 1;
background: var(--el-bg-color);
padding: 2px 12px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.session-option-meta { font-size: 11px; color: var(--el-text-color-placeholder); }
/* Orchestrate */