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

@@ -14,6 +14,7 @@ import hashlib
import json
import logging
import time
import asyncio
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Protocol, TypedDict
from app.agent_runtime.schemas import (
@@ -271,13 +272,23 @@ class AgentRuntime:
def _real_token_usage_payload(self) -> Dict[str, Any]:
"""构造随终态事件下发的真实用量 dict(形状含 total_tokens,供下游/前端与计费消费)。"""
return {
payload = {
"prompt_tokens": self._real_usage["prompt_tokens"],
"completion_tokens": self._real_usage["completion_tokens"],
"total_tokens": self._real_usage["total_tokens"],
"llm_calls": self._real_usage["llm_calls"],
"model": self.config.llm.model,
}
try:
from app.services.cost_estimator import estimate_cost_yuan
payload["cost_yuan"] = estimate_cost_yuan(
payload["model"] or "",
payload["prompt_tokens"],
payload["completion_tokens"],
)
except Exception:
pass
return payload
def _attach_token_usage(self, result: AgentResult) -> AgentResult:
"""将 token 用量摘要附加到 AgentResult。预算启用则用其摘要;否则用真实累计用量。"""
@@ -978,15 +989,36 @@ class AgentRuntime:
# think 事件:告知前端 Agent 正在思考(让 UI 即时反馈,避免假死感)
yield {"type": "think", "content": "", "reasoning": None, "iteration": self.context.iteration}
# 调用 LLM
# 调用 LLM(token 级流式:delta 进队列,边下边 yield answer_chunk)
try:
response = await llm.chat(
delta_q: asyncio.Queue = asyncio.Queue()
def _on_delta(text: str) -> None:
try:
delta_q.put_nowait(text)
except Exception:
pass
chat_task = asyncio.create_task(llm.chat(
messages=messages,
tools=tool_schemas if has_tools and self.context.iteration == 1 else
(tool_schemas if has_tools else None),
iteration=self.context.iteration,
on_completion=_llm_callback,
)
on_delta=_on_delta,
))
while True:
try:
piece = await asyncio.wait_for(delta_q.get(), timeout=0.2)
yield {"type": "answer_chunk", "content": piece,
"iteration": self.context.iteration}
except asyncio.TimeoutError:
if chat_task.done():
break
while not delta_q.empty():
yield {"type": "answer_chunk", "content": delta_q.get_nowait(),
"iteration": self.context.iteration}
response = chat_task.result()
except Exception as e:
err_str = str(e)
logger.error("LLM 调用失败 (iteration=%s): %s", self.context.iteration, err_str)
@@ -1803,8 +1835,10 @@ class _LLMClient:
tools: Optional[List[Dict[str, Any]]] = None,
iteration: int = 1,
on_completion: Optional[Callable[[Dict[str, Any]], Any]] = None,
on_delta: Optional[Callable[[str], None]] = None,
) -> Any:
"""调用 LLM,主模型失败时自动切换 fallback_llm 重试。"""
"""调用 LLM,主模型失败时自动切换 fallback_llm 重试。
on_delta 提供时启用 token 级流式(仅内容增量,工具调用仍会重组后返回)。"""
from openai import AsyncOpenAI
from app.core.config import settings
@@ -1822,7 +1856,7 @@ class _LLMClient:
return await self._do_chat(
api_key=api_key, base_url=base_url, model=self._config.model,
messages=messages, tools=tools, iteration=iteration,
on_completion=on_completion,
on_completion=on_completion, on_delta=on_delta,
)
async def _do_chat(
@@ -1835,6 +1869,7 @@ class _LLMClient:
iteration: int = 1,
on_completion: Optional[Callable[[Dict[str, Any]], Any]] = None,
_is_fallback: bool = False,
on_delta: Optional[Callable[[str], None]] = None,
) -> Any:
from openai import AsyncOpenAI
from app.core.config import settings
@@ -1883,10 +1918,22 @@ class _LLMClient:
tool_calls = None
return _CachedMsg()
# token 级流式:要求增量回调时走 stream=True,末 chunk 带 usage
if on_delta:
kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True}
start_time = time.perf_counter()
last_error = None
message = None
usage = None
try:
if on_delta:
message, usage = await self._stream_chat(client, kwargs, on_delta)
else:
response = await client.chat.completions.create(**kwargs)
message = response.choices[0].message
usage = getattr(response, "usage", None)
except Exception as e:
last_error = e
@@ -1914,6 +1961,7 @@ class _LLMClient:
iteration=iteration,
on_completion=on_completion,
_is_fallback=_is_fallback,
on_delta=on_delta,
)
except Exception as ce:
logger.error("ReactiveCompact 失败: %s", ce)
@@ -1953,12 +2001,11 @@ class _LLMClient:
model=fb_model,
messages=messages, tools=tools,
iteration=iteration, on_completion=on_completion,
_is_fallback=True,
_is_fallback=True, on_delta=on_delta,
)
raise
latency_ms = int((time.perf_counter() - start_time) * 1000)
message = response.choices[0].message
# 缓存写入(仅不用工具时)
if self._config.cache_enabled and not tools and message.content:
@@ -1966,7 +2013,6 @@ class _LLMClient:
await _llm_cache_set(ck, message.content, self._config.cache_ttl_ms)
# 提取 token 用量
usage = getattr(response, "usage", None)
prompt_tokens = usage.prompt_tokens if usage else 0
completion_tokens = usage.completion_tokens if usage else 0
total_tokens = usage.total_tokens if usage else 0
@@ -1985,3 +2031,61 @@ class _LLMClient:
})
return message
@staticmethod
async def _stream_chat(client: Any, kwargs: Dict[str, Any], on_delta: Callable[[str], None]) -> Any:
"""消费 OpenAI 兼容流式响应:逐 delta 回调 on_delta,重组为 message 对象返回。
返回 (message, usage):message 为 SimpleNamespace(content/tool_calls/
reasoning_content,duck-type 兼容 SDK Message),usage 来自开启
stream_options.include_usage 后的末 chunk。
"""
from types import SimpleNamespace
stream = await client.chat.completions.create(**kwargs)
content_parts: List[str] = []
reasoning_parts: List[str] = []
tool_slots: Dict[int, Dict[str, Any]] = {}
usage = None
async for chunk in stream:
if getattr(chunk, "usage", None):
usage = chunk.usage
if not chunk.choices:
continue
delta = chunk.choices[0].delta
piece = getattr(delta, "content", None)
if piece:
content_parts.append(piece)
try:
on_delta(piece)
except Exception:
pass
reasoning_piece = getattr(delta, "reasoning_content", None)
if reasoning_piece:
reasoning_parts.append(reasoning_piece)
for tc in (getattr(delta, "tool_calls", None) or []):
slot = tool_slots.setdefault(tc.index, {"id": None, "name": "", "arguments": ""})
if tc.id:
slot["id"] = tc.id
fn = getattr(tc, "function", None)
if fn is not None:
if getattr(fn, "name", None):
slot["name"] += fn.name
if getattr(fn, "arguments", None):
slot["arguments"] += fn.arguments
tool_calls = [
SimpleNamespace(
id=s["id"], type="function",
function=SimpleNamespace(name=s["name"], arguments=s["arguments"]),
)
for _, s in sorted(tool_slots.items())
] or None
message = SimpleNamespace(
content="".join(content_parts) if content_parts else None,
tool_calls=tool_calls,
reasoning_content="".join(reasoning_parts) if reasoning_parts else None,
)
return message, usage

