feat(frontend): Add AI Integration UI components

- Add aiApi client with types (ChatMessage, ChatRequest, ChatResponse, AIConfig, AIModel, AIUsageStats)
- Add useAI hooks (useAIConfig, useUpdateAIConfig, useAIModels, useAIChat, useAIUsage, useCurrentAIUsage, useAIHealth)
- Add AI components (AIChat, AISettings, ChatMessage)
- Add AIPage with chat interface and usage stats
- Add AI tab to Settings page
- Add AI Assistant link to dashboard navigation
- 979 lines added across 13 files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-07 07:13:28 -06:00
parent 40d57f8124
commit 19ead3506b
13 changed files with 979 additions and 2 deletions

View File

@ -0,0 +1,232 @@
import { useState, useRef, useEffect, FormEvent } from 'react';
import { Send, Trash2, Settings2, Loader2 } from 'lucide-react';
import clsx from 'clsx';
import { ChatMessage, ChatMessageProps } from './ChatMessage';
import { useAIChat, useAIConfig, useAIModels } from '@/hooks/useAI';
import { ChatMessage as ChatMessageType } from '@/services/api';
interface Message extends ChatMessageProps {
id: string;
}
interface AIChatProps {
systemPrompt?: string;
placeholder?: string;
className?: string;
showModelSelector?: boolean;
onUsageUpdate?: () => void;
}
export function AIChat({
systemPrompt,
placeholder = 'Type your message...',
className,
showModelSelector = false,
onUsageUpdate,
}: AIChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [selectedModel, setSelectedModel] = useState<string | undefined>();
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const { data: config } = useAIConfig();
const { data: models } = useAIModels();
const chatMutation = useAIChat();
// Set default model from config
useEffect(() => {
if (config?.default_model && !selectedModel) {
setSelectedModel(config.default_model);
}
}, [config, selectedModel]);
// Auto scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto resize textarea
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || chatMutation.isPending) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: trimmedInput,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
// Reset textarea height
if (inputRef.current) {
inputRef.current.style.height = 'auto';
}
// Build message history for API
const apiMessages: ChatMessageType[] = [];
// Add system prompt if provided
if (systemPrompt) {
apiMessages.push({ role: 'system', content: systemPrompt });
} else if (config?.system_prompt) {
apiMessages.push({ role: 'system', content: config.system_prompt });
}
// Add conversation history
messages.forEach((msg) => {
if (msg.role !== 'system') {
apiMessages.push({ role: msg.role, content: msg.content });
}
});
// Add current user message
apiMessages.push({ role: 'user', content: trimmedInput });
// Add loading message
const loadingId = `assistant-${Date.now()}`;
setMessages((prev) => [
...prev,
{ id: loadingId, role: 'assistant', content: '', isLoading: true },
]);
try {
const response = await chatMutation.mutateAsync({
messages: apiMessages,
model: selectedModel,
temperature: config?.temperature,
max_tokens: config?.max_tokens,
});
// Replace loading message with actual response
setMessages((prev) =>
prev.map((msg) =>
msg.id === loadingId
? {
...msg,
content: response.choices[0]?.message?.content || 'No response',
isLoading: false,
timestamp: new Date(),
}
: msg
)
);
onUsageUpdate?.();
} catch {
// Remove loading message on error
setMessages((prev) => prev.filter((msg) => msg.id !== loadingId));
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
const clearChat = () => {
setMessages([]);
};
return (
<div className={clsx('flex flex-col bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700', className)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-2">
<Settings2 className="w-4 h-4 text-secondary-500" />
<span className="text-sm font-medium text-secondary-900 dark:text-white">
AI Assistant
</span>
</div>
<div className="flex items-center gap-2">
{showModelSelector && models && models.length > 0 && (
<select
value={selectedModel || ''}
onChange={(e) => setSelectedModel(e.target.value)}
className="text-xs bg-secondary-100 dark:bg-secondary-700 border-0 rounded px-2 py-1 text-secondary-700 dark:text-secondary-300"
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
)}
<button
onClick={clearChat}
disabled={messages.length === 0}
className="p-1.5 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 disabled:opacity-50 disabled:cursor-not-allowed"
title="Clear chat"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto min-h-[300px] max-h-[500px]">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-secondary-400 py-12">
<Settings2 className="w-12 h-12 mb-3 opacity-50" />
<p className="text-sm">Start a conversation</p>
<p className="text-xs mt-1">Type a message below to begin</p>
</div>
) : (
<div className="py-2">
{messages.map((message) => (
<ChatMessage key={message.id} {...message} />
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input Area */}
<form onSubmit={handleSubmit} className="border-t border-secondary-200 dark:border-secondary-700 p-3">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={chatMutation.isPending || !config?.is_enabled}
rows={1}
className="flex-1 resize-none bg-secondary-50 dark:bg-secondary-700 border-0 rounded-lg px-4 py-2.5 text-sm text-secondary-900 dark:text-white placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 disabled:opacity-50"
/>
<button
type="submit"
disabled={!input.trim() || chatMutation.isPending || !config?.is_enabled}
className="flex-shrink-0 p-2.5 bg-primary-500 hover:bg-primary-600 disabled:bg-secondary-300 dark:disabled:bg-secondary-600 text-white rounded-lg transition-colors disabled:cursor-not-allowed"
>
{chatMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
{!config?.is_enabled && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
AI is currently disabled. Enable it in settings to start chatting.
</p>
)}
</form>
</div>
);
}

View File

@ -0,0 +1,300 @@
import { useForm } from 'react-hook-form';
import { Bot, Zap, AlertCircle, Loader2, CheckCircle, XCircle } from 'lucide-react';
import clsx from 'clsx';
import { useAIConfig, useUpdateAIConfig, useAIModels, useCurrentAIUsage, useAIHealth } from '@/hooks/useAI';
interface AIConfigForm {
is_enabled: boolean;
default_model: string;
temperature: number;
max_tokens: number;
system_prompt: string;
allow_custom_prompts: boolean;
log_conversations: boolean;
}
export function AISettings() {
const { data: config, isLoading: configLoading } = useAIConfig();
const { data: models } = useAIModels();
const { data: usage } = useCurrentAIUsage();
const { data: health } = useAIHealth();
const updateConfig = useUpdateAIConfig();
const {
register,
handleSubmit,
watch,
formState: { isDirty },
} = useForm<AIConfigForm>({
values: config ? {
is_enabled: config.is_enabled,
default_model: config.default_model,
temperature: config.temperature,
max_tokens: config.max_tokens,
system_prompt: config.system_prompt || '',
allow_custom_prompts: config.allow_custom_prompts,
log_conversations: config.log_conversations,
} : undefined,
});
const isEnabled = watch('is_enabled');
const temperature = watch('temperature');
const onSubmit = async (data: AIConfigForm) => {
await updateConfig.mutateAsync(data);
};
if (configLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
</div>
);
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 4,
}).format(amount);
};
return (
<div className="space-y-6">
{/* Status Card */}
<div className="card">
<div className="card-header flex items-center gap-3">
<Bot className="w-5 h-5 text-secondary-500" />
<div>
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
AI Integration
</h2>
<p className="text-sm text-secondary-500">
Configure AI assistant settings for your organization
</p>
</div>
</div>
<div className="card-body">
{/* Health Status */}
<div className="flex items-center justify-between p-4 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg mb-4">
<div className="flex items-center gap-3">
<div className={clsx(
'w-10 h-10 rounded-lg flex items-center justify-center',
health?.status === 'healthy' ? 'bg-green-100 dark:bg-green-900/30' :
health?.status === 'degraded' ? 'bg-yellow-100 dark:bg-yellow-900/30' :
'bg-red-100 dark:bg-red-900/30'
)}>
{health?.status === 'healthy' ? (
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />
) : health?.status === 'degraded' ? (
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
) : (
<XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
)}
</div>
<div>
<p className="font-medium text-secondary-900 dark:text-white">
{health?.status === 'healthy' ? 'AI Service Operational' :
health?.status === 'degraded' ? 'AI Service Degraded' :
'AI Service Unavailable'}
</p>
<p className="text-sm text-secondary-500">
{health?.provider || 'OpenRouter'} - {health?.models_available || 0} models available
</p>
</div>
</div>
{health?.latency_ms && (
<span className="text-sm text-secondary-500">
{health.latency_ms}ms latency
</span>
)}
</div>
{/* Usage Stats */}
{usage && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<p className="text-xs text-secondary-500 uppercase tracking-wide">Requests</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{usage.request_count.toLocaleString()}
</p>
</div>
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<p className="text-xs text-secondary-500 uppercase tracking-wide">Tokens Used</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{usage.total_tokens.toLocaleString()}
</p>
</div>
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<p className="text-xs text-secondary-500 uppercase tracking-wide">Cost (Month)</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{formatCurrency(usage.total_cost)}
</p>
</div>
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<p className="text-xs text-secondary-500 uppercase tracking-wide">Avg Latency</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{Math.round(usage.avg_latency_ms)}ms
</p>
</div>
</div>
)}
</div>
</div>
{/* Configuration Form */}
<form onSubmit={handleSubmit(onSubmit)} className="card">
<div className="card-header flex items-center gap-3">
<Zap className="w-5 h-5 text-secondary-500" />
<div>
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Configuration
</h2>
<p className="text-sm text-secondary-500">
Customize AI behavior for your organization
</p>
</div>
</div>
<div className="card-body space-y-6">
{/* Enable AI */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-secondary-900 dark:text-white">Enable AI</p>
<p className="text-sm text-secondary-500">Allow AI features in your organization</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
{...register('is_enabled')}
/>
<div className="w-11 h-6 bg-secondary-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-secondary-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-secondary-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-secondary-600 peer-checked:bg-primary-500"></div>
</label>
</div>
{/* Model Selection */}
<div>
<label className="label">Default Model</label>
<select
className="input"
disabled={!isEnabled}
{...register('default_model')}
>
{models?.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.provider})
</option>
))}
</select>
<p className="text-xs text-secondary-500 mt-1">
This model will be used by default for all AI requests
</p>
</div>
{/* Temperature */}
<div>
<label className="label">Temperature: {temperature}</label>
<input
type="range"
min="0"
max="2"
step="0.1"
disabled={!isEnabled}
className="w-full h-2 bg-secondary-200 rounded-lg appearance-none cursor-pointer dark:bg-secondary-700 disabled:opacity-50"
{...register('temperature', { valueAsNumber: true })}
/>
<div className="flex justify-between text-xs text-secondary-500 mt-1">
<span>Precise (0)</span>
<span>Balanced (1)</span>
<span>Creative (2)</span>
</div>
</div>
{/* Max Tokens */}
<div>
<label className="label">Max Tokens</label>
<input
type="number"
className="input"
min="100"
max="128000"
disabled={!isEnabled}
{...register('max_tokens', { valueAsNumber: true })}
/>
<p className="text-xs text-secondary-500 mt-1">
Maximum number of tokens per response (100-128,000)
</p>
</div>
{/* System Prompt */}
<div>
<label className="label">System Prompt</label>
<textarea
className="input min-h-[100px]"
placeholder="You are a helpful assistant..."
disabled={!isEnabled}
{...register('system_prompt')}
/>
<p className="text-xs text-secondary-500 mt-1">
Custom instructions for the AI assistant
</p>
</div>
{/* Additional Settings */}
<div className="space-y-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-secondary-900 dark:text-white">Allow Custom Prompts</p>
<p className="text-sm text-secondary-500">Let users override the system prompt</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
disabled={!isEnabled}
{...register('allow_custom_prompts')}
/>
<div className="w-11 h-6 bg-secondary-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-secondary-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-secondary-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-secondary-600 peer-checked:bg-primary-500 peer-disabled:opacity-50"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-secondary-900 dark:text-white">Log Conversations</p>
<p className="text-sm text-secondary-500">Store conversation history for analytics</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
disabled={!isEnabled}
{...register('log_conversations')}
/>
<div className="w-11 h-6 bg-secondary-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-secondary-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-secondary-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-secondary-600 peer-checked:bg-primary-500 peer-disabled:opacity-50"></div>
</label>
</div>
</div>
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={!isDirty || updateConfig.isPending}
className="btn-primary"
>
{updateConfig.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</button>
</div>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,83 @@
import { Bot, User } from 'lucide-react';
import clsx from 'clsx';
export interface ChatMessageProps {
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: Date;
isLoading?: boolean;
}
export function ChatMessage({ role, content, timestamp, isLoading }: ChatMessageProps) {
const isUser = role === 'user';
const isSystem = role === 'system';
if (isSystem) {
return (
<div className="flex justify-center my-2">
<span className="text-xs text-secondary-500 bg-secondary-100 dark:bg-secondary-800 px-3 py-1 rounded-full">
{content}
</span>
</div>
);
}
return (
<div
className={clsx(
'flex gap-3 p-4',
isUser ? 'flex-row-reverse' : 'flex-row'
)}
>
{/* Avatar */}
<div
className={clsx(
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
isUser
? 'bg-primary-100 dark:bg-primary-900/30'
: 'bg-secondary-100 dark:bg-secondary-800'
)}
>
{isUser ? (
<User className="w-4 h-4 text-primary-600 dark:text-primary-400" />
) : (
<Bot className="w-4 h-4 text-secondary-600 dark:text-secondary-400" />
)}
</div>
{/* Message Content */}
<div
className={clsx(
'flex flex-col max-w-[80%]',
isUser ? 'items-end' : 'items-start'
)}
>
<div
className={clsx(
'rounded-2xl px-4 py-2',
isUser
? 'bg-primary-500 text-white rounded-tr-sm'
: 'bg-secondary-100 dark:bg-secondary-800 text-secondary-900 dark:text-white rounded-tl-sm'
)}
>
{isLoading ? (
<div className="flex items-center gap-1">
<span className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
) : (
<p className="text-sm whitespace-pre-wrap">{content}</p>
)}
</div>
{/* Timestamp */}
{timestamp && (
<span className="text-xs text-secondary-400 mt-1">
{timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,4 @@
export { AIChat } from './AIChat';
export { AISettings } from './AISettings';
export { ChatMessage } from './ChatMessage';
export type { ChatMessageProps } from './ChatMessage';

View File

@ -1,2 +1,5 @@
// Notifications // Notifications
export * from './notifications'; export * from './notifications';
// AI
export * from './ai';

View File

@ -2,3 +2,4 @@ export * from './useAuth';
export * from './useData'; export * from './useData';
export * from './useSuperadmin'; export * from './useSuperadmin';
export * from './useOnboarding'; export * from './useOnboarding';
export * from './useAI';

View File

@ -0,0 +1,134 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { aiApi, ChatRequest, ChatResponse, AIConfig, AIModel, AIUsageStats } from '@/services/api';
import { AxiosError } from 'axios';
interface ApiError {
message: string;
statusCode?: number;
}
// ==================== Query Keys ====================
export const aiQueryKeys = {
all: ['ai'] as const,
config: () => [...aiQueryKeys.all, 'config'] as const,
models: () => [...aiQueryKeys.all, 'models'] as const,
usage: (page?: number, limit?: number) => [...aiQueryKeys.all, 'usage', { page, limit }] as const,
currentUsage: () => [...aiQueryKeys.all, 'current-usage'] as const,
health: () => [...aiQueryKeys.all, 'health'] as const,
};
// ==================== Config Hooks ====================
export function useAIConfig() {
return useQuery({
queryKey: aiQueryKeys.config(),
queryFn: () => aiApi.getConfig(),
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useUpdateAIConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<AIConfig>) => aiApi.updateConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: aiQueryKeys.config() });
toast.success('AI configuration updated');
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to update AI configuration');
},
});
}
// ==================== Models Hook ====================
export function useAIModels() {
return useQuery({
queryKey: aiQueryKeys.models(),
queryFn: () => aiApi.getModels(),
staleTime: 30 * 60 * 1000, // 30 minutes - models don't change often
});
}
// ==================== Chat Hook ====================
export function useAIChat() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ChatRequest) => aiApi.chat(data),
onSuccess: () => {
// Invalidate usage queries after successful chat
queryClient.invalidateQueries({ queryKey: aiQueryKeys.currentUsage() });
queryClient.invalidateQueries({ queryKey: aiQueryKeys.usage() });
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Failed to get AI response';
toast.error(message);
},
});
}
// ==================== Usage Hooks ====================
export interface AIUsageRecord {
id: string;
model: string;
input_tokens: number;
output_tokens: number;
total_tokens: number;
cost: number;
latency_ms: number;
status: string;
created_at: string;
}
export interface AIUsageResponse {
data: AIUsageRecord[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export function useAIUsage(page = 1, limit = 10) {
return useQuery({
queryKey: aiQueryKeys.usage(page, limit),
queryFn: () => aiApi.getUsage({ page, limit }) as Promise<AIUsageResponse>,
});
}
export function useCurrentAIUsage() {
return useQuery({
queryKey: aiQueryKeys.currentUsage(),
queryFn: () => aiApi.getCurrentUsage(),
refetchInterval: 60000, // Refetch every minute
});
}
// ==================== Health Hook ====================
export interface AIHealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
provider: string;
latency_ms: number;
models_available: number;
}
export function useAIHealth() {
return useQuery({
queryKey: aiQueryKeys.health(),
queryFn: () => aiApi.getHealth() as Promise<AIHealthStatus>,
refetchInterval: 5 * 60 * 1000, // Check every 5 minutes
retry: 1,
});
}
// ==================== Re-export types ====================
export type { ChatRequest, ChatResponse, AIConfig, AIModel, AIUsageStats };

View File

@ -13,6 +13,7 @@ import {
Building2, Building2,
Shield, Shield,
BarChart3, BarChart3,
Bot,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
@ -20,6 +21,7 @@ import { NotificationBell } from '@/components/notifications';
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'AI Assistant', href: '/dashboard/ai', icon: Bot },
{ name: 'Users', href: '/dashboard/users', icon: Users }, { name: 'Users', href: '/dashboard/users', icon: Users },
{ name: 'Billing', href: '/dashboard/billing', icon: CreditCard }, { name: 'Billing', href: '/dashboard/billing', icon: CreditCard },
{ name: 'Settings', href: '/dashboard/settings', icon: Settings }, { name: 'Settings', href: '/dashboard/settings', icon: Settings },

View File

@ -0,0 +1,107 @@
import { Bot, Sparkles, TrendingUp } from 'lucide-react';
import { AIChat } from '@/components/ai';
import { useCurrentAIUsage, useAIConfig } from '@/hooks/useAI';
export function AIPage() {
const { data: usage } = useCurrentAIUsage();
const { data: config } = useAIConfig();
const formatNumber = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
AI Assistant
</h1>
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
Chat with your AI assistant powered by {config?.provider || 'OpenRouter'}
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<Bot className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<p className="text-sm text-secondary-500">Requests This Month</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{usage?.request_count?.toLocaleString() || 0}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<Sparkles className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-secondary-500">Tokens Used</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{formatNumber(usage?.total_tokens || 0)}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm text-secondary-500">Avg Response Time</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{Math.round(usage?.avg_latency_ms || 0)}ms
</p>
</div>
</div>
</div>
</div>
{/* Chat Interface */}
<AIChat
className="h-[600px]"
showModelSelector={config?.allow_custom_prompts}
placeholder="Ask me anything..."
/>
{/* Tips */}
<div className="card p-4">
<h3 className="font-medium text-secondary-900 dark:text-white mb-3">
Tips for better results
</h3>
<ul className="space-y-2 text-sm text-secondary-600 dark:text-secondary-400">
<li className="flex items-start gap-2">
<span className="text-primary-500"></span>
Be specific and provide context for your questions
</li>
<li className="flex items-start gap-2">
<span className="text-primary-500"></span>
Break complex tasks into smaller, focused requests
</li>
<li className="flex items-start gap-2">
<span className="text-primary-500"></span>
Use follow-up questions to refine responses
</li>
<li className="flex items-start gap-2">
<span className="text-primary-500"></span>
Clear the chat to start fresh conversations
</li>
</ul>
</div>
</div>
);
}

