Add detailed user analytics page with comprehensive statistics

Features:
- New UserDetailsView component with expandable conversations
- Each conversation shows all messages with token usage and cost
- User information section (email, role, status, last login)
- Token usage statistics grid (8 stat cards)
- Message content truncation for long messages (500 chars)
- Role-based styling (user: blue, assistant: gold)

Backend:
- New GET /admin/users/{user_id}/details endpoint
- Complex SQL queries with joins for user stats and conversations
- Pydantic schemas: UserDetails, ConversationDetail, MessageDetail
- Per-message and per-conversation token tracking

Frontend:
- React Router integration for /admin/users/:userId route
- Navigation from Usage page "View" button to user details
- Back button to return to admin panel
- Proper error handling and loading states
- Responsive CSS styling with hover effects

Changes:
- backend/app/api/v1/endpoints/admin.py: Added getUserDetails endpoint
- frontend/src/components/UserDetailsView.tsx: New component
- frontend/src/App.tsx: Added route for user details page
- frontend/src/components/TokenUsageDashboard.tsx: Added navigation handler
- frontend/src/services/api.ts: Added adminAPI.getUserDetails method
- frontend/src/styles/admin.css: Added comprehensive styling for user details
- frontend/package.json: Added react-router-dom dependency

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
SamoilenkoVadym 2026-01-27 21:51:44 +00:00
parent 2e6597ee08
commit e43feb6163
8 changed files with 841 additions and 36 deletions

View file

@ -71,6 +71,48 @@ class UserAnalytics(BaseModel):
last_activity: datetime | None
class MessageDetail(BaseModel):
"""Message detail for user details view"""
id: str
role: str
content: str
created_at: datetime
token_count: int
cost_usd: float
class ConversationDetail(BaseModel):
"""Conversation detail for user details view"""
id: str
title: str
created_at: datetime
last_message_at: datetime | None
message_count: int
total_tokens: int
total_cost_usd: float
messages: List[MessageDetail]
class UserDetails(BaseModel):
"""Detailed user information with conversations and messages"""
user_id: str
email: str
display_name: str
role: str
is_active: bool
created_at: datetime
last_login_at: datetime | None
total_conversations: int
total_messages: int
total_tokens: int
prompt_tokens: int
completion_tokens: int
cached_tokens: int
total_cost_usd: float
avg_tokens_per_message: float
conversations: List[ConversationDetail]
# Endpoints
@router.get("/users", response_model=List[UserListItem])
@ -445,3 +487,148 @@ async def get_all_conversations(
}
for conv, user in conversations
]
@router.get("/users/{user_id}/details", response_model=UserDetails)
async def get_user_details(
user_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
limit_conversations: int = 20
):
"""
Get detailed information for a specific user (admin only)
Args:
user_id: User ID to get details for
current_user: Current authenticated user
db: Database session
limit_conversations: Max number of conversations to include
Returns:
Detailed user information with conversations and messages
Raises:
HTTPException: If user is not admin or user not found
"""
require_permission(current_user.role, Permission.READ_ALL_CHATS)
# Get user
user_result = await db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Get user's token usage stats
token_stats = await db.execute(
select(
func.count(TokenUsage.id).label("total_messages"),
func.sum(TokenUsage.total_tokens).label("total_tokens"),
func.sum(TokenUsage.prompt_tokens).label("prompt_tokens"),
func.sum(TokenUsage.completion_tokens).label("completion_tokens"),
func.sum(TokenUsage.cached_tokens).label("cached_tokens"),
func.sum(TokenUsage.cost_usd).label("total_cost_usd")
).where(TokenUsage.user_id == user_id)
)
stats_row = token_stats.first()
total_messages = int(stats_row.total_messages or 0)
total_tokens = int(stats_row.total_tokens or 0)
prompt_tokens = int(stats_row.prompt_tokens or 0)
completion_tokens = int(stats_row.completion_tokens or 0)
cached_tokens = int(stats_row.cached_tokens or 0)
total_cost_usd = float(stats_row.total_cost_usd or 0.0)
avg_tokens_per_message = total_tokens / total_messages if total_messages > 0 else 0
# Get user's conversations with message counts and token stats
conv_query = (
select(
Conversation,
func.count(Message.id).label("message_count"),
func.sum(TokenUsage.total_tokens).label("total_tokens"),
func.sum(TokenUsage.cost_usd).label("total_cost")
)
.outerjoin(Message, Conversation.id == Message.conversation_id)
.outerjoin(TokenUsage, Conversation.id == TokenUsage.conversation_id)
.where(Conversation.user_id == user_id)
.group_by(Conversation.id)
.order_by(Conversation.last_message_at.desc().nullslast())
.limit(limit_conversations)
)
conv_result = await db.execute(conv_query)
conversations_data = conv_result.all()
# Build conversation details with messages
conversations = []
for conv, message_count, conv_tokens, conv_cost in conversations_data:
# Get messages for this conversation
msg_query = (
select(Message, TokenUsage)
.outerjoin(TokenUsage,
(TokenUsage.conversation_id == Message.conversation_id) &
(TokenUsage.message_id == Message.id))
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at.asc())
)
msg_result = await db.execute(msg_query)
messages_data = msg_result.all()
messages = [
MessageDetail(
id=str(msg.id),
role=msg.role,
content=msg.content,
created_at=msg.created_at,
token_count=int(token.total_tokens) if token else 0,
cost_usd=float(token.cost_usd) if token else 0.0
)
for msg, token in messages_data
]
conversations.append(
ConversationDetail(
id=str(conv.id),
title=conv.title,
created_at=conv.created_at,
last_message_at=conv.last_message_at,
message_count=int(message_count or 0),
total_tokens=int(conv_tokens or 0),
total_cost_usd=float(conv_cost or 0.0),
messages=messages
)
)
# Get total conversation count
total_conv_count = await db.scalar(
select(func.count(Conversation.id))
.where(Conversation.user_id == user_id)
)
logger.info(f"Admin {current_user.id} viewed details for user {user_id}")
return UserDetails(
user_id=str(user.id),
email=user.email,
display_name=user.display_name,
role=user.role,
is_active=user.is_active,
created_at=user.created_at,
last_login_at=user.last_login_at,
total_conversations=total_conv_count or 0,
total_messages=total_messages,
total_tokens=total_tokens,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cached_tokens=cached_tokens,
total_cost_usd=total_cost_usd,
avg_tokens_per_message=round(avg_tokens_per_message, 1),
conversations=conversations
)

