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:
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user