feat: show conversation content in usage logs
This commit is contained in:
@@ -392,6 +392,9 @@ function normalizeProxyLogRow(row: any): any {
|
||||
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
||||
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
|
||||
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
|
||||
contentSummary: row.contentSummary ?? row.content_summary ?? null,
|
||||
requestContent: row.requestContent ?? row.request_content ?? null,
|
||||
responseContent: row.responseContent ?? row.response_content ?? null,
|
||||
createdAt: row.createdAt ?? row.created_at ?? null,
|
||||
};
|
||||
}
|
||||
@@ -784,6 +787,9 @@ export type ProxyLogListItem = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
estimatedCost?: number | null;
|
||||
contentSummary?: string | null;
|
||||
requestContent?: string | null;
|
||||
responseContent?: string | null;
|
||||
};
|
||||
|
||||
export type ProxyLogDetail = ProxyLogListItem & {
|
||||
|
||||
@@ -276,6 +276,88 @@ function formatLatency(ms?: number | null) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
function renderContentSummary(log: ProxyLogRenderItem) {
|
||||
const summary = log.contentSummary?.trim();
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
maxWidth: 360,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
}}
|
||||
title={summary}
|
||||
>
|
||||
内容:{summary}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContentDetailBlock(log: ProxyLogRenderItem) {
|
||||
const sections = [
|
||||
{ label: "请求内容", value: log.requestContent?.trim() },
|
||||
{ label: "响应内容", value: log.responseContent?.trim() },
|
||||
].filter(
|
||||
(section): section is { label: string; value: string } =>
|
||||
Boolean(section.value),
|
||||
);
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 10, marginTop: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
对话内容
|
||||
</div>
|
||||
{sections.map((section) => (
|
||||
<div key={section.label} style={{ display: "grid", gap: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link"
|
||||
style={{ fontSize: 11, padding: 0 }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void copyTextToClipboard(section.value);
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<pre style={debugCodeBlockStyle}>{section.value}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function latencyColor(ms?: number | null) {
|
||||
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
||||
return "var(--color-text-muted)";
|
||||
@@ -2625,11 +2707,12 @@ export default function ProxyLogs() {
|
||||
const isExpanded = expanded === log.id;
|
||||
const clientDisplay = resolveProxyLogClientDisplay(detailLog);
|
||||
const streamModeLabel = formatStreamModeLabel(detailLog.isStream);
|
||||
const firstByteLabel = formatFirstByteLabel(
|
||||
detailLog.firstByteLatencyMs,
|
||||
);
|
||||
const firstByteLabel = formatFirstByteLabel(
|
||||
detailLog.firstByteLatencyMs,
|
||||
);
|
||||
const contentDetailBlock = renderContentDetailBlock(detailLog);
|
||||
|
||||
return (
|
||||
return (
|
||||
<MobileCard
|
||||
key={log.id}
|
||||
title={detailLog.modelRequested || "unknown"}
|
||||
@@ -2731,6 +2814,7 @@ export default function ProxyLogs() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderContentSummary(detailLog)}
|
||||
{isExpanded ? (
|
||||
<div className="mobile-card-extra">
|
||||
<MobileField
|
||||
@@ -2811,6 +2895,7 @@ export default function ProxyLogs() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{contentDetailBlock}
|
||||
{detail && pathMeta.errorMessage.trim().length > 0 && (
|
||||
<div style={{ color: "var(--color-danger)" }}>
|
||||
{pathMeta.errorMessage}
|
||||
@@ -2863,6 +2948,7 @@ export default function ProxyLogs() {
|
||||
const firstByteLabel = formatFirstByteLabel(
|
||||
detailLog.firstByteLatencyMs,
|
||||
);
|
||||
const contentDetailBlock = renderContentDetailBlock(detailLog);
|
||||
|
||||
return (
|
||||
<React.Fragment key={log.id}>
|
||||
@@ -2968,6 +3054,7 @@ export default function ProxyLogs() {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{renderContentSummary(detailLog)}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
@@ -3426,6 +3513,8 @@ export default function ProxyLogs() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contentDetailBlock}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { userApi } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
|
||||
interface LogItem {
|
||||
id: number;
|
||||
id: number | string;
|
||||
model_requested: string | null;
|
||||
model_actual: string | null;
|
||||
status: string | null;
|
||||
@@ -16,6 +16,9 @@ interface LogItem {
|
||||
estimated_cost: number | null;
|
||||
error_message: string | null;
|
||||
is_stream: boolean | null;
|
||||
content_summary?: string | null;
|
||||
request_content?: string | null;
|
||||
response_content?: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
@@ -60,6 +63,63 @@ function latencyTextColor(log: LogItem): string {
|
||||
return latencyColor(log.latency_ms);
|
||||
}
|
||||
|
||||
function renderContentPreview(log: LogItem) {
|
||||
const summary = log.content_summary?.trim();
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
maxWidth: 420,
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
title={summary}
|
||||
>
|
||||
内容:{summary}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContentDetail(log: LogItem) {
|
||||
const sections = [
|
||||
{ label: '请求内容', value: log.request_content?.trim() },
|
||||
{ label: '响应内容', value: log.response_content?.trim() },
|
||||
].filter((section): section is { label: string; value: string } => Boolean(section.value));
|
||||
if (sections.length === 0) return null;
|
||||
return (
|
||||
<div style={{ gridColumn: '1 / -1', display: 'grid', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600 }}>对话内容</div>
|
||||
{sections.map((section) => (
|
||||
<div key={section.label} style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{section.label}</div>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: 10,
|
||||
maxHeight: 220,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}>{section.value}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsageRecords() {
|
||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
@@ -70,7 +130,7 @@ export default function UsageRecords() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -162,7 +222,7 @@ export default function UsageRecords() {
|
||||
</svg>
|
||||
<input
|
||||
style={{ width: '100%', paddingLeft: 30, paddingRight: 10, height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, boxSizing: 'border-box' }}
|
||||
placeholder="搜索模型名称..."
|
||||
placeholder="搜索模型、提示词或错误..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
@@ -233,21 +293,24 @@ export default function UsageRecords() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<React.Fragment key={log.id}>
|
||||
<tr
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||
>
|
||||
logs.map((log) => {
|
||||
const logKey = String(log.id);
|
||||
return (
|
||||
<React.Fragment key={logKey}>
|
||||
<tr
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setExpandedId(expandedId === logKey ? null : logKey)}
|
||||
>
|
||||
<td style={{ fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{log.model_requested || '-'}</div>
|
||||
{log.model_actual && log.model_actual !== log.model_requested && (
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
|
||||
)}
|
||||
</td>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{log.model_requested || '-'}</div>
|
||||
{log.model_actual && log.model_actual !== log.model_requested && (
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
|
||||
)}
|
||||
{renderContentPreview(log)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge badge-${log.status === 'success' ? 'success' : 'error'}`} style={{ fontSize: 11 }}>
|
||||
{log.status === 'success' ? '成功' : '失败'}
|
||||
@@ -267,15 +330,15 @@ export default function UsageRecords() {
|
||||
{log.estimated_cost != null ? `$${log.estimated_cost.toFixed(5)}` : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<svg
|
||||
style={{ display: 'block', color: 'var(--color-text-muted)', transition: 'transform 0.15s', transform: expandedId === log.id ? 'rotate(180deg)' : 'none' }}
|
||||
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<svg
|
||||
style={{ display: 'block', color: 'var(--color-text-muted)', transition: 'transform 0.15s', transform: expandedId === logKey ? 'rotate(180deg)' : 'none' }}
|
||||
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedId === log.id && (
|
||||
{expandedId === logKey && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: 0, background: 'var(--color-bg-alt)' }}>
|
||||
<div style={{ padding: '14px 20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, borderBottom: '1px solid var(--color-border)' }}>
|
||||
@@ -290,18 +353,20 @@ export default function UsageRecords() {
|
||||
<div style={{ fontSize: 13, fontFamily: 'var(--font-mono)' }}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
{log.error_message && (
|
||||
{log.error_message && (
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 2 }}>错误信息</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-danger)', wordBreak: 'break-all' }}>{log.error_message}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{renderContentDetail(log)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user