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:
parent
40d57f8124
commit
19ead3506b
232
apps/frontend/src/components/ai/AIChat.tsx
Normal file
232
apps/frontend/src/components/ai/AIChat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
apps/frontend/src/components/ai/AISettings.tsx
Normal file
300
apps/frontend/src/components/ai/AISettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/frontend/src/components/ai/ChatMessage.tsx
Normal file
83
apps/frontend/src/components/ai/ChatMessage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
apps/frontend/src/components/ai/index.ts
Normal file
4
apps/frontend/src/components/ai/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { AIChat } from './AIChat';
|
||||||
|
export { AISettings } from './AISettings';
|
||||||
|
export { ChatMessage } from './ChatMessage';
|
||||||
|
export type { ChatMessageProps } from './ChatMessage';
|
||||||
@ -1,2 +1,5 @@
|
|||||||
// Notifications
|
// Notifications
|
||||||
export * from './notifications';
|
export * from './notifications';
|
||||||
|
|
||||||
|
// AI
|
||||||
|
export * from './ai';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
134
apps/frontend/src/hooks/useAI.ts
Normal file
134
apps/frontend/src/hooks/useAI.ts
Normal 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 };
|
||||||
@ -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 },
|
||||||
|
|||||||
107
apps/frontend/src/pages/dashboard/AIPage.tsx
Normal file
107
apps/frontend/src/pages/dashboard/AIPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user