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 json
import logging import logging
import time import time
import asyncio
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Protocol, TypedDict from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Protocol, TypedDict
from app.agent_runtime.schemas import ( from app.agent_runtime.schemas import (
@@ -271,13 +272,23 @@ class AgentRuntime:
def _real_token_usage_payload(self) -> Dict[str, Any]: def _real_token_usage_payload(self) -> Dict[str, Any]:
"""构造随终态事件下发的真实用量 dict(形状含 total_tokens,供下游/前端与计费消费)。""" """构造随终态事件下发的真实用量 dict(形状含 total_tokens,供下游/前端与计费消费)。"""
return { payload = {
"prompt_tokens": self._real_usage["prompt_tokens"], "prompt_tokens": self._real_usage["prompt_tokens"],
"completion_tokens": self._real_usage["completion_tokens"], "completion_tokens": self._real_usage["completion_tokens"],
"total_tokens": self._real_usage["total_tokens"], "total_tokens": self._real_usage["total_tokens"],
"llm_calls": self._real_usage["llm_calls"], "llm_calls": self._real_usage["llm_calls"],
"model": self.config.llm.model, "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: def _attach_token_usage(self, result: AgentResult) -> AgentResult:
"""将 token 用量摘要附加到 AgentResult。预算启用则用其摘要;否则用真实累计用量。""" """将 token 用量摘要附加到 AgentResult。预算启用则用其摘要;否则用真实累计用量。"""
@@ -978,15 +989,36 @@ class AgentRuntime:
# think 事件:告知前端 Agent 正在思考(让 UI 即时反馈,避免假死感) # think 事件:告知前端 Agent 正在思考(让 UI 即时反馈,避免假死感)
yield {"type": "think", "content": "", "reasoning": None, "iteration": self.context.iteration} yield {"type": "think", "content": "", "reasoning": None, "iteration": self.context.iteration}
# 调用 LLM # 调用 LLM(token 级流式:delta 进队列,边下边 yield answer_chunk)
try: 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, messages=messages,
tools=tool_schemas if has_tools and self.context.iteration == 1 else tools=tool_schemas if has_tools and self.context.iteration == 1 else
(tool_schemas if has_tools else None), (tool_schemas if has_tools else None),
iteration=self.context.iteration, iteration=self.context.iteration,
on_completion=_llm_callback, 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: except Exception as e:
err_str = str(e) err_str = str(e)
logger.error("LLM 调用失败 (iteration=%s): %s", self.context.iteration, err_str) logger.error("LLM 调用失败 (iteration=%s): %s", self.context.iteration, err_str)
@@ -1803,8 +1835,10 @@ class _LLMClient:
tools: Optional[List[Dict[str, Any]]] = None, tools: Optional[List[Dict[str, Any]]] = None,
iteration: int = 1, iteration: int = 1,
on_completion: Optional[Callable[[Dict[str, Any]], Any]] = None, on_completion: Optional[Callable[[Dict[str, Any]], Any]] = None,
on_delta: Optional[Callable[[str], None]] = None,
) -> Any: ) -> Any:
"""调用 LLM,主模型失败时自动切换 fallback_llm 重试。""" """调用 LLM,主模型失败时自动切换 fallback_llm 重试。
on_delta 提供时启用 token 级流式(仅内容增量,工具调用仍会重组后返回)。"""
from openai import AsyncOpenAI from openai import AsyncOpenAI
from app.core.config import settings from app.core.config import settings
@@ -1822,7 +1856,7 @@ class _LLMClient:
return await self._do_chat( return await self._do_chat(
api_key=api_key, base_url=base_url, model=self._config.model, api_key=api_key, base_url=base_url, model=self._config.model,
messages=messages, tools=tools, iteration=iteration, messages=messages, tools=tools, iteration=iteration,
on_completion=on_completion, on_completion=on_completion, on_delta=on_delta,
) )
async def _do_chat( async def _do_chat(
@@ -1835,6 +1869,7 @@ class _LLMClient:
iteration: int = 1, iteration: int = 1,
on_completion: Optional[Callable[[Dict[str, Any]], Any]] = None, on_completion: Optional[Callable[[Dict[str, Any]], Any]] = None,
_is_fallback: bool = False, _is_fallback: bool = False,
on_delta: Optional[Callable[[str], None]] = None,
) -> Any: ) -> Any:
from openai import AsyncOpenAI from openai import AsyncOpenAI
from app.core.config import settings from app.core.config import settings
@@ -1883,10 +1918,22 @@ class _LLMClient:
tool_calls = None tool_calls = None
return _CachedMsg() return _CachedMsg()
# token 级流式:要求增量回调时走 stream=True,末 chunk 带 usage
if on_delta:
kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True}
start_time = time.perf_counter() start_time = time.perf_counter()
last_error = None last_error = None
message = None
usage = None
try: try:
if on_delta:
message, usage = await self._stream_chat(client, kwargs, on_delta)
else:
response = await client.chat.completions.create(**kwargs) response = await client.chat.completions.create(**kwargs)
message = response.choices[0].message
usage = getattr(response, "usage", None)
except Exception as e: except Exception as e:
last_error = e last_error = e
@@ -1914,6 +1961,7 @@ class _LLMClient:
iteration=iteration, iteration=iteration,
on_completion=on_completion, on_completion=on_completion,
_is_fallback=_is_fallback, _is_fallback=_is_fallback,
on_delta=on_delta,
) )
except Exception as ce: except Exception as ce:
logger.error("ReactiveCompact 失败: %s", ce) logger.error("ReactiveCompact 失败: %s", ce)
@@ -1953,12 +2001,11 @@ class _LLMClient:
model=fb_model, model=fb_model,
messages=messages, tools=tools, messages=messages, tools=tools,
iteration=iteration, on_completion=on_completion, iteration=iteration, on_completion=on_completion,
_is_fallback=True, _is_fallback=True, on_delta=on_delta,
) )
raise raise
latency_ms = int((time.perf_counter() - start_time) * 1000) 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: 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) await _llm_cache_set(ck, message.content, self._config.cache_ttl_ms)
# 提取 token 用量 # 提取 token 用量
usage = getattr(response, "usage", None)
prompt_tokens = usage.prompt_tokens if usage else 0 prompt_tokens = usage.prompt_tokens if usage else 0
completion_tokens = usage.completion_tokens if usage else 0 completion_tokens = usage.completion_tokens if usage else 0
total_tokens = usage.total_tokens if usage else 0 total_tokens = usage.total_tokens if usage else 0
@@ -1985,3 +2031,61 @@ class _LLMClient:
}) })
return message 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-haiku": (0.25, 1.25),
"claude-3.5-sonnet": (3.00, 15.00), "claude-3.5-sonnet": (3.00, 15.00),
"claude-3.5-haiku": (1.00, 5.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 --> <!-- Meta line -->
<div class="msg-meta"> <div class="msg-meta">
<span class="msg-time">{{ formatTime(timestamp) }}</span> <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="iterations" class="msg-iterations">{{ iterations }} 步 · {{ toolCallsMade }} 次工具调用</span>
<span v-if="tokenUsageText" class="msg-token-usage">{{ tokenUsageText }}</span>
<span class="msg-actions"> <span class="msg-actions">
<slot name="actions" /> <slot name="actions" />
</span> </span>
@@ -54,6 +56,7 @@ const props = defineProps<{
attachments?: MessageAttachment[] attachments?: MessageAttachment[]
iterations?: number iterations?: number
toolCallsMade?: number toolCallsMade?: number
tokenUsage?: any
}>() }>()
defineEmits<{ defineEmits<{
@@ -62,6 +65,14 @@ defineEmits<{
const attachmentsClass = computed(() => (props.role === 'user' ? 'is-user' : 'is-agent')) 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 { function formatTime(ts: number): string {
const d = new Date(ts) const d = new Date(ts)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
@@ -194,6 +205,7 @@ function formatTime(ts: number): string {
flex-wrap: wrap; flex-wrap: wrap;
} }
.msg-iterations { color: var(--el-color-info); } .msg-iterations { color: var(--el-color-info); }
.msg-token-usage { color: var(--el-text-color-placeholder); }
.msg-actions { .msg-actions {
margin-left: auto; margin-left: auto;
display: flex; display: flex;

View File

@@ -33,15 +33,34 @@
<span style="color: var(--el-color-primary); font-weight: 500">+ 新对话</span> <span style="color: var(--el-color-primary); font-weight: 500">+ 新对话</span>
</el-option> </el-option>
<el-option <el-option
v-for="s in sessions" v-for="s in sortedSessions"
:key="s.session_id" :key="s.session_id"
:label="s.title || '未命名对话'" :label="s.title || '未命名对话'"
:value="s.session_id" :value="s.session_id"
> >
<div style="display:flex;flex-direction:column;width:100%"> <div class="session-option-row">
<span class="session-option-title">{{ s.title || '未命名对话' }}</span> <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> <span class="session-option-meta">{{ s.message_count }} 条消息 · {{ relativeTimeText(s.updated_at) }}</span>
</div> </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-option>
</el-select> </el-select>
@@ -170,24 +189,26 @@
</template> </template>
</div> </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 <ChatMessageBubble
v-for="(msg, i) in displayMessages" v-else
:key="i" :role="item.msg.role"
:role="msg.role" :content-html="renderMarkdown(item.msg.orchestrateResult?.final_answer || item.msg.content)"
:content-html="renderMarkdown(msg.orchestrateResult?.final_answer || msg.content)" :timestamp="item.msg.timestamp"
:timestamp="msg.timestamp" :status="item.msg.status"
:status="msg.status" :iterations="item.msg.iterations"
:iterations="msg.iterations" :tool-calls-made="item.msg.tool_calls_made"
:tool-calls-made="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="orchestrate-result">
<div class="orch-header"> <div class="orch-header">
<el-tag size="small" type="info">{{ msg.orchestrateResult.mode }}</el-tag> <el-tag size="small" type="info">{{ item.msg.orchestrateResult.mode }}</el-tag>
<span class="orch-agent-count">{{ msg.orchestrateResult.steps.length }} 个 Agent</span> <span class="orch-agent-count">{{ item.msg.orchestrateResult.steps.length }} 个 Agent</span>
</div> </div>
<div class="orch-steps"> <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"> <div class="orch-step-header" @click="step._open = !step._open">
<el-icon><CaretRight :style="{ transform: step._open ? 'rotate(90deg)' : '' }" /></el-icon> <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> <el-tag size="small" :type="step.error ? 'danger' : 'success'" round>{{ step.agent_name }}</el-tag>
@@ -201,21 +222,21 @@
</div> </div>
</template> </template>
<template v-if="!msg.orchestrateResult" #extra> <template v-if="!item.msg.orchestrateResult" #extra>
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls"> <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> 工具调用 ({{ msg.tool_calls.length }})</div> <div class="tool-calls-header"><el-icon><Tools /></el-icon> 工具调用 ({{ item.msg.tool_calls.length }})</div>
<div v-for="(tc, j) in msg.tool_calls" :key="j" class="tool-call-item"> <div v-for="(tc, j) in item.msg.tool_calls" :key="j" class="tool-call-item">
<span class="tool-name">{{ tc.function?.name || '?' }}</span> <span class="tool-name">{{ tc.function?.name || '?' }}</span>
<el-tag size="small" type="info">{{ safeParseArgCount(tc.function?.arguments) }} 个参数</el-tag> <el-tag size="small" type="info">{{ safeParseArgCount(tc.function?.arguments) }} 个参数</el-tag>
</div> </div>
</div> </div>
<div v-if="msg.steps && msg.steps.length > 0" class="thinking-trace"> <div v-if="item.msg.steps && item.msg.steps.length > 0" class="thinking-trace">
<div class="trace-header" @click="toggleTrace(msg)"> <div class="trace-header" @click="toggleTrace(item.msg)">
<el-icon><CaretRight :style="{ transform: msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon> <el-icon><CaretRight :style="{ transform: item.msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
<span>思考链 ({{ msg.steps.length }} 步)</span> <span>思考链 ({{ item.msg.steps.length }} 步)</span>
</div> </div>
<div v-show="msg._traceOpen" class="trace-steps"> <div v-show="item.msg._traceOpen" class="trace-steps">
<div v-for="(step, si) in msg.steps" :key="si" class="trace-step" :class="'step-' + step.type"> <div v-for="(step, si) in item.msg.steps" :key="si" class="trace-step" :class="'step-' + step.type">
<div class="step-icon"> <div class="step-icon">
<el-icon v-if="step.type === 'think'"><ChatDotSquare /></el-icon> <el-icon v-if="step.type === 'think'"><ChatDotSquare /></el-icon>
<el-icon v-else-if="step.type === 'tool_result'"><Tools /></el-icon> <el-icon v-else-if="step.type === 'tool_result'"><Tools /></el-icon>
@@ -247,16 +268,17 @@
</template> </template>
<template #actions> <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-icon><Headset v-if="!isSpeaking" /><VideoPause v-else /></el-icon>
</el-button> </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="item.msg.role === 'user'" link size="small" @click="editMessage(item.idx)" 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 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(i)" title="从此分支"><el-icon><Share /></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="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 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(i)" title="删除"><el-icon style="color:var(--el-color-danger)"><Delete /></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> </template>
</ChatMessageBubble> </ChatMessageBubble>
</template>
<div v-if="loading && !streamingActive" class="message assistant"> <div v-if="loading && !streamingActive" class="message assistant">
<div class="message-avatar"><el-avatar :size="36" icon="Promotion" /></div> <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 { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' 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 MainLayout from '@/components/MainLayout.vue'
import ChatMessageBubble from '@/components/ChatMessageBubble.vue' import ChatMessageBubble from '@/components/ChatMessageBubble.vue'
import ChatInputArea from '@/components/ChatInputArea.vue' import ChatInputArea from '@/components/ChatInputArea.vue'
@@ -481,6 +503,7 @@ interface OrchestrateResult {
interface ChatMessage { interface ChatMessage {
role: 'user' | 'assistant'; content: string; tool_calls?: any[]; timestamp: number role: 'user' | 'assistant'; content: string; tool_calls?: any[]; timestamp: number
iterations?: number; tool_calls_made?: number; status?: string; steps?: AgentStep[] iterations?: number; tool_calls_made?: number; status?: string; steps?: AgentStep[]
token_usage?: any
_traceOpen?: boolean; orchestrateResult?: OrchestrateResult _traceOpen?: boolean; orchestrateResult?: OrchestrateResult
} }
interface OrchestrateAgentForm { interface OrchestrateAgentForm {
@@ -612,6 +635,33 @@ const currentAgentKey = computed(() => {
}) })
const displayMessages = computed(() => messages.value[currentAgentKey.value] || []) 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 chatMode = ref<'single' | 'orchestrate'>('single')
const orchestrateMode = ref('debate') const orchestrateMode = ref('debate')
@@ -891,6 +941,38 @@ function newSession() {
nextTick(scrollToBottom) 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 更新后自动失效)── // ── 预设问题:LLM 按 Agent 描述生成,localStorage 缓存(Agent 更新后自动失效)──
const presetQuestions = ref<string[]>([]) const presetQuestions = ref<string[]>([])
const presetLoading = ref(false) const presetLoading = ref(false)
@@ -1044,7 +1126,11 @@ async function sendMessage() {
const data = JSON.parse(dataStr) const data = JSON.parse(dataStr)
if (!receivedFirstEvent) { receivedFirstEvent = true; streamingActive.value = true } if (!receivedFirstEvent) { receivedFirstEvent = true; streamingActive.value = true }
if (eventType === 'think') { 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] }) 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') { } 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 }) 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') { } else if (eventType === 'tool_result') {
@@ -1055,6 +1141,7 @@ async function sendMessage() {
} else if (eventType === 'final') { } else if (eventType === 'final') {
clearTimeout(streamTimeout) clearTimeout(streamTimeout)
currentMsg.content = data.content || ''; currentMsg.iterations = data.iterations_used || 0; currentMsg.tool_calls_made = data.tool_calls_made || 0 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() } } 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 streamingActive.value = false; loading.value = false
} else if (eventType === 'error') { } else if (eventType === 'error') {
@@ -1066,7 +1153,19 @@ async function sendMessage() {
} }
clearTimeout(streamTimeout) 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 (!usedStreaming) {
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) messages.value[key].splice(placeholderIdx, 1) 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 selector */
.session-option-title { font-size: 13px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; } .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); } .session-option-meta { font-size: 11px; color: var(--el-text-color-placeholder); }
/* Orchestrate */ /* Orchestrate */