feat(UI): Enhance model statistics tracking and citation handling in chat components

This commit is contained in:
Willie Zutz 2025-08-04 00:41:31 -06:00
parent 3e238303b0
commit 71120c997a
11 changed files with 354 additions and 104 deletions

View file

@ -150,3 +150,4 @@ When working on this codebase, you might need to:
- `/langchain-ai/langchainjs` for LangChain - `/langchain-ai/langchainjs` for LangChain
- `/langchain-ai/langgraph` for LangGraph - `/langchain-ai/langgraph` for LangGraph
- `/quantizor/markdown-to-jsx` for Markdown to JSX conversion - `/quantizor/markdown-to-jsx` for Markdown to JSX conversion
- `/context7/headlessui_com` for Headless UI components

View file

@ -56,6 +56,11 @@ type Body = {
type ModelStats = { type ModelStats = {
modelName: string; modelName: string;
responseTime?: number; responseTime?: number;
usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
}; };
const handleEmitterEvents = async ( const handleEmitterEvents = async (

View file

@ -16,6 +16,11 @@ import NextError from 'next/error';
export type ModelStats = { export type ModelStats = {
modelName: string; modelName: string;
responseTime?: number; responseTime?: number;
usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
}; };
export type AgentActionEvent = { export type AgentActionEvent = {

View file

@ -0,0 +1,55 @@
import { Document } from '@langchain/core/documents';
import { useState } from 'react';
import MessageSource from './MessageSource';
interface CitationLinkProps {
number: string;
source?: Document;
url?: string;
}
const CitationLink = ({ number, source, url }: CitationLinkProps) => {
const [showTooltip, setShowTooltip] = useState(false);
const linkContent = (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative hover:bg-light-200 dark:hover:bg-dark-200 transition-colors duration-200"
>
{number}
</a>
);
// If we have source data, wrap with tooltip
if (source) {
return (
<div className="relative inline-block">
<div
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{linkContent}
</div>
{showTooltip && (
<div className="absolute z-50 bottom-full mb-2 left-1/2 transform -translate-x-1/2 animate-in fade-in-0 duration-150">
<div className="bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 shadow-lg w-96">
<MessageSource
source={source}
className="shadow-none border-none bg-transparent hover:bg-transparent dark:hover:bg-transparent cursor-pointer"
/>
</div>
{/* Tooltip arrow */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-light-200 dark:border-t-dark-200"></div>
</div>
)}
</div>
);
}
// Otherwise, just return the plain link
return linkContent;
};
export default CitationLink;

View file

@ -2,7 +2,14 @@
'use client'; 'use client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CheckCheck, Copy as CopyIcon, Search, FileText, Globe, Settings } from 'lucide-react'; import {
CheckCheck,
Copy as CopyIcon,
Search,
FileText,
Globe,
Settings,
} from 'lucide-react';
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx'; import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
import { useState } from 'react'; import { useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
@ -12,6 +19,8 @@ import {
} from 'react-syntax-highlighter/dist/cjs/styles/prism'; } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import ThinkBox from './ThinkBox'; import ThinkBox from './ThinkBox';
import { Document } from '@langchain/core/documents';
import CitationLink from './CitationLink';
// Helper functions for think overlay // Helper functions for think overlay
const extractThinkContent = (content: string): string | null => { const extractThinkContent = (content: string): string | null => {
@ -51,6 +60,7 @@ interface MarkdownRendererProps {
thinkBoxId: string, thinkBoxId: string,
expanded: boolean, expanded: boolean,
) => void; ) => void;
sources?: Document[];
} }
// Custom ToolCall component for markdown // Custom ToolCall component for markdown
@ -162,8 +172,8 @@ const ThinkTagProcessor = ({
onToggle?: (thinkBoxId: string, expanded: boolean) => void; onToggle?: (thinkBoxId: string, expanded: boolean) => void;
}) => { }) => {
return ( return (
<ThinkBox <ThinkBox
content={children} content={children}
expanded={isExpanded} expanded={isExpanded}
onToggle={() => { onToggle={() => {
if (id && onToggle) { if (id && onToggle) {
@ -172,7 +182,8 @@ const ThinkTagProcessor = ({
}} }}
/> />
); );
};const CodeBlock = ({ };
const CodeBlock = ({
className, className,
children, children,
}: { }: {
@ -248,6 +259,7 @@ const MarkdownRenderer = ({
messageId, messageId,
expandedThinkBoxes, expandedThinkBoxes,
onThinkBoxToggle, onThinkBoxToggle,
sources,
}: MarkdownRendererProps) => { }: MarkdownRendererProps) => {
// Preprocess content to add stable IDs to think tags // Preprocess content to add stable IDs to think tags
const processedContent = addThinkBoxIds(content); const processedContent = addThinkBoxIds(content);
@ -265,7 +277,7 @@ const MarkdownRenderer = ({
}; };
// Determine what content to render based on showThinking parameter // Determine what content to render based on showThinking parameter
const contentToRender = showThinking const contentToRender = showThinking
? processedContent ? processedContent
: removeThinkTags(processedContent); : removeThinkTags(processedContent);
// Markdown formatting options // Markdown formatting options
@ -317,9 +329,28 @@ const MarkdownRenderer = ({
component: ({ children }) => children, component: ({ children }) => children,
}, },
a: { a: {
component: (props) => ( component: (props) => {
<a {...props} target="_blank" rel="noopener noreferrer" /> // Check if this is a citation link with data-citation attribute
), const citationNumber = props['data-citation'];
if (sources && citationNumber) {
const number = parseInt(citationNumber);
const source = sources[number - 1];
if (source) {
return (
<CitationLink
number={number.toString()}
source={source}
url={props.href}
/>
);
}
}
// Default link behavior
return <a {...props} target="_blank" rel="noopener noreferrer" />;
},
}, },
// Prevent rendering of certain HTML elements for security // Prevent rendering of certain HTML elements for security
iframe: () => null, // Don't render iframes iframe: () => null, // Don't render iframes

View file

@ -48,21 +48,21 @@ const ModelInfoButton: React.FC<ModelInfoButtonProps> = ({ modelStats }) => {
{showPopover && ( {showPopover && (
<div <div
ref={popoverRef} ref={popoverRef}
className="absolute z-10 left-6 top-0 w-64 rounded-md shadow-lg bg-white dark:bg-dark-secondary border border-light-200 dark:border-dark-200" className="absolute z-10 left-6 top-0 w-72 rounded-md shadow-lg bg-white dark:bg-dark-secondary border border-light-200 dark:border-dark-200"
> >
<div className="py-2 px-3"> <div className="py-2 px-3">
<h4 className="text-sm font-medium mb-2 text-black dark:text-white"> <h4 className="text-sm font-medium mb-2 text-black dark:text-white">
Model Information Model Information
</h4> </h4>
<div className="space-y-1 text-xs"> <div className="space-y-1 text-xs">
<div className="flex justify-between"> <div className="flex space-x-2">
<span className="text-black/70 dark:text-white/70">Model:</span> <span className="text-black/70 dark:text-white/70">Model:</span>
<span className="text-black dark:text-white font-medium"> <span className="text-black dark:text-white font-medium">
{modelName} {modelName}
</span> </span>
</div> </div>
{modelStats?.responseTime && ( {modelStats?.responseTime && (
<div className="flex justify-between"> <div className="flex space-x-2">
<span className="text-black/70 dark:text-white/70"> <span className="text-black/70 dark:text-white/70">
Response time: Response time:
</span> </span>
@ -71,6 +71,34 @@ const ModelInfoButton: React.FC<ModelInfoButtonProps> = ({ modelStats }) => {
</span> </span>
</div> </div>
)} )}
{modelStats?.usage && (
<>
<div className="flex space-x-2">
<span className="text-black/70 dark:text-white/70">
Input tokens:
</span>
<span className="text-black dark:text-white font-medium">
{modelStats.usage.input_tokens.toLocaleString()}
</span>
</div>
<div className="flex space-x-2">
<span className="text-black/70 dark:text-white/70">
Output tokens:
</span>
<span className="text-black dark:text-white font-medium">
{modelStats.usage.output_tokens.toLocaleString()}
</span>
</div>
<div className="flex space-x-2">
<span className="text-black/70 dark:text-white/70">
Total tokens:
</span>
<span className="text-black dark:text-white font-medium">
{modelStats.usage.total_tokens.toLocaleString()}
</span>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,111 @@
/* eslint-disable @next/next/no-img-element */
import { Document } from '@langchain/core/documents';
import { File, Zap, Microscope, FileText, Sparkles } from 'lucide-react';
interface MessageSourceProps {
source: Document;
index?: number;
style?: React.CSSProperties;
className?: string;
}
const MessageSource = ({
source,
index,
style,
className,
}: MessageSourceProps) => {
return (
<a
className={`bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-4 flex flex-row no-underline space-x-3 font-medium ${className || ''}`}
href={source.metadata.url}
target="_blank"
style={style}
>
{/* Left side: Favicon/Icon and source number */}
<div className="flex flex-col items-center space-y-2 flex-shrink-0">
{source.metadata.url === 'File' ? (
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-8 h-8 rounded-full">
<File size={16} className="text-white/70" />
</div>
) : (
<img
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
width={28}
height={28}
alt="favicon"
className="rounded-lg h-7 w-7"
/>
)}
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
{typeof index === 'number' && (
<span className="font-semibold">{index + 1}</span>
)}
{/* Processing type indicator */}
{source.metadata.processingType === 'preview-only' && (
<span title="Partial content analyzed" className="inline-flex">
<Zap size={12} className="text-black/40 dark:text-white/40" />
</span>
)}
{source.metadata.processingType === 'full-content' && (
<span title="Full content analyzed" className="inline-flex">
<Microscope
size={12}
className="text-black/40 dark:text-white/40"
/>
</span>
)}
{source.metadata.processingType === 'url-direct-content' && (
<span title="Direct URL content" className="inline-flex">
<FileText
size={12}
className="text-black/40 dark:text-white/40"
/>
</span>
)}
{source.metadata.processingType === 'url-content-extraction' && (
<span title="Summarized URL content" className="inline-flex">
<Sparkles
size={12}
className="text-black/40 dark:text-white/40"
/>
</span>
)}
</div>
</div>
{/* Right side: Content */}
<div className="flex-1 flex flex-col space-y-2">
{/* Title */}
<h3 className="dark:text-white text-sm font-semibold leading-tight">
{source.metadata.title}
</h3>
{/* URL */}
<p className="text-xs text-black/50 dark:text-white/50">
{source.metadata.url.replace(/.+\/\/|www.|\..+/g, '')}
</p>
{/* Preview content */}
<p
className="text-xs text-black/70 dark:text-white/70 leading-relaxed overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
>
{/* Use snippet for preview-only content, otherwise use pageContent */}
{source.metadata.processingType === 'preview-only' &&
source.metadata.snippet
? source.metadata.snippet
: source.pageContent?.length > 250
? source.pageContent.slice(0, 250) + '...'
: source.pageContent || 'No preview available'}
</p>
</div>
</a>
);
};
export default MessageSource;

View file

@ -1,94 +1,12 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { Document } from '@langchain/core/documents'; import { Document } from '@langchain/core/documents';
import { File, Zap, Microscope, FileText, Sparkles } from 'lucide-react'; import MessageSource from './MessageSource';
const MessageSources = ({ sources }: { sources: Document[] }) => { const MessageSources = ({ sources }: { sources: Document[] }) => {
return ( return (
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
{sources.map((source, i) => ( {sources.map((source, i) => (
<a <MessageSource key={i} source={source} index={i} />
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-4 flex flex-row space-x-3 font-medium"
key={i}
href={source.metadata.url}
target="_blank"
>
{/* Left side: Favicon/Icon and source number */}
<div className="flex flex-col items-center space-y-2 flex-shrink-0">
{source.metadata.url === 'File' ? (
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-8 h-8 rounded-full">
<File size={16} className="text-white/70" />
</div>
) : (
<img
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
width={28}
height={28}
alt="favicon"
className="rounded-lg h-7 w-7"
/>
)}
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
<span className="font-semibold">{i + 1}</span>
{/* Processing type indicator */}
{source.metadata.processingType === 'preview-only' && (
<span title="Partial content analyzed" className="inline-flex">
<Zap
size={12}
className="text-black/40 dark:text-white/40"
/>
</span>
)}
{source.metadata.processingType === 'full-content' && (
<span title="Full content analyzed" className="inline-flex">
<Microscope
size={12}
className="text-black/40 dark:text-white/40"
/>
</span>
)}
{source.metadata.processingType === 'url-direct-content' && (
<span title="Direct URL content" className="inline-flex">
<FileText
size={12}
className="text-black/40 dark:text-white/40"
/>
</span>
)}
{source.metadata.processingType === 'url-content-extraction' && (
<span title="Summarized URL content" className="inline-flex">
<Sparkles
size={12}
className="text-black/40 dark:text-white/40"
/>
</span>
)}
</div>
</div>
{/* Right side: Content */}
<div className="flex-1 flex flex-col space-y-2">
{/* Title */}
<h3 className="dark:text-white text-sm font-semibold leading-tight">
{source.metadata.title}
</h3>
{/* URL */}
<p className="text-xs text-black/50 dark:text-white/50">
{source.metadata.url.replace(/.+\/\/|www.|\..+/g, '')}
</p>
{/* Preview content */}
<p className="text-xs text-black/70 dark:text-white/70 leading-relaxed overflow-hidden" style={{ display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical' }}>
{/* Use snippet for preview-only content, otherwise use pageContent */}
{source.metadata.processingType === 'preview-only' && source.metadata.snippet
? source.metadata.snippet
: source.pageContent?.length > 250
? source.pageContent.slice(0, 250) + '...'
: source.pageContent || 'No preview available'
}
</p>
</div>
</a>
))} ))}
</div> </div>
); );

View file

@ -138,7 +138,7 @@ const MessageTabs = ({
const url = source?.metadata?.url; const url = source?.metadata?.url;
if (url) { if (url) {
return `<a href="${url}" target="_blank" className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative">${numStr}</a>`; return `<a href="${url}" target="_blank" data-citation="${number}" className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative hover:bg-light-200 dark:hover:bg-dark-200 transition-colors duration-200">${numStr}</a>`;
} else { } else {
return `[${numStr}]`; return `[${numStr}]`;
} }
@ -279,14 +279,16 @@ const MessageTabs = ({
{/* Answer Tab */} {/* Answer Tab */}
{activeTab === 'text' && ( {activeTab === 'text' && (
<div className="flex flex-col space-y-4 animate-fadeIn"> <div className="flex flex-col space-y-4 animate-fadeIn">
<MarkdownRenderer <MarkdownRenderer
content={parsedMessage} content={parsedMessage}
className="px-4" className="px-4"
messageId={message.messageId} messageId={message.messageId}
expandedThinkBoxes={message.expandedThinkBoxes} expandedThinkBoxes={message.expandedThinkBoxes}
onThinkBoxToggle={onThinkBoxToggle} onThinkBoxToggle={onThinkBoxToggle}
showThinking={true} showThinking={true}
/> {loading && isLast ? null : ( sources={message.sources}
/>{' '}
{loading && isLast ? null : (
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white px-4 py-4"> <div className="flex flex-row items-center justify-between w-full text-black dark:text-white px-4 py-4">
<div className="flex flex-row items-center space-x-1"> <div className="flex flex-row items-center space-x-1">
<Rewrite rewrite={rewrite} messageId={message.messageId} /> <Rewrite rewrite={rewrite} messageId={message.messageId} />
@ -315,7 +317,6 @@ const MessageTabs = ({
</div> </div>
</div> </div>
)} )}
{isLast && message.role === 'assistant' && !loading && ( {isLast && message.role === 'assistant' && !loading && (
<> <>
<div className="border-t border-light-secondary dark:border-dark-secondary px-4 pt-4 mt-4"> <div className="border-t border-light-secondary dark:border-dark-secondary px-4 pt-4 mt-4">

View file

@ -419,8 +419,13 @@ const WidgetConfigModal = ({
</h4> </h4>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Brain size={16} className="text-gray-600 dark:text-gray-400" /> <Brain
<span className="text-sm text-gray-700 dark:text-gray-300">Thinking</span> size={16}
className="text-gray-600 dark:text-gray-400"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Thinking
</span>
<Switch <Switch
checked={showThinking} checked={showThinking}
onChange={setShowThinking} onChange={setShowThinking}

View file

@ -20,6 +20,43 @@ import { formatDateForLLM } from '../utils';
import { getModelName } from '../utils/modelUtils'; import { getModelName } from '../utils/modelUtils';
import { removeThinkingBlocks } from '../utils/contentUtils'; import { removeThinkingBlocks } from '../utils/contentUtils';
/**
* Normalize usage metadata from different LLM providers
*/
function normalizeUsageMetadata(usageData: any): {
input_tokens: number;
output_tokens: number;
total_tokens: number;
} {
if (!usageData) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
// Handle different provider formats
const inputTokens =
usageData.input_tokens ||
usageData.prompt_tokens ||
usageData.promptTokens ||
usageData.usedTokens ||
0;
const outputTokens =
usageData.output_tokens ||
usageData.completion_tokens ||
usageData.completionTokens ||
0;
const totalTokens =
usageData.total_tokens ||
usageData.totalTokens ||
usageData.usedTokens ||
inputTokens + outputTokens;
return {
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
};
}
/** /**
* Simplified Agent using createReactAgent * Simplified Agent using createReactAgent
* *
@ -487,6 +524,11 @@ Use all available tools strategically to provide comprehensive, well-researched,
let finalResult: any = null; let finalResult: any = null;
let collectedDocuments: any[] = []; let collectedDocuments: any[] = [];
let currentResponseBuffer = ''; let currentResponseBuffer = '';
let totalUsage = {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
};
// Process the event stream // Process the event stream
for await (const event of eventStream) { for await (const event of eventStream) {
@ -540,6 +582,31 @@ Use all available tools strategically to provide comprehensive, well-researched,
// Handle streaming tool calls (for thought messages) // Handle streaming tool calls (for thought messages)
if (event.event === 'on_chat_model_end' && event.data.output) { if (event.event === 'on_chat_model_end' && event.data.output) {
const output = event.data.output; const output = event.data.output;
// Collect token usage from chat model end events
if (output.usage_metadata) {
const normalized = normalizeUsageMetadata(output.usage_metadata);
totalUsage.input_tokens += normalized.input_tokens;
totalUsage.output_tokens += normalized.output_tokens;
totalUsage.total_tokens += normalized.total_tokens;
console.log(
'SimplifiedAgent: Collected usage from usage_metadata:',
normalized,
);
} else if (output.response_metadata?.usage) {
// Fallback to response_metadata for different model providers
const normalized = normalizeUsageMetadata(
output.response_metadata.usage,
);
totalUsage.input_tokens += normalized.input_tokens;
totalUsage.output_tokens += normalized.output_tokens;
totalUsage.total_tokens += normalized.total_tokens;
console.log(
'SimplifiedAgent: Collected usage from response_metadata:',
normalized,
);
}
if ( if (
output._getType() === 'ai' && output._getType() === 'ai' &&
output.tool_calls && output.tool_calls &&
@ -589,6 +656,25 @@ Use all available tools strategically to provide comprehensive, well-researched,
} }
} }
// Handle LLM end events for token usage tracking
if (event.event === 'on_llm_end' && event.data.output) {
const output = event.data.output;
// Collect token usage from LLM end events
if (output.llmOutput?.tokenUsage) {
const normalized = normalizeUsageMetadata(
output.llmOutput.tokenUsage,
);
totalUsage.input_tokens += normalized.input_tokens;
totalUsage.output_tokens += normalized.output_tokens;
totalUsage.total_tokens += normalized.total_tokens;
console.log(
'SimplifiedAgent: Collected usage from llmOutput:',
normalized,
);
}
}
// Handle token-level streaming for the final response // Handle token-level streaming for the final response
if (event.event === 'on_chat_model_stream' && event.data.chunk) { if (event.event === 'on_chat_model_stream' && event.data.chunk) {
const chunk = event.data.chunk; const chunk = event.data.chunk;
@ -676,11 +762,15 @@ Use all available tools strategically to provide comprehensive, well-researched,
// Emit model stats and end signal after streaming is complete // Emit model stats and end signal after streaming is complete
const modelName = getModelName(this.llm); const modelName = getModelName(this.llm);
console.log('SimplifiedAgent: Total usage collected:', totalUsage);
this.emitter.emit( this.emitter.emit(
'stats', 'stats',
JSON.stringify({ JSON.stringify({
type: 'modelStats', type: 'modelStats',
data: { modelName }, data: {
modelName,
usage: totalUsage,
},
}), }),
); );