View file

@ -15,7 +15,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.21.1",
"react-router-dom": "^6.30.3",
"recharts": "^2.10.4"
},
"devDependencies": {
@ -4154,6 +4154,7 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/q": {
@ -4181,6 +4182,7 @@
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@ -18899,22 +18901,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"extraneous": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",

View file

@ -6,19 +6,19 @@
"dependencies": {
"@azure/msal-browser": "^3.7.1",
"@azure/msal-react": "^2.0.11",
"axios": "^1.6.5",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"axios": "^1.6.5",
"react-markdown": "^9.0.1",
"prismjs": "^1.29.0",
"react-router-dom": "^6.30.3",
"recharts": "^2.10.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/prismjs": "^1.26.3",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/prismjs": "^1.26.3",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0",

View file

@ -5,6 +5,7 @@
*/
import React, { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ChatProvider, useChat } from './context/ChatContext';
import SimpleLogin from './components/SimpleLogin';
@ -12,16 +13,26 @@ import ChatInterface from './components/ChatInterface';
import ChatList from './components/ChatList';
import TokenUsageDashboard from './components/TokenUsageDashboard';
import AdminPanel from './components/AdminPanel';
import UserDetailsView from './components/UserDetailsView';
const AppContent: React.FC = () => {
const { isAuthenticated, isLoading, user, logout, login } = useAuth();
const { loadConversations, clearState } = useChat();
const [activeView, setActiveView] = useState<'chat' | 'usage' | 'admin'>('chat');
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [prevUserId, setPrevUserId] = useState<string | null>(null);
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin';
// Determine active view from current route
const getActiveView = () => {
if (location.pathname.startsWith('/usage')) return 'usage';
if (location.pathname.startsWith('/admin')) return 'admin';
return 'chat';
};
const activeView = getActiveView();
// Clear chat state when user logs out
useEffect(() => {
if (!isAuthenticated) {
@ -81,7 +92,7 @@ const AppContent: React.FC = () => {
<div className="app-header-right">
<button
className={`btn-nav ${activeView === 'chat' ? 'active' : ''}`}
onClick={() => setActiveView('chat')}
onClick={() => navigate('/')}
>
💬 Chat
</button>
@ -89,13 +100,13 @@ const AppContent: React.FC = () => {
<>
<button
className={`btn-nav ${activeView === 'usage' ? 'active' : ''}`}
onClick={() => setActiveView('usage')}
onClick={() => navigate('/usage')}
>
📊 Usage
</button>
<button
className={`btn-nav ${activeView === 'admin' ? 'active' : ''}`}
onClick={() => setActiveView('admin')}
onClick={() => navigate('/admin')}
>
👨💼 Admin
</button>
@ -124,9 +135,12 @@ const AppContent: React.FC = () => {
)}
<main className="app-content">
{activeView === 'chat' && <ChatInterface />}
{activeView === 'usage' && <TokenUsageDashboard />}
{activeView === 'admin' && isAdmin && <AdminPanel />}
<Routes>
<Route path="/" element={<ChatInterface />} />
<Route path="/usage" element={<TokenUsageDashboard />} />
<Route path="/admin" element={<AdminPanel />} />
<Route path="/admin/users/:userId" element={<UserDetailsView />} />
</Routes>
</main>
</div>
</div>
@ -135,11 +149,13 @@ const AppContent: React.FC = () => {
function App() {
return (
<AuthProvider>
<ChatProvider>
<AppContent />
</ChatProvider>
</AuthProvider>
<BrowserRouter>
<AuthProvider>
<ChatProvider>
<AppContent />
</ChatProvider>
</AuthProvider>
</BrowserRouter>
);
}

View file

@ -6,6 +6,7 @@
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { tokenAPI } from '../services/api';
import { useAuth } from '../context/AuthContext';
@ -52,6 +53,7 @@ interface UserTokenStats {
const TokenUsageDashboard: React.FC = () => {
const { user } = useAuth();
const navigate = useNavigate();
const [usage, setUsage] = useState<UsageSummary | null>(null);
const [dailyUserUsage, setDailyUserUsage] = useState<DailyUserUsage[]>([]);
const [usersStats, setUsersStats] = useState<UserTokenStats[]>([]);
@ -95,9 +97,7 @@ const TokenUsageDashboard: React.FC = () => {
}, [loadUsage]);
const handleViewUserDetails = (userId: string) => {
// TODO: Navigate to user details page (to be implemented)
console.log('View details for user:', userId);
alert('User details view coming soon!');
navigate(`/admin/users/${userId}`);
};
if (isLoading) {

View file

@ -0,0 +1,286 @@
/**
* User Details View Component
*
* Displays comprehensive analytics for a specific user (admin only)
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { adminAPI } from '../services/api';
interface MessageDetail {
id: string;
role: string;
content: string;
created_at: string;
token_count: number;
cost_usd: number;
}
interface ConversationDetail {
id: string;
title: string;
created_at: string;
last_message_at: string | null;
message_count: number;
total_tokens: number;
total_cost_usd: number;
messages: MessageDetail[];
}
interface UserDetails {
user_id: string;
email: string;
display_name: string;
role: string;
is_active: boolean;
created_at: string;
last_login_at: string | null;
total_conversations: number;
total_messages: number;
total_tokens: number;
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
total_cost_usd: number;
avg_tokens_per_message: number;
conversations: ConversationDetail[];
}
const UserDetailsView: React.FC = () => {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const [userDetails, setUserDetails] = useState<UserDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedConversations, setExpandedConversations] = useState<Set<string>>(new Set());
useEffect(() => {
loadUserDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
const loadUserDetails = async () => {
if (!userId) {
setError('User ID is required');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setError(null);
const response = await adminAPI.getUserDetails(userId);
setUserDetails(response.data);
} catch (err: any) {
console.error('Failed to load user details:', err);
setError(err.response?.data?.detail || 'Failed to load user details');
} finally {
setIsLoading(false);
}
};
const toggleConversation = (conversationId: string) => {
setExpandedConversations((prev) => {
const newSet = new Set(prev);
if (newSet.has(conversationId)) {
newSet.delete(conversationId);
} else {
newSet.add(conversationId);
}
return newSet;
});
};
const handleBack = () => {
navigate('/admin');
};
if (isLoading) {
return (
<div className="user-details-view">
<div className="user-details-loading">Loading user details...</div>
</div>
);
}
if (error || !userDetails) {
return (
<div className="user-details-view">
<div className="user-details-error">{error || 'No data available'}</div>
<button onClick={handleBack} className="btn-back">Back to Admin</button>
</div>
);
}
return (
<div className="user-details-view">
<div className="user-details-header">
<button onClick={handleBack} className="btn-back"> Back to Admin</button>
<h2>User Details: {userDetails.display_name}</h2>
</div>
{/* User Information Section */}
<div className="user-info-section">
<h3>User Information</h3>
<div className="user-info-grid">
<div className="user-info-item">
<span className="user-info-label">Email:</span>
<span className="user-info-value">{userDetails.email}</span>
</div>
<div className="user-info-item">
<span className="user-info-label">Role:</span>
<span className="user-info-value">{userDetails.role}</span>
</div>
<div className="user-info-item">
<span className="user-info-label">Status:</span>
<span className={`status-badge ${userDetails.is_active ? 'active' : 'inactive'}`}>
{userDetails.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<div className="user-info-item">
<span className="user-info-label">Created:</span>
<span className="user-info-value">
{new Date(userDetails.created_at).toLocaleDateString()}
</span>
</div>
<div className="user-info-item">
<span className="user-info-label">Last Login:</span>
<span className="user-info-value">
{userDetails.last_login_at
? new Date(userDetails.last_login_at).toLocaleString()
: 'Never'}
</span>
</div>
</div>
</div>
{/* Token Statistics Section */}
<div className="user-stats-section">
<h3>Token Usage Statistics</h3>
<div className="user-stats-grid">
<div className="user-stat-card">
<div className="user-stat-label">Total Conversations</div>
<div className="user-stat-value">{userDetails.total_conversations}</div>
</div>
<div className="user-stat-card">
<div className="user-stat-label">Total Messages</div>
<div className="user-stat-value">{userDetails.total_messages}</div>
</div>
<div className="user-stat-card">
<div className="user-stat-label">Total Tokens</div>
<div className="user-stat-value">{userDetails.total_tokens.toLocaleString()}</div>
</div>
<div className="user-stat-card">
<div className="user-stat-label">Prompt Tokens</div>
<div className="user-stat-value">{userDetails.prompt_tokens.toLocaleString()}</div>
</div>
<div className="user-stat-card">
<div className="user-stat-label">Completion Tokens</div>
<div className="user-stat-value">{userDetails.completion_tokens.toLocaleString()}</div>
</div>
<div className="user-stat-card">
<div className="user-stat-label">Cached Tokens</div>
<div className="user-stat-value">{userDetails.cached_tokens.toLocaleString()}</div>
</div>
<div className="user-stat-card">
<div className="user-stat-label">Total Cost</div>
<div className="user-stat-value">${userDetails.total_cost_usd.toFixed(4)}</div>
</div>
<div className="user-stat-card">
<div className="user-stat-label">Avg Tokens/Message</div>
<div className="user-stat-value">{userDetails.avg_tokens_per_message.toFixed(1)}</div>
</div>
</div>
</div>
{/* Conversations Section */}
<div className="user-conversations-section">
<h3>Conversations ({userDetails.conversations.length})</h3>
{userDetails.conversations.length === 0 ? (
<div className="empty-state">No conversations found</div>
) : (
<div className="conversations-list">
{userDetails.conversations.map((conversation) => (
<div key={conversation.id} className="conversation-card">
<div
className="conversation-header"
onClick={() => toggleConversation(conversation.id)}
>
<div className="conversation-title">
<span className="expand-icon">
{expandedConversations.has(conversation.id) ? '▼' : '▶'}
</span>
<span>{conversation.title}</span>
</div>
<div className="conversation-stats">
<span>{conversation.message_count} messages</span>
<span>{conversation.total_tokens.toLocaleString()} tokens</span>
<span>${conversation.total_cost_usd.toFixed(4)}</span>
</div>
</div>
{expandedConversations.has(conversation.id) && (
<div className="conversation-details">
<div className="conversation-meta">
<div>
<strong>Created:</strong>{' '}
{new Date(conversation.created_at).toLocaleString()}
</div>
<div>
<strong>Last Activity:</strong>{' '}
{conversation.last_message_at
? new Date(conversation.last_message_at).toLocaleString()
: 'N/A'}
</div>
<div>
<strong>Total Tokens:</strong>{' '}
{conversation.total_tokens.toLocaleString()}
</div>
<div>
<strong>Total Cost:</strong>{' '}
${conversation.total_cost_usd.toFixed(4)}
</div>
</div>
<div className="messages-section">
<h4>Messages</h4>
<div className="messages-list">
{conversation.messages.map((message) => (
<div
key={message.id}
className={`message-item ${message.role}`}
>
<div className="message-header">
<span className="message-role">
{message.role === 'user' ? '👤 User' : '🤖 Assistant'}
</span>
<span className="message-date">
{new Date(message.created_at).toLocaleString()}
</span>
</div>
<div className="message-content">
{message.content.length > 500
? message.content.substring(0, 500) + '...'
: message.content}
</div>
<div className="message-tokens">
<span>Tokens: {message.token_count}</span>
<span>Cost: ${message.cost_usd.toFixed(4)}</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};
export default UserDetailsView;

View file

@ -156,4 +156,8 @@ export const adminAPI = {
getAllConversations: (skip: number = 0, limit: number = 50) =>
apiClient.get('/admin/conversations/all', { params: { skip, limit } }),
// User details
getUserDetails: (userId: string) =>
apiClient.get(`/admin/users/${userId}/details`),
};

View file

@ -318,3 +318,329 @@
margin: 0;
font-size: 0.95rem;
}
/* User Details View */
.user-details-view {
width: 100%;
height: 100%;
overflow: auto;
padding: 2rem;
background: var(--bg-secondary);
}
.user-details-header {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 2rem;
}
.user-details-header h2 {
margin: 0;
font-size: 1.75rem;
color: var(--text-primary);
}
.btn-back {
padding: 0.625rem 1.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.95rem;
cursor: pointer;
transition: all var(--transition-normal);
font-weight: 500;
}
.btn-back:hover {
background: var(--bg-hover);
border-color: var(--primary-gold);
color: var(--primary-gold);
}
.user-details-loading,
.user-details-error {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
font-size: 1.1rem;
}
.user-details-error {
color: var(--error-color);
}
/* User Information Section */
.user-info-section {
background: var(--white);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.user-info-section h3 {
margin: 0 0 1.25rem 0;
font-size: 1.25rem;
color: var(--text-primary);
border-bottom: 2px solid var(--primary-gold);
padding-bottom: 0.5rem;
}
.user-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.user-info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.user-info-label {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
.user-info-value {
font-size: 1rem;
color: var(--text-primary);
font-weight: 500;
}
/* User Stats Section */
.user-stats-section {
background: var(--white);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.user-stats-section h3 {
margin: 0 0 1.25rem 0;
font-size: 1.25rem;
color: var(--text-primary);
border-bottom: 2px solid var(--primary-gold);
padding-bottom: 0.5rem;
}
.user-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1.5rem;
}
.user-stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 1.25rem;
text-align: center;
transition: all var(--transition-normal);
}
.user-stat-card:hover {
border-color: var(--primary-gold);
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
}
.user-stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-weight: 500;
}
.user-stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--primary-gold);
}
/* Conversations Section */
.user-conversations-section {
background: var(--white);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1.5rem;
box-shadow: var(--shadow-sm);
}
.user-conversations-section h3 {
margin: 0 0 1.25rem 0;
font-size: 1.25rem;
color: var(--text-primary);
border-bottom: 2px solid var(--primary-gold);
padding-bottom: 0.5rem;
}
.conversations-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.conversation-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
overflow: hidden;
transition: all var(--transition-normal);
}
.conversation-card:hover {
border-color: var(--primary-gold);
box-shadow: var(--shadow-sm);
}
.conversation-header {
padding: 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background var(--transition-normal);
}
.conversation-header:hover {
background: var(--bg-hover);
}
.conversation-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
color: var(--text-primary);
font-size: 1rem;
}
.expand-icon {
color: var(--primary-gold);
font-size: 0.875rem;
}
.archived-badge {
padding: 0.25rem 0.5rem;
background: var(--error-bg);
color: var(--error-color);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
}
.conversation-stats {
display: flex;
gap: 1.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.conversation-details {
padding: 1rem;
border-top: 1px solid var(--border-color);
background: var(--white);
}
.conversation-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--text-primary);
}
.conversation-meta strong {
color: var(--text-secondary);
font-weight: 600;
}
/* Messages Section */
.messages-section h4 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--text-primary);
}
.messages-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.message-item {
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--white);
}
.message-item.user {
border-left: 3px solid var(--primary-blue);
}
.message-item.assistant {
border-left: 3px solid var(--primary-gold);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.message-role {
font-weight: 600;
color: var(--text-primary);
font-size: 0.9rem;
}
.message-date {
font-size: 0.8rem;
color: var(--text-secondary);
}
.message-content {
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.95rem;
line-height: 1.5;
margin-bottom: 0.75rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.message-tokens {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: var(--text-secondary);
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.message-tokens span {
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
font-size: 1rem;
}