Files
oci-portal-dash/src/components/logs/AiCallLogPanel.vue
T
Wang Defa 5464d2dfea
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s
发布 0.1.0:通知渠道、告警规则与多项体验修复
2026-07-10 17:31:19 +08:00

295 lines
12 KiB
Vue

<script setup lang="ts">
import { NDataTable, NModal, NTooltip, type DataTableColumns } from 'naive-ui'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { listAiCallLogs, listAiContentLogs } from '@/api/aigateway'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { fmtTime } from '@/composables/useFormat'
import type { AiCallLog, AiContentLog } from '@/types/api'
import { useToast } from '@/composables/useToast'
const message = useToast()
const rows = ref<AiCallLog[]>([])
const loading = ref(false)
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
showSizePicker: true,
pageSizes: [20, 50, 100],
onUpdatePage: (p: number) => {
pagination.page = p
void load()
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
pagination.page = 1
void load()
},
})
async function load(silent = false) {
if (!silent) loading.value = true
try {
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
rows.value = data.items
pagination.itemCount = data.total
} catch {
// 自动刷新失败静默,手动路径由全局兜底
} finally {
if (!silent) loading.value = false
}
}
onMounted(() => void load())
useAutoRefresh(() => load(true))
function fmtLatency(ms: number) {
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`
}
function statusCell(row: AiCallLog) {
const ok = row.status >= 200 && row.status < 300
const badge = h(StatusBadge, { kind: ok ? 'run' : 'term', label: String(row.status) })
if (ok || !row.errMsg) return badge
return h(
NTooltip,
{ style: { maxWidth: '380px' } },
{
trigger: () => h('span', { class: 'inline-flex cursor-help' }, [badge]),
default: () => h('span', { class: 'text-xs leading-relaxed whitespace-pre-wrap break-all' }, row.errMsg),
},
)
}
function tokensCell(row: AiCallLog) {
if (row.errMsg && !row.totalTokens) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
const flow = row.stream ? ' · 流' : ''
return h('span', { class: 'mono text-[13px]' }, `${row.promptTokens} / ${row.completionTokens}${flow}`)
}
const columns: DataTableColumns<AiCallLog> = [
{ title: '时间', key: 'createdAt', width: 150, render: (r) => h('span', { class: 'text-[13px] whitespace-nowrap text-ink-2' }, fmtTime(r.createdAt)) },
{ title: '密钥', key: 'keyName', width: 140, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px]' }, r.keyName || '—') },
{ title: '请求 IP', key: 'clientIp', width: 130, render: (r) => (r.clientIp ? h('span', { class: 'mono text-[12.5px] text-ink-2' }, r.clientIp) : h('span', { class: 'text-[13px] text-ink-3' }, '—')) },
{ title: '模型', key: 'model', minWidth: 220, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'mono text-xs' }, r.model) },
{ title: '渠道', key: 'channelName', minWidth: 180, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px]' }, r.channelName || '—') },
{ title: '状态', key: 'status', width: 80, render: statusCell },
{ title: 'Tokens(入/出)', key: 'tokens', width: 150, render: tokensCell },
{ title: '耗时', key: 'latencyMs', width: 80, render: (r) => h('span', { class: 'mono text-[13px]' }, fmtLatency(r.latencyMs)) },
{ title: '重试', key: 'retries', width: 60, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.retries)) },
]
// ---- 行点击详情 ----
const EP_PATH: Record<string, string> = {
openai: '/ai/v1/chat/completions',
anthropic: '/ai/v1/messages',
responses: '/ai/v1/responses',
}
const detail = ref<AiCallLog | null>(null)
const showDetail = ref(false)
// ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ----
const contentLog = ref<AiContentLog | null>(null)
async function loadContent(callLogId: number) {
contentLog.value = null
try {
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
contentLog.value = items[0] ?? null
} catch {
// 正文加载失败不阻塞元数据详情
}
}
/** 展示层深截断:base64 data URI 只留协议头与少量前缀,多轮对话里的
* 长文本(历史回答/长 prompt)留前 500 字符;存储仍是完整快照,全文走「复制完整」 */
function truncateText(s: string): string {
const b64 = /^(data:[\w/+.-]+;base64,)/.exec(s)
if (b64) {
const keep = b64[1].length + 24
return s.length > keep ? `${s.slice(0, keep)}…(已截断,共 ${s.length} 字符)` : s
}
if (s.length > 600) return `${s.slice(0, 500)}…(已截断,共 ${s.length} 字符)`
return s
}
function truncateDeep(v: unknown): unknown {
if (typeof v === 'string') return truncateText(v)
if (Array.isArray(v)) return v.map(truncateDeep)
if (v && typeof v === 'object')
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, truncateDeep(x)]))
return v
}
/** 正文按 JSON 美化并深截断超长值;非 JSON(如 64KB 尾部截断)按文本截断展示 */
function fmtBody(s: string) {
try {
return JSON.stringify(truncateDeep(JSON.parse(s)), null, 2)
} catch {
return s.length > 2000 ? `${s.slice(0, 2000)}…(内容过长,完整正文请复制查看)` : s
}
}
async function copyBody(text: string) {
try {
await navigator.clipboard.writeText(text)
message.success('已复制完整正文')
} catch {
message.error('复制失败,请手动选择复制')
}
}
const rowProps = (row: AiCallLog) => ({
style: 'cursor: pointer',
onClick: () => {
detail.value = row
showDetail.value = true
void loadContent(row.id)
},
})
const detailOk = computed(() => !!detail.value && detail.value.status >= 200 && detail.value.status < 300)
const heroSub = computed(() => {
const d = detail.value
if (!d) return ''
const path = EP_PATH[d.endpoint] ?? d.endpoint
return `POST ${path} · ${d.stream ? '流式(SSE)' : '非流式'} · ${fmtLatency(d.latencyMs)}`
})
const heroChips = computed<HeroChip[]>(() => {
const d = detail.value
if (!d) return []
const chips: HeroChip[] = [{ label: '密钥', value: d.keyName || '—' }]
if (d.clientIp) chips.push({ label: '来源 IP', value: d.clientIp, mono: true })
chips.push({ label: '时间', value: fmtTime(d.createdAt) })
return chips
})
const detailRows = computed<DetailRow[]>(() => {
const d = detail.value
if (!d) return []
const list: DetailRow[] = []
if (d.errMsg) list.push({ label: '失败原因', value: d.errMsg, tone: 'err', span: 2 })
list.push({ label: '渠道', value: d.channelName || '—(未命中渠道)' })
list.push({ label: '重试次数', value: String(d.retries), mono: true })
return list
})
/** 失败且无用量时隐藏 Token 区(无意义的 0/0) */
const showTokens = computed(() => detailOk.value || (detail.value?.totalTokens ?? 0) > 0)
const footerText = computed(() => {
if (contentLog.value) return '该调用命中密钥内容日志窗口,正文为截断快照(上限 64KB,保留 7 天 / 1 万行)'
return detailOk.value ? '无计费信息,OCI 成本见总览成本卡' : '仅记录元数据与 token 用量,不含任何对话内容'
})
</script>
<template>
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3">
<div class="text-sm font-semibold">AI 调用日志</div>
<div class="mt-0.5 text-xs text-ink-3">仅元数据与 token 用量,不记录对话内容;保留 90 / 5 万行 · 5 秒自动刷新</div>
</div>
<NDataTable
remote
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
:bordered="false"
:row-props="rowProps"
size="small"
/>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '600px', maxWidth: 'calc(100vw - 24px)' }">
<template #header>
<DetailHero
v-if="detail"
:kind="`AI 调用 · ${detail.endpoint.toUpperCase()} 协议`"
:title="detail.model"
title-mono
:sub="heroSub"
:chips="heroChips"
>
<StatusBadge
:kind="detailOk ? 'run' : 'term'"
:label="`${detailOk ? '成功' : '失败'} ${detail.status}`"
:dot="false"
/>
</DetailHero>
</template>
<div v-if="detail" class="flex flex-col gap-3.5">
<DetailFieldList :rows="detailRows" :cols="2" />
<div v-if="showTokens" class="overflow-hidden rounded-[10px] border border-line-soft">
<div class="flex items-baseline gap-2 border-b border-line-soft bg-wash px-3.5 py-2">
<span class="text-xs font-semibold text-ink-2">Token 明细</span>
<span class="text-[11px] text-ink-3">仅用量计数 · 不含对话内容</span>
</div>
<div class="grid" :class="detail.cachedTokens > 0 ? 'grid-cols-4' : 'grid-cols-3'">
<div class="px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold">{{ detail.promptTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">输入 Token</div>
</div>
<div v-if="detail.cachedTokens > 0" class="border-l border-line-soft px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold text-ok">{{ detail.cachedTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">缓存读取</div>
</div>
<div class="border-l border-line-soft px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold">{{ detail.completionTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">输出 Token</div>
</div>
<div class="border-l border-line-soft px-3.5 py-3 text-center">
<div class="mono text-[21px] font-semibold">{{ detail.totalTokens }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">合计</div>
</div>
</div>
</div>
<template v-if="contentLog">
<div class="overflow-hidden rounded-[10px] border border-line-soft">
<div class="flex items-baseline gap-2 border-b border-line-soft bg-wash px-3.5 py-2">
<span class="text-xs font-semibold text-ink-2">请求正文</span>
<span class="text-[11px] text-ink-3">内容日志 · 超长值已截断展示</span>
<span class="flex-1"></span>
<button
type="button"
class="cursor-pointer text-[11px] font-medium text-accent hover:text-accent-hover"
@click="copyBody(contentLog.requestBody)"
>
复制完整
</button>
</div>
<pre class="mono max-h-56 overflow-auto px-3.5 py-2.5 text-[11.5px] leading-relaxed break-all whitespace-pre-wrap">{{ fmtBody(contentLog.requestBody) }}</pre>
</div>
<div v-if="contentLog.responseBody" class="overflow-hidden rounded-[10px] border border-line-soft">
<div class="flex items-baseline gap-2 border-b border-line-soft bg-wash px-3.5 py-2">
<span class="text-xs font-semibold text-ink-2">响应正文</span>
<span class="text-[11px] text-ink-3">内容日志 · 超长值已截断展示</span>
<span class="flex-1"></span>
<button
type="button"
class="cursor-pointer text-[11px] font-medium text-accent hover:text-accent-hover"
@click="copyBody(contentLog.responseBody)"
>
复制完整
</button>
</div>
<pre class="mono max-h-56 overflow-auto px-3.5 py-2.5 text-[11.5px] leading-relaxed break-all whitespace-pre-wrap">{{ fmtBody(contentLog.responseBody) }}</pre>
</div>
<div v-else class="text-[11px] text-ink-3">无响应正文:流式响应向量结果与失败调用只记请求</div>
</template>
<div class="text-xs leading-relaxed text-ink-3">
{{ footerText }}
</div>
</div>
</NModal>
</div>
</template>