feat(export): add metadata and statistics to export data

- Add ExportMetadata type with session info, token stats, file operation stats
- Track response_id from LLM API for telemetry correlation
- Collect usageMetadata from assistant messages
- Calculate file stats (files read/written, lines added/removed)
- Calculate token stats (total tokens, context usage percentage)
- Add metadata sidebar to HTML export template
- Support metadata in JSONL and Markdown formatters
- Update chatRecordingService to record response_id

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mingholy.lmh 2026-03-12 21:37:05 +08:00
parent 27356c1bac
commit d59e668729
11 changed files with 776 additions and 31 deletions

View file

@ -29,6 +29,27 @@ type ChatData = {
messages?: unknown[];
sessionId?: string;
startTime?: string;
metadata?: ExportMetadata;
};
type ExportMetadata = {
sessionId: string;
startTime: string;
relativeTime: string;
exportTime: string;
cwd: string;
gitBranch?: string;
model?: string;
channel?: string;
promptCount: number;
contextUsagePercent?: number;
totalTokens?: number;
filesRead?: number;
filesWritten?: number;
linesAdded?: number;
linesRemoved?: number;
uniqueFiles: string[];
requestId?: string;
};
type PlatformContextValue = {
@ -132,6 +153,198 @@ const formatSessionDate = (startTime?: string | null) => {
}
};
const formatExportTime = (exportTime?: string | null) => {
if (!exportTime) {
return '-';
}
try {
const date = new Date(exportTime);
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return exportTime;
}
};
const formatPath = (path: string, maxLength: number = 40) => {
if (!path || path.length <= maxLength) return path;
const parts = path.split('/');
if (parts.length <= 2) return '...' + path.slice(-maxLength + 3);
return '...' + path.slice(-maxLength + 3);
};
const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = React.useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<button
onClick={handleCopy}
className="copy-button"
title={copied ? 'Copied!' : 'Copy to clipboard'}
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
>
{copied ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
};
const MetadataItem = ({
label,
value,
valueClass,
}: {
label: string;
value?: string | number;
valueClass?: string;
}) => {
if (value === undefined || value === null || value === '') {
return null;
}
return (
<div className="metadata-item">
<div className="metadata-content">
<span className="metadata-label">{label}</span>
<span
className={`metadata-value ${valueClass || ''}`}
title={typeof value === 'string' ? value : undefined}
>
{value}
</span>
</div>
</div>
);
};
const MetadataSidebar = ({ metadata }: { metadata: ExportMetadata }) => {
const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0;
return (
<aside className="metadata-sidebar">
<div className="metadata-section">
<h3 className="metadata-section-title">Session Info</h3>
<MetadataItem label="Time" value={metadata.relativeTime} />
<MetadataItem label="Project" value={formatPath(metadata.cwd)} />
{metadata.gitBranch && (
<MetadataItem label="Branch" value={metadata.gitBranch} />
)}
{metadata.model && (
<MetadataItem label="Model" value={metadata.model} />
)}
{metadata.channel && (
<MetadataItem label="Channel" value={metadata.channel} />
)}
</div>
<div className="metadata-section">
<h3 className="metadata-section-title">Statistics</h3>
<MetadataItem label="Prompts" value={metadata.promptCount} />
{metadata.contextUsagePercent !== undefined && (
<MetadataItem
label="Context"
value={`${metadata.contextUsagePercent}% of 128k`}
/>
)}
{metadata.totalTokens !== undefined && (
<MetadataItem
label="Tokens"
value={metadata.totalTokens.toLocaleString()}
/>
)}
<MetadataItem label="Files" value={uniqueFilesCount} />
</div>
<div className="metadata-section">
<h3 className="metadata-section-title">File Operations</h3>
{metadata.filesRead !== undefined && metadata.filesRead > 0 && (
<MetadataItem label="Read" value={metadata.filesRead} />
)}
{metadata.filesWritten !== undefined && metadata.filesWritten > 0 && (
<MetadataItem label="Written" value={metadata.filesWritten} />
)}
{metadata.linesAdded !== undefined && metadata.linesAdded > 0 && (
<MetadataItem
label="Added"
value={`+${metadata.linesAdded}`}
valueClass="text-green"
/>
)}
{metadata.linesRemoved !== undefined && metadata.linesRemoved > 0 && (
<MetadataItem
label="Removed"
value={`-${metadata.linesRemoved}`}
valueClass="text-red"
/>
)}
</div>
<div className="metadata-section metadata-section-small">
{metadata.requestId ? (
<div className="metadata-item">
<div className="metadata-content">
<span className="metadata-label">Request Id</span>
<div className="metadata-value-with-copy">
<span className="metadata-value font-mono">
{metadata.requestId}
</span>
<CopyButton text={metadata.requestId} />
</div>
</div>
</div>
) : (
<MetadataItem
label="Session ID"
value={metadata.sessionId}
valueClass="font-mono"
/>
)}
<MetadataItem
label="Export Time"
value={formatExportTime(metadata.exportTime)}
/>
</div>
</aside>
);
};
const App = () => {
const chatData = parseChatData();
const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : [];
@ -140,6 +353,7 @@ const App = () => {
.filter((record) => record.type !== 'system');
const sessionId = chatData.sessionId ?? '-';
const sessionDate = formatSessionDate(chatData.startTime);
const metadata = chatData.metadata;
const { platformContext, modalState, closeModal } = usePlatformContext();
return (
@ -168,10 +382,13 @@ const App = () => {
</div>
</div>
</header>
<div className="chat-container">
<PlatformProvider value={platformContext}>
<ChatViewer messages={messages} autoScroll={false} theme="dark" />
</PlatformProvider>
<div className="content-wrapper">
<div className="chat-container">
<PlatformProvider value={platformContext}>
<ChatViewer messages={messages} autoScroll={false} theme="dark" />
</PlatformProvider>
</div>
{metadata && <MetadataSidebar metadata={metadata} />}
</div>
<TempFileModal state={modalState} onClose={closeModal} />
</div>

View file

@ -144,14 +144,6 @@ body {
color: #71717a;
}
.chat-container {
width: 100%;
max-width: 900px;
padding: 40px 20px;
box-sizing: border-box;
flex: 1;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
@ -201,3 +193,181 @@ body {
padding: 16px 12px;
}
}
/* Main layout - sidebar on right, messages on left */
.content-wrapper {
display: flex;
width: 100%;
max-width: 1600px;
height: calc(100vh - 73px);
}
.chat-container {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 24px;
box-sizing: border-box;
}
/* Metadata Sidebar - fixed on right */
.metadata-sidebar {
width: 280px;
min-width: 280px;
padding: 12px;
border-right: 1px solid var(--border-color);
background-color: var(--bg-secondary);
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
height: 100%;
box-sizing: border-box;
}
.metadata-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.metadata-section-title {
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-color);
}
.metadata-section-small {
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.metadata-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.metadata-content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.metadata-content .metadata-label {
font-size: 10px;
color: #71717a;
}
.metadata-content .metadata-value {
font-size: 11px;
color: var(--text-primary);
word-break: break-all;
line-height: 1.3;
cursor: pointer;
}
.metadata-content .metadata-value.text-green {
color: #22c55e;
}
.metadata-content .metadata-value.text-red {
color: #ef4444;
}
.metadata-value-with-copy {
display: flex;
align-items: center;
gap: 8px;
}
.metadata-value-with-copy .metadata-value {
flex: 1;
min-width: 0;
}
.copy-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: 1px solid var(--border-color, #3f3f46);
border-radius: 4px;
color: var(--text-secondary, #a1a1aa);
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.copy-button:hover {
background: var(--bg-hover, #27272a);
color: var(--text-primary, #f4f4f5);
border-color: var(--border-hover, #52525b);
}
.copy-button:active {
transform: scale(0.95);
}
/* Responsive adjustments */
@media (max-width: 1024px) {
.metadata-sidebar {
width: 260px;
min-width: 260px;
padding: 10px;
}
}
@media (max-width: 768px) {
.content-wrapper {
flex-direction: column;
height: auto;
}
.chat-container {
height: auto;
min-height: 50vh;
}
.metadata-sidebar {
width: 100%;
min-width: 100%;
height: auto;
max-height: none;
border-right: none;
border-top: 1px solid var(--border-color);
padding: 12px;
gap: 12px;
}
.metadata-section {
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
}
.metadata-section-title {
width: 100%;
border-bottom: none;
padding-bottom: 0;
}
.metadata-item {
flex: 1;
min-width: 140px;
}
.metadata-section-small {
margin-top: 0;
padding-top: 0;
border-top: none;
}
}