View File

@@ -23,6 +23,8 @@ MODEL_PRICING: Dict[str, Tuple[float, float]] = {
"claude-3-haiku": (0.25, 1.25),
"claude-3.5-sonnet": (3.00, 15.00),
"claude-3.5-haiku": (1.00, 5.00),
"kimi-k3": (3.00, 15.00),
"kimi-k2": (0.56, 2.22),
}
# 缓存未命中模型的默认定价

View File

@@ -32,7 +32,9 @@
<!-- Meta line -->
<div class="msg-meta">
<span class="msg-time">{{ formatTime(timestamp) }}</span>
<el-tag v-if="status === 'stopped'" size="small" type="warning" effect="plain">已停止</el-tag>
<span v-if="iterations" class="msg-iterations">{{ iterations }} 步 · {{ toolCallsMade }} 次工具调用</span>
<span v-if="tokenUsageText" class="msg-token-usage">{{ tokenUsageText }}</span>
<span class="msg-actions">
<slot name="actions" />
</span>
@@ -54,6 +56,7 @@ const props = defineProps<{
attachments?: MessageAttachment[]
iterations?: number
toolCallsMade?: number
tokenUsage?: any
}>()
defineEmits<{
@@ -62,6 +65,14 @@ defineEmits<{
const attachmentsClass = computed(() => (props.role === 'user' ? 'is-user' : 'is-agent'))
const tokenUsageText = computed(() => {
const u = props.tokenUsage
if (!u || !u.total_tokens) return ''
let text = `${u.total_tokens} tokens`
if (u.cost_yuan !== undefined && u.cost_yuan !== null) text += ` · ¥${u.cost_yuan}`
return text
})
function formatTime(ts: number): string {
const d = new Date(ts)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
@@ -194,6 +205,7 @@ function formatTime(ts: number): string {
flex-wrap: wrap;
}
.msg-iterations { color: var(--el-color-info); }
.msg-token-usage { color: var(--el-text-color-placeholder); }
.msg-actions {
margin-left: auto;
display: flex;

View File

@@ -33,15 +33,34 @@
<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>
<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>
<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-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"
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="msg.orchestrateResult" #extra>
<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>
</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 */