|
|
|
@ -19,11 +19,15 @@ |
|
|
|
<t-select v-model="filterRoleId" :options="roleOptions" placeholder="全部角色" clearable size="small" style="width:150px;" @change="onFilterChange" /> |
|
|
|
<t-input v-model="filterConversationId" placeholder="会话 ID" clearable size="small" style="width:180px;" @enter="onSearch" /> |
|
|
|
<t-select v-model="filterIntent" :options="intentOptions" placeholder="全部意图" clearable size="small" style="width:120px;" @change="onFilterChange" /> |
|
|
|
<t-select v-model="filterApiKeyId" :options="apiKeyOptions" placeholder="全部 API Key" clearable size="small" style="width:160px;" @change="onFilterChange" /> |
|
|
|
<t-select v-model="filterErrorType" :options="errorTypeOptions" placeholder="全部错误类型" clearable size="small" style="width:140px;" @change="onFilterChange" /> |
|
|
|
<t-date-picker v-model="filterStart" placeholder="开始日期" clearable size="small" style="width:140px;" @change="onFilterChange" /> |
|
|
|
<t-date-picker v-model="filterEnd" placeholder="结束日期" clearable size="small" style="width:140px;" @change="onFilterChange" /> |
|
|
|
<t-input v-model="filterKeyword" placeholder="搜索用户消息 / AI 回复" clearable size="small" style="width:200px;" @enter="onSearch" /> |
|
|
|
<div style="flex:1;" /> |
|
|
|
<t-button size="small" variant="outline" @click="refreshAll">刷新</t-button> |
|
|
|
<t-button size="small" :disabled="selectedRowKeys.length !== 2" @click="openCompare">对比({{ selectedRowKeys.length }}/2)</t-button> |
|
|
|
<t-button size="small" variant="outline" @click="openTrend">趋势</t-button> |
|
|
|
<t-button size="small" variant="outline" @click="openClean">清理</t-button> |
|
|
|
</div> |
|
|
|
|
|
|
|
@ -42,6 +46,9 @@ |
|
|
|
<template #faqHit="{ row }"> |
|
|
|
<t-tag size="small" variant="light" :theme="row.faqHit ? 'success' : 'default'">{{ row.faqHit ? '命中' : '未命中' }}</t-tag> |
|
|
|
</template> |
|
|
|
<template #enableRag="{ row }"> |
|
|
|
<t-tag size="small" variant="light" :theme="row.enableRag ? 'primary' : 'default'">{{ row.enableRag ? '是' : '否' }}</t-tag> |
|
|
|
</template> |
|
|
|
<template #status="{ row }"> |
|
|
|
<t-tag size="small" variant="light" :theme="statusTheme(row.status)">{{ statusLabel(row.status) }}</t-tag> |
|
|
|
</template> |
|
|
|
@ -50,6 +57,7 @@ |
|
|
|
</template> |
|
|
|
<template #op="{ row }"> |
|
|
|
<t-button size="small" variant="text" @click="openDetail(row)">详情</t-button> |
|
|
|
<t-button size="small" variant="text" @click="openTimeline(row)">时间线</t-button> |
|
|
|
</template> |
|
|
|
</t-table> |
|
|
|
|
|
|
|
@ -60,8 +68,11 @@ |
|
|
|
<div class="detail-meta"> |
|
|
|
<t-tag size="small" variant="light" :theme="intentTheme(detail.intent)">{{ intentLabel(detail.intent) }}</t-tag> |
|
|
|
<t-tag size="small" variant="light" :theme="statusTheme(detail.status)">{{ statusLabel(detail.status) }}</t-tag> |
|
|
|
<t-tag v-if="detail.searchMode" size="small" variant="outline">{{ detail.searchMode }}</t-tag> |
|
|
|
<t-tag v-if="detail.errorType" size="small" variant="light" theme="danger">{{ detail.errorType }}</t-tag> |
|
|
|
<span class="muted">{{ detail.roleName || '(无角色)' }} · {{ detail.modelName || '未知模型' }} · {{ detail.provider }} · {{ detail.latencyMs }}ms</span> |
|
|
|
</div> |
|
|
|
<div v-if="detail.errorMessage" class="error-box">{{ detail.errorMessage }}</div> |
|
|
|
<t-tabs v-model="detailTab"> |
|
|
|
<t-tab-panel value="system" label="系统提示词"> |
|
|
|
<div class="prompt-segments"> |
|
|
|
@ -70,7 +81,7 @@ |
|
|
|
<div class="seg" v-if="detail.ragContext"><span class="seg-tag">RAG 资料</span><pre class="seg-text">{{ detail.ragContext }}</pre></div> |
|
|
|
</div> |
|
|
|
<div class="block-title">最终完整 system prompt</div> |
|
|
|
<pre class="full-prompt">{{ detail.systemPrompt || '(空)' }}</pre> |
|
|
|
<PromptSearchHighlight :text="detail.systemPrompt || '(空)'" /> |
|
|
|
<div class="detail-actions"> |
|
|
|
<t-button size="small" @click="copyPrompt">复制完整 Prompt</t-button> |
|
|
|
<t-button size="small" variant="outline" :disabled="!detail.roleId" @click="jumpEditRole">编辑角色提示词</t-button> |
|
|
|
@ -78,10 +89,41 @@ |
|
|
|
</div> |
|
|
|
</t-tab-panel> |
|
|
|
<t-tab-panel value="user" label="用户消息"><pre class="full-prompt">{{ detail.userMessage }}</pre></t-tab-panel> |
|
|
|
<t-tab-panel value="ai" label="AI 回复"><pre class="full-prompt">{{ detail.aiResponse }}<span v-if="detail.aiResponseTruncated" class="muted">(已截断)</span></pre></t-tab-panel> |
|
|
|
<t-tab-panel value="ai" label="AI 回复"> |
|
|
|
<div v-if="detail.totalTokens || detail.promptTokens || detail.completionTokens" class="muted" style="margin-bottom:8px;"> |
|
|
|
Token:prompt {{ detail.promptTokens ?? '-' }} / completion {{ detail.completionTokens ?? '-' }} / 合计 {{ detail.totalTokens ?? '-' }} |
|
|
|
</div> |
|
|
|
<pre class="full-prompt">{{ detail.aiResponse }}<span v-if="detail.aiResponseTruncated" class="muted">(已截断)</span></pre> |
|
|
|
</t-tab-panel> |
|
|
|
<t-tab-panel value="rag" label="RAG 资料"> |
|
|
|
<div class="muted" style="margin-bottom:8px;">检索模式:{{ detail.searchMode || '-' }} · 命中:{{ detail.hitCount ?? '-' }} 条</div> |
|
|
|
<t-table v-if="detailRagHits.length" :data="detailRagHits" :columns="ragHitsColumns" size="small" :pagination="false" style="margin-bottom:12px;" /> |
|
|
|
<div class="block-title">注入的 RAG 上下文</div> |
|
|
|
<pre class="full-prompt">{{ detail.ragContext || '(无 RAG 资料)' }}</pre> |
|
|
|
<div class="muted" style="margin-top:8px;">检索模式:{{ detail.searchMode || '-' }} · 命中:{{ detail.hitCount ?? '-' }} 条</div> |
|
|
|
</t-tab-panel> |
|
|
|
<t-tab-panel value="faq" label="FAQ"> |
|
|
|
<t-empty v-if="!detail.faqQuestion && !detail.faqId" description="本次调用未命中 FAQ" /> |
|
|
|
<div v-else class="faq-box"> |
|
|
|
<div class="faq-row"><span class="faq-label">FAQ ID</span><span>{{ detail.faqId ?? '-' }}</span></div> |
|
|
|
<div class="faq-row"><span class="faq-label">问题</span><span>{{ detail.faqQuestion ?? '-' }}</span></div> |
|
|
|
<div class="faq-row"><span class="faq-label">匹配方式</span><span>{{ detail.faqMatchType || '-' }}</span></div> |
|
|
|
<div class="faq-row"><span class="faq-label">相似度</span><span>{{ formatScore(detail.faqScore) }}</span></div> |
|
|
|
</div> |
|
|
|
</t-tab-panel> |
|
|
|
<t-tab-panel value="tools" label="工具调用"> |
|
|
|
<t-table v-if="detailToolCalls.length" :data="detailToolCalls" :columns="toolCallsColumns" size="small" :pagination="false"> |
|
|
|
<template #latencyMs="{ row }"><span>{{ row.latencyMs ?? '-' }}ms</span></template> |
|
|
|
<template #error="{ row }"> |
|
|
|
<t-tag v-if="row.error" size="small" variant="light" theme="danger">失败</t-tag> |
|
|
|
<span v-else class="muted">-</span> |
|
|
|
</template> |
|
|
|
</t-table> |
|
|
|
<t-empty v-else description="本次调用未触发 MCP 工具" /> |
|
|
|
</t-tab-panel> |
|
|
|
<t-tab-panel value="context" label="对话上下文"> |
|
|
|
<div v-if="detail.historyTurns" class="muted" style="margin-bottom:8px;">历史轮数:{{ detail.historyTurns }}</div> |
|
|
|
<t-table v-if="detailHistory.length" :data="detailHistory" :columns="historyColumns" size="small" :pagination="false" /> |
|
|
|
<t-empty v-else description="无多轮历史记录" /> |
|
|
|
</t-tab-panel> |
|
|
|
</t-tabs> |
|
|
|
</div> |
|
|
|
@ -94,6 +136,13 @@ |
|
|
|
<div v-if="compareA && compareB" class="compare-grid"> |
|
|
|
<div v-for="(c, idx) in [compareA, compareB]" :key="idx" class="compare-col"> |
|
|
|
<div class="compare-head">{{ c.roleName || '(无角色)' }} · {{ c.intent }} · {{ formatDate(c.createTime) }}</div> |
|
|
|
<div class="compare-meta"> |
|
|
|
<t-tag size="small" variant="light" :theme="c.enableRag ? 'primary' : 'default'">RAG {{ c.enableRag ? '开' : '关' }}</t-tag> |
|
|
|
<t-tag v-if="c.searchMode" size="small" variant="outline">{{ c.searchMode }}</t-tag> |
|
|
|
<t-tag v-if="c.errorType" size="small" variant="light" theme="danger">{{ c.errorType }}</t-tag> |
|
|
|
<span v-if="c.faqMatchType" class="muted">FAQ:{{ c.faqMatchType }}</span> |
|
|
|
<span v-if="c.totalTokens" class="muted">Token:{{ c.totalTokens }}</span> |
|
|
|
</div> |
|
|
|
<div class="block-title">系统提示词</div> |
|
|
|
<pre class="full-prompt small">{{ c.systemPrompt || '(空)' }}</pre> |
|
|
|
<div class="block-title">用户消息</div> |
|
|
|
@ -105,6 +154,44 @@ |
|
|
|
<t-empty v-else description="请先在列表中勾选 2 条记录" /> |
|
|
|
</t-drawer> |
|
|
|
|
|
|
|
<!-- 会话时间线抽屉 --> |
|
|
|
<t-drawer v-model:visible="timelineVisible" size="medium" header="会话时间线" :footer="false"> |
|
|
|
<t-loading :loading="timelineLoading" show-overlay> |
|
|
|
<div class="muted" style="margin-bottom:12px;">会话 ID:{{ timelineConversationId }}</div> |
|
|
|
<t-empty v-if="!timelineLoading && !timelineRows.length" description="该会话无调用记录" /> |
|
|
|
<t-timeline v-else> |
|
|
|
<t-timeline-item v-for="row in timelineRows" :key="row.id"> |
|
|
|
<div class="tl-head"> |
|
|
|
<t-tag size="small" variant="light" :theme="intentTheme(row.intent)">{{ intentLabel(row.intent) }}</t-tag> |
|
|
|
<t-tag size="small" variant="light" :theme="statusTheme(row.status)">{{ statusLabel(row.status) }}</t-tag> |
|
|
|
<span class="muted">{{ formatDate(row.createTime) }} · {{ row.latencyMs }}ms</span> |
|
|
|
</div> |
|
|
|
<div class="tl-user">{{ row.userMessage }}</div> |
|
|
|
<div class="tl-ai">{{ row.aiResponse }}</div> |
|
|
|
</t-timeline-item> |
|
|
|
</t-timeline> |
|
|
|
</t-loading> |
|
|
|
</t-drawer> |
|
|
|
|
|
|
|
<!-- 趋势图抽屉 --> |
|
|
|
<t-drawer v-model:visible="trendVisible" size="large" header="调用趋势" :footer="false" @opened="loadTrend" @closed="destroyTrendChart"> |
|
|
|
<div class="trend-toolbar"> |
|
|
|
<t-radio-group v-model="trendRange" variant="default-filled" size="small" @change="loadTrend"> |
|
|
|
<t-radio-button value="24h">近 24 小时</t-radio-button> |
|
|
|
<t-radio-button value="7d">近 7 天</t-radio-button> |
|
|
|
</t-radio-group> |
|
|
|
<t-radio-group v-model="trendGroupBy" variant="default-filled" size="small" @change="loadTrend"> |
|
|
|
<t-radio-button value="HOUR">按小时</t-radio-button> |
|
|
|
<t-radio-button value="DAY">按天</t-radio-button> |
|
|
|
</t-radio-group> |
|
|
|
<div style="flex:1;" /> |
|
|
|
<t-button size="small" variant="outline" :loading="trendLoading" @click="loadTrend">刷新</t-button> |
|
|
|
</div> |
|
|
|
<div class="trend-chart-wrap"> |
|
|
|
<canvas ref="trendChartRef"></canvas> |
|
|
|
</div> |
|
|
|
</t-drawer> |
|
|
|
|
|
|
|
<!-- 清理弹窗 --> |
|
|
|
<t-dialog v-model:visible="cleanVisible" header="清理调用记录" width="420px" :footer="false"> |
|
|
|
<t-form label-align="top"> |
|
|
|
@ -122,14 +209,17 @@ |
|
|
|
</template> |
|
|
|
|
|
|
|
<script setup lang="ts"> |
|
|
|
import { ref, computed, onMounted } from 'vue' |
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' |
|
|
|
import { useRouter } from 'vue-router' |
|
|
|
import { listLlmTraces, getLlmTrace, cleanLlmTraces, getLlmTraceStats } from '@/api/llm-trace' |
|
|
|
import { listLlmTraces, getLlmTrace, cleanLlmTraces, getLlmTraceStats, getLlmTraceErrorTypes, getLlmTraceConversation, getLlmTraceTrend } from '@/api/llm-trace' |
|
|
|
import { getAllRoles } from '@/api/role' |
|
|
|
import { listAllApiKeys } from '@/api/api-key' |
|
|
|
import PromptSearchHighlight from '@/components/PromptSearchHighlight.vue' |
|
|
|
import { toast } from '@/utils/toast' |
|
|
|
import { formatDate } from '@/utils/format' |
|
|
|
import { useDebounce } from '@/composables/useDebounce' |
|
|
|
import { useConfirm } from '@/composables/useConfirm' |
|
|
|
import { palette } from '@/utils/palette' |
|
|
|
|
|
|
|
const router = useRouter() |
|
|
|
const { confirm } = useConfirm() |
|
|
|
@ -157,7 +247,11 @@ const filterIntent = ref('') |
|
|
|
const filterStart = ref('') |
|
|
|
const filterEnd = ref('') |
|
|
|
const filterKeyword = ref('') |
|
|
|
const filterApiKeyId = ref('') |
|
|
|
const filterErrorType = ref('') |
|
|
|
const roleOptions = ref<{ label: string; value: string }[]>([]) |
|
|
|
const apiKeyOptions = ref<{ label: string; value: string }[]>([]) |
|
|
|
const errorTypeOptions = ref<{ label: string; value: string }[]>([]) |
|
|
|
|
|
|
|
// ===== 列表状态 ===== |
|
|
|
const traces = ref<any[]>([]) |
|
|
|
@ -172,12 +266,13 @@ const columns = [ |
|
|
|
{ colKey: 'createTime', title: '时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.createTime) }, |
|
|
|
{ colKey: 'roleName', title: '角色', width: 120 }, |
|
|
|
{ colKey: 'intent', title: '意图', width: 100 }, |
|
|
|
{ colKey: 'enableRag', title: 'RAG', width: 70 }, |
|
|
|
{ colKey: 'modelName', title: '模型', width: 140, ellipsis: true }, |
|
|
|
{ colKey: 'latencyMs', title: '耗时', width: 90 }, |
|
|
|
{ colKey: 'faqHit', title: 'FAQ', width: 80 }, |
|
|
|
{ colKey: 'status', title: '状态', width: 100 }, |
|
|
|
{ colKey: 'userMessage', title: '用户消息', ellipsis: true }, |
|
|
|
{ colKey: 'op', title: '操作', width: 80, fixed: 'right' }, |
|
|
|
{ colKey: 'op', title: '操作', width: 150, fixed: 'right' }, |
|
|
|
] |
|
|
|
|
|
|
|
// ===== 聚合统计状态 ===== |
|
|
|
@ -197,6 +292,38 @@ const detailLoading = ref(false) |
|
|
|
const detail = ref<any>(null) |
|
|
|
const detailTab = ref('system') |
|
|
|
|
|
|
|
const detailRagHits = computed(() => parseJsonArray(detail.value?.ragHitsJson)) |
|
|
|
const detailToolCalls = computed(() => parseJsonArray(detail.value?.toolCallsJson)) |
|
|
|
const detailHistory = computed(() => parseJsonArray(detail.value?.historyMessagesJson)) |
|
|
|
|
|
|
|
const ragHitsColumns = [ |
|
|
|
{ colKey: 'title', title: '标题', ellipsis: true }, |
|
|
|
{ colKey: 'documentId', title: '文档 ID', width: 150, ellipsis: true }, |
|
|
|
{ colKey: 'chunkIndex', title: '分块', width: 70 }, |
|
|
|
{ colKey: 'score', title: '相似度', width: 90, cell: (_: any, { row }: any) => formatScore(row.score) }, |
|
|
|
{ colKey: 'searchMode', title: '检索模式', width: 100 }, |
|
|
|
] |
|
|
|
|
|
|
|
const toolCallsColumns = [ |
|
|
|
{ colKey: 'tool', title: '工具', width: 150, ellipsis: true }, |
|
|
|
{ colKey: 'input', title: '输入', ellipsis: true }, |
|
|
|
{ colKey: 'result', title: '结果', ellipsis: true }, |
|
|
|
{ colKey: 'latencyMs', title: '耗时', width: 90 }, |
|
|
|
{ colKey: 'error', title: '错误', width: 70 }, |
|
|
|
] |
|
|
|
|
|
|
|
const historyColumns = [ |
|
|
|
{ colKey: 'role', title: '角色', width: 100 }, |
|
|
|
{ colKey: 'content', title: '内容', ellipsis: true }, |
|
|
|
] |
|
|
|
|
|
|
|
function formatScore(v: any): string { |
|
|
|
if (v === null || v === undefined || v === '') return '-' |
|
|
|
const n = Number(v) |
|
|
|
if (Number.isFinite(n)) return n.toFixed(4) |
|
|
|
return String(v) |
|
|
|
} |
|
|
|
|
|
|
|
// ===== 对比状态 ===== |
|
|
|
const compareVisible = ref(false) |
|
|
|
const compareA = ref<any>(null) |
|
|
|
@ -207,8 +334,25 @@ const cleanVisible = ref(false) |
|
|
|
const cleanKeepDays = ref(30) |
|
|
|
const cleaning = ref(false) |
|
|
|
|
|
|
|
// ===== 会话时间线状态 ===== |
|
|
|
const timelineVisible = ref(false) |
|
|
|
const timelineLoading = ref(false) |
|
|
|
const timelineConversationId = ref('') |
|
|
|
const timelineRows = ref<any[]>([]) |
|
|
|
|
|
|
|
// ===== 趋势图状态 ===== |
|
|
|
const trendVisible = ref(false) |
|
|
|
const trendGroupBy = ref<'HOUR' | 'DAY'>('HOUR') |
|
|
|
const trendRange = ref('24h') |
|
|
|
const trendLoading = ref(false) |
|
|
|
const trendRows = ref<any[]>([]) |
|
|
|
const trendChartRef = ref<HTMLCanvasElement | null>(null) |
|
|
|
let trendChart: any = null |
|
|
|
|
|
|
|
onMounted(async () => { |
|
|
|
await loadRoles() |
|
|
|
loadApiKeys() |
|
|
|
loadErrorTypes() |
|
|
|
loadList() |
|
|
|
loadStats() |
|
|
|
}) |
|
|
|
@ -222,6 +366,24 @@ async function loadRoles() { |
|
|
|
} catch (e: any) { console.warn('角色下拉加载失败:' + e.message) } |
|
|
|
} |
|
|
|
|
|
|
|
async function loadApiKeys() { |
|
|
|
try { |
|
|
|
const r = await listAllApiKeys() |
|
|
|
if (r.success) { |
|
|
|
apiKeyOptions.value = (r.data || []).map((k: any) => ({ label: k.name || ('API Key ' + k.id), value: String(k.id) })) |
|
|
|
} |
|
|
|
} catch (e: any) { console.warn('API Key 下拉加载失败:' + e.message) } |
|
|
|
} |
|
|
|
|
|
|
|
async function loadErrorTypes() { |
|
|
|
try { |
|
|
|
const r = await getLlmTraceErrorTypes() |
|
|
|
if (r.success) { |
|
|
|
errorTypeOptions.value = (r.data || []).map((t: any) => ({ label: t.label, value: t.value })) |
|
|
|
} |
|
|
|
} catch (e: any) { console.warn('错误类型下拉加载失败:' + e.message) } |
|
|
|
} |
|
|
|
|
|
|
|
// ===== 聚合统计 ===== |
|
|
|
const statSummary = computed(() => { |
|
|
|
let count = 0, latencySum = 0, faqHits = 0 |
|
|
|
@ -270,6 +432,8 @@ async function loadList() { |
|
|
|
startTime: filterStart.value ? filterStart.value + ' 00:00:00' : undefined, |
|
|
|
endTime: filterEnd.value ? filterEnd.value + ' 23:59:59' : undefined, |
|
|
|
keyword: filterKeyword.value || undefined, |
|
|
|
apiKeyId: filterApiKeyId.value || undefined, |
|
|
|
errorType: filterErrorType.value || undefined, |
|
|
|
}) |
|
|
|
if (r.success) { |
|
|
|
traces.value = r.data?.records || r.data || [] |
|
|
|
@ -297,6 +461,11 @@ function onFilterChange() { |
|
|
|
loadList() |
|
|
|
} |
|
|
|
|
|
|
|
function refreshAll() { |
|
|
|
loadList() |
|
|
|
loadStats() |
|
|
|
} |
|
|
|
|
|
|
|
const onSearch = debounce(() => { page.value = 1; selectedRowKeys.value = []; loadList() }) |
|
|
|
|
|
|
|
function onSelectChange(keys: (string | number)[], options?: { currentRowKey?: string | number }) { |
|
|
|
@ -394,6 +563,112 @@ async function doClean() { |
|
|
|
cleaning.value = false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ===== 会话时间线 ===== |
|
|
|
async function openTimeline(row: any) { |
|
|
|
if (!row.conversationId) { toast('该记录无会话 ID', 'warning'); return } |
|
|
|
timelineVisible.value = true |
|
|
|
timelineConversationId.value = row.conversationId |
|
|
|
timelineLoading.value = true |
|
|
|
timelineRows.value = [] |
|
|
|
try { |
|
|
|
const r = await getLlmTraceConversation(row.conversationId) |
|
|
|
if (r.success) { |
|
|
|
timelineRows.value = r.data || [] |
|
|
|
} else { |
|
|
|
toast(r.message || '加载会话时间线失败', 'error') |
|
|
|
} |
|
|
|
} catch (e: any) { |
|
|
|
toast('加载会话时间线失败:' + e.message, 'error') |
|
|
|
} finally { |
|
|
|
timelineLoading.value = false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ===== 趋势图 ===== |
|
|
|
function openTrend() { |
|
|
|
trendVisible.value = true |
|
|
|
} |
|
|
|
|
|
|
|
function trendTimeRange(): { startTime?: string; endTime?: string } { |
|
|
|
const end = new Date() |
|
|
|
if (trendRange.value === '24h') { |
|
|
|
const start = new Date(end.getTime() - 24 * 3600 * 1000) |
|
|
|
return { startTime: fmtDateTime(start), endTime: fmtDateTime(end) } |
|
|
|
} |
|
|
|
if (trendRange.value === '7d') { |
|
|
|
const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000) |
|
|
|
return { startTime: fmtDateTime(start), endTime: fmtDateTime(end) } |
|
|
|
} |
|
|
|
return {} |
|
|
|
} |
|
|
|
|
|
|
|
function fmtDateTime(d: Date): string { |
|
|
|
const p = (n: number) => String(n).padStart(2, '0') |
|
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` |
|
|
|
} |
|
|
|
|
|
|
|
async function loadTrend() { |
|
|
|
trendLoading.value = true |
|
|
|
try { |
|
|
|
const range = trendTimeRange() |
|
|
|
const r = await getLlmTraceTrend({ groupBy: trendGroupBy.value, ...range }) |
|
|
|
if (r.success) { |
|
|
|
trendRows.value = r.data || [] |
|
|
|
await nextTick() |
|
|
|
renderTrendChart() |
|
|
|
} else { |
|
|
|
toast(r.message || '加载趋势失败', 'error') |
|
|
|
} |
|
|
|
} catch (e: any) { |
|
|
|
toast('加载趋势失败:' + e.message, 'error') |
|
|
|
} finally { |
|
|
|
trendLoading.value = false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function renderTrendChart() { |
|
|
|
const { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler } = await import('chart.js') |
|
|
|
Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler) |
|
|
|
const labels = trendRows.value.map((s: any) => s.timeBucket || '') |
|
|
|
if (trendChart) { trendChart.destroy(); trendChart = null } |
|
|
|
if (!trendChartRef.value) return |
|
|
|
trendChart = new Chart(trendChartRef.value, { |
|
|
|
type: 'line', |
|
|
|
data: { labels, datasets: [ |
|
|
|
{ label: '调用量', data: trendRows.value.map((s: any) => s.callCount ?? 0), borderColor: palette.blue, backgroundColor: `rgba(${palette.blueRgb},0.1)`, fill: true, tension: 0.3, pointRadius: 3, yAxisID: 'y' }, |
|
|
|
{ label: '平均耗时(ms)', data: trendRows.value.map((s: any) => s.avgLatencyMs ?? 0), borderColor: palette.purple, backgroundColor: `rgba(${palette.purpleRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' }, |
|
|
|
{ label: '错误率(%)', data: trendRows.value.map((s: any) => s.errorRate ?? 0), borderColor: palette.red, backgroundColor: `rgba(${palette.redRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' }, |
|
|
|
{ label: 'FAQ 命中率(%)', data: trendRows.value.map((s: any) => s.faqHitRate ?? 0), borderColor: palette.green, backgroundColor: `rgba(${palette.greenRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' }, |
|
|
|
{ label: '总 Token', data: trendRows.value.map((s: any) => s.totalTokens ?? 0), borderColor: palette.orange, backgroundColor: `rgba(${palette.orangeRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' }, |
|
|
|
] }, |
|
|
|
options: { |
|
|
|
responsive: true, maintainAspectRatio: false, |
|
|
|
interaction: { mode: 'index', intersect: false }, |
|
|
|
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 12 } } } }, |
|
|
|
scales: { |
|
|
|
y: { type: 'linear', display: true, position: 'left', beginAtZero: true, title: { display: true, text: '调用量' } }, |
|
|
|
y1: { type: 'linear', display: true, position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: '耗时/率/Token' } }, |
|
|
|
x: { grid: { display: false } }, |
|
|
|
}, |
|
|
|
}, |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
function destroyTrendChart() { if (trendChart) { trendChart.destroy(); trendChart = null } } |
|
|
|
|
|
|
|
onBeforeUnmount(() => destroyTrendChart()) |
|
|
|
|
|
|
|
// ===== JSON 解析辅助 ===== |
|
|
|
function parseJsonArray(text: string | null | undefined): any[] { |
|
|
|
if (!text) return [] |
|
|
|
try { |
|
|
|
const arr = JSON.parse(text) |
|
|
|
return Array.isArray(arr) ? arr : [] |
|
|
|
} catch { |
|
|
|
return [] |
|
|
|
} |
|
|
|
} |
|
|
|
</script> |
|
|
|
|
|
|
|
<style scoped> |
|
|
|
@ -417,4 +692,14 @@ async function doClean() { |
|
|
|
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } |
|
|
|
.compare-col { min-width: 0; } |
|
|
|
.compare-head { font-weight: 600; margin-bottom: 8px; } |
|
|
|
.compare-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } |
|
|
|
.error-box { background: var(--td-error-color-1, #fdecec); color: var(--td-error-color, #d54941); padding: 8px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; word-break: break-word; white-space: pre-wrap; } |
|
|
|
.faq-box { border: 1px solid var(--color-border, #e7e7e7); border-radius: 6px; padding: 12px; display: flex; flex-direction: column; gap: 10px; } |
|
|
|
.faq-row { display: flex; gap: 16px; font-size: 13px; } |
|
|
|
.faq-label { flex-shrink: 0; width: 80px; color: var(--color-text-secondary); } |
|
|
|
.tl-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } |
|
|
|
.tl-user { font-size: 13px; color: var(--color-text-secondary); margin-bottom: 4px; white-space: pre-wrap; word-break: break-word; } |
|
|
|
.tl-ai { font-size: 13px; background: var(--color-bg-secondary, #f7f7f7); padding: 8px 12px; border-radius: 6px; white-space: pre-wrap; word-break: break-word; } |
|
|
|
.trend-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } |
|
|
|
.trend-chart-wrap { position: relative; height: 420px; } |
|
|
|
</style> |