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:
@@ -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:
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
# 缓存未命中模型的默认定价
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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