295 lines
12 KiB
Vue
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>
|