View File

@ -2,3 +2,4 @@ export * from './DashboardPage';
export * from './SettingsPage'; export * from './SettingsPage';
export * from './BillingPage'; export * from './BillingPage';
export * from './UsersPage'; export * from './UsersPage';
export * from './AIPage';

View File

@ -1,11 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { Settings, User, Bell, Shield } from 'lucide-react'; import { Settings, User, Bell, Shield, Bot } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import { GeneralSettings } from './GeneralSettings'; import { GeneralSettings } from './GeneralSettings';
import { NotificationSettings } from './NotificationSettings'; import { NotificationSettings } from './NotificationSettings';
import { SecuritySettings } from './SecuritySettings'; import { SecuritySettings } from './SecuritySettings';
import { AISettings } from '@/components/ai';
type TabKey = 'general' | 'notifications' | 'security'; type TabKey = 'general' | 'notifications' | 'security' | 'ai';
interface Tab { interface Tab {
key: TabKey; key: TabKey;
@ -33,6 +34,12 @@ const tabs: Tab[] = [
icon: Shield, icon: Shield,
component: SecuritySettings, component: SecuritySettings,
}, },
{
key: 'ai',
label: 'AI',
icon: Bot,
component: AISettings,
},
]; ];
export function SettingsPage() { export function SettingsPage() {

View File

@ -14,6 +14,7 @@ import { ForgotPasswordPage } from '@/pages/auth/ForgotPasswordPage';
import { DashboardPage } from '@/pages/dashboard/DashboardPage'; import { DashboardPage } from '@/pages/dashboard/DashboardPage';
import { BillingPage } from '@/pages/dashboard/BillingPage'; import { BillingPage } from '@/pages/dashboard/BillingPage';
import { UsersPage } from '@/pages/dashboard/UsersPage'; import { UsersPage } from '@/pages/dashboard/UsersPage';
import { AIPage } from '@/pages/dashboard/AIPage';
// Settings pages // Settings pages
import { SettingsPage } from '@/pages/settings'; import { SettingsPage } from '@/pages/settings';
@ -96,6 +97,7 @@ export function AppRouter() {
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="billing" element={<BillingPage />} /> <Route path="billing" element={<BillingPage />} />
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="ai" element={<AIPage />} />
</Route> </Route>
{/* Superadmin routes */} {/* Superadmin routes */}

View File

@ -423,6 +423,107 @@ export const superadminApi = {
}, },
}; };
// AI API
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ChatRequest {
messages: ChatMessage[];
model?: string;
temperature?: number;
max_tokens?: number;
}
export interface ChatResponse {
id: string;
model: string;
choices: {
index: number;
message: ChatMessage;
finish_reason: string;
}[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface AIConfig {
id: string;
tenant_id: string;
provider: string;
default_model: string;
fallback_model?: string;
temperature: number;
max_tokens: number;
system_prompt?: string;
is_enabled: boolean;
allow_custom_prompts: boolean;
log_conversations: boolean;
created_at: string;
updated_at: string;
}
export interface AIModel {
id: string;
name: string;
provider: string;
context_length: number;
pricing: {
prompt: number;
completion: number;
};
}
export interface AIUsageStats {
request_count: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_cost: number;
avg_latency_ms: number;
}
export const aiApi = {
chat: async (data: ChatRequest): Promise<ChatResponse> => {
const response = await api.post<ChatResponse>('/ai/chat', data);
return response.data;
},
getModels: async (): Promise<AIModel[]> => {
const response = await api.get<AIModel[]>('/ai/models');
return response.data;
},
getConfig: async (): Promise<AIConfig> => {
const response = await api.get<AIConfig>('/ai/config');
return response.data;
},
updateConfig: async (data: Partial<AIConfig>): Promise<AIConfig> => {
const response = await api.patch<AIConfig>('/ai/config', data);
return response.data;
},
getUsage: async (params?: { page?: number; limit?: number }) => {
const response = await api.get('/ai/usage', { params });
return response.data;
},
getCurrentUsage: async (): Promise<AIUsageStats> => {
const response = await api.get<AIUsageStats>('/ai/usage/current');
return response.data;
},
getHealth: async () => {
const response = await api.get('/ai/health');
return response.data;
},
};
// Export utilities // Export utilities
export { getTenantId }; export { getTenantId };
export default api; export default api;