master
Kar 2025-03-22 15:39:38 +05:30
commit c1be9d2bd2
53 changed files with 17637 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# build output
dist/
# generated types
.astro/
.vscode/
netlify.toml
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

3
README.md Normal file
View File

@ -0,0 +1,3 @@
so there chatGPT, DeepSeek
Lets build with more freedom
freedom to run on my system, connect with any other and

43
astro.config.mjs Normal file
View File

@ -0,0 +1,43 @@
import { defineConfig } from 'astro/config';
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
import { chunkSplitPlugin } from 'vite-plugin-chunk-split';
// https://astro.build/config
export default defineConfig({
output: 'static',
// Use client:only for all React components
integrations: [
react({ ssr: false }), // Disable SSR for React components
tailwind()
],
vite: {
plugins: [
chunkSplitPlugin({
strategy: 'default',
customSplitting: {
'ui-components': [/src\/components\/ui\/.*/],
'contexts': [/src\/contexts\/.*/],
'utils': [/src\/utils\/.*/],
'document-extraction': [/src\/utils\/documentExtraction.ts/],
'mongo-sync': [/src\/utils\/mongoSync.ts/],
'react-vendor': ['react', 'react-dom']
}
})
],
// Avoid hydration issues with different Node.js environments
ssr: {
noExternal: ['@radix-ui/*', 'class-variance-authority']
},
build: {
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks: {
'pdf-lib': ['pdfjs-dist']
}
}
}
}
}
});

1314
bun.lock Normal file

File diff suppressed because it is too large Load Diff

9537
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "my-chat-app",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/netlify": "^6.2.3",
"@mlc-ai/web-llm": "^0.2.78",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"astro": "^5.5.3",
"docx-parser": "^0.2.1",
"file-type": "^20.4.1",
"mammoth": "^1.9.0",
"pdfjs-dist": "^5.0.375",
"react-hook-form": "^7.54.2"
},
"devDependencies": {
"@astrojs/react": "^4.2.1",
"@astrojs/tailwind": "^6.0.0",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"autoprefixer": "10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.483.0",
"postcss": "^8.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "3",
"vite-plugin-chunk-split": "^0.5.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

9
public/favicon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@ -0,0 +1,205 @@
import React from 'react';
import { useChat, type UsageStats } from '../contexts/ChatContext';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from './ui/tabs';
export function AnalyticsDashboard() {
const {
sessionUsage,
persistentUsage,
resetSessionStats,
resetAllStats
} = useChat();
const formatNumber = (num: number) => {
return num.toLocaleString();
};
const formatCost = (tokens: number, costPerMillion: number) => {
const cost = (tokens / 1000000) * costPerMillion;
return cost.toFixed(4);
};
// Render a usage stats section
const renderUsageStats = (stats: UsageStats, title: string, averageCostPerMillion: number = 2) => {
// Calculate average cost based on a rough estimate
const estimatedCost = (stats.totalTokens / 1000000) * averageCostPerMillion;
// Get the top 3 models by usage
const topModels = Object.entries(stats.modelUsage)
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
.slice(0, 3);
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">{title}</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<h4 className="text-sm font-medium">Token Usage</h4>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span>Prompt tokens:</span>
<span>{formatNumber(stats.totalPromptTokens)}</span>
</div>
<div className="flex justify-between">
<span>Completion tokens:</span>
<span>{formatNumber(stats.totalCompletionTokens)}</span>
</div>
<div className="flex justify-between font-medium">
<span>Total tokens:</span>
<span>{formatNumber(stats.totalTokens)}</span>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Requests</h4>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span>API calls:</span>
<span>{formatNumber(stats.requestCount)}</span>
</div>
<div className="flex justify-between">
<span>Messages:</span>
<span>{formatNumber(stats.messageCount)}</span>
</div>
<div className="flex justify-between font-medium">
<span>Est. cost (USD):</span>
<span>${estimatedCost.toFixed(4)}</span>
</div>
</div>
</div>
</div>
{topModels.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Top Models</h4>
<div className="space-y-2">
{topModels.map(([modelId, usage]) => (
<div key={modelId} className="bg-muted/30 p-2 rounded-md">
<div className="flex justify-between text-sm font-medium">
<span>{modelId}</span>
<span>{formatNumber(usage.totalTokens)} tokens</span>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{usage.requestCount} requests</span>
<span>Est. cost: ${formatCost(usage.totalTokens, getCostPerMillion(modelId))}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
// Helper to get cost per million tokens based on model ID
const getCostPerMillion = (modelId: string): number => {
// Simplified pricing estimates (average of input/output costs)
const prices: Record<string, number> = {
'gpt-4o': 10,
'gpt-4-turbo': 20,
'gpt-3.5-turbo': 1,
'claude-3-opus': 45,
'claude-3-sonnet': 9,
'claude-3-haiku': 0.75,
'gemini-pro': 0.25,
'gemini-1.5-pro': 7
};
// Find the closest model name match
const match = Object.keys(prices).find(key => modelId.includes(key));
return match ? prices[match] : 2; // Default to $2 per million tokens
};
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3.5 w-3.5 mr-1"
>
<path d="M3 3v18h18" />
<path d="m7 17 4-4 4 4 5-5" />
</svg>
Analytics
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Token Usage Analytics</DialogTitle>
<DialogDescription>
Track your token usage and estimated costs across sessions.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="current">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="current">Current Session</TabsTrigger>
<TabsTrigger value="all">All-Time Stats</TabsTrigger>
</TabsList>
<TabsContent value="current" className="mt-4">
{renderUsageStats(sessionUsage, 'Current Session Stats')}
<div className="mt-4 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={resetSessionStats}
>
Reset Session Stats
</Button>
</div>
</TabsContent>
<TabsContent value="all" className="mt-4">
{renderUsageStats(persistentUsage, 'All-Time Usage Stats')}
<div className="mt-4 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={resetAllStats}
>
Reset All Stats
</Button>
</div>
</TabsContent>
</Tabs>
<div className="mt-4 text-xs text-muted-foreground">
<p>Note: Cost estimates are based on published API pricing and may not be exact.
Token counts for streaming responses are approximate.</p>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { useChat } from '../contexts/ChatContext';
import { useConfig } from '../contexts/ConfigContext';
import { Button } from './ui/button';
import { AnalyticsDashboard } from './AnalyticsDashboard';
export function AnalyticsPanel() {
const { lastTokenUsage } = useChat();
const { provider, selectedModel } = useConfig();
const [isExpanded, setIsExpanded] = useState(false);
// Define pricing per million tokens for different models (in USD)
const modelPricing: {
[key: string]: {
inputPerMillion: number;
outputPerMillion: number;
currency: string;
};
} = {
// OpenAI models
'gpt-4o': { inputPerMillion: 5, outputPerMillion: 15, currency: 'USD' },
'gpt-4-turbo': { inputPerMillion: 10, outputPerMillion: 30, currency: 'USD' },
'gpt-3.5-turbo': { inputPerMillion: 0.5, outputPerMillion: 1.5, currency: 'USD' },
// Anthropic models
'claude-3-opus-20240229': { inputPerMillion: 15, outputPerMillion: 75, currency: 'USD' },
'claude-3-sonnet-20240229': { inputPerMillion: 3, outputPerMillion: 15, currency: 'USD' },
'claude-3-haiku-20240307': { inputPerMillion: 0.25, outputPerMillion: 1.25, currency: 'USD' },
// Google models
'gemini-pro': { inputPerMillion: 0.25, outputPerMillion: 0.25, currency: 'USD' },
'gemini-1.5-pro': { inputPerMillion: 3.5, outputPerMillion: 10.5, currency: 'USD' },
// Default for unknown models
'default': { inputPerMillion: 1, outputPerMillion: 3, currency: 'USD' }
};
if (!lastTokenUsage) return null;
const { prompt, completion, total } = lastTokenUsage;
// Get pricing for the selected model, or use default
const pricing = modelPricing[selectedModel] || modelPricing.default;
const { inputPerMillion, outputPerMillion, currency } = pricing;
// Calculate costs
const promptCost = (prompt / 1000000) * inputPerMillion;
const completionCost = (completion / 1000000) * outputPerMillion;
const totalCost = promptCost + completionCost;
return (
<div className="p-2 border-t space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="text-xs font-medium">Token Usage</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Hide Details' : 'Show Details'}
</Button>
<AnalyticsDashboard />
</div>
<div className="text-xs text-muted-foreground">
Total: {total.toLocaleString()} tokens
</div>
</div>
{isExpanded && (
<div className="bg-muted/30 rounded-md p-3 space-y-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<h4 className="text-xs font-medium">Last Message</h4>
<div className="grid grid-cols-2 gap-1 text-xs">
<div>Input:</div>
<div className="text-right">{prompt.toLocaleString()} tokens</div>
<div>Output:</div>
<div className="text-right">{completion.toLocaleString()} tokens</div>
<div>Total:</div>
<div className="text-right">{total.toLocaleString()} tokens</div>
</div>
</div>
<div className="space-y-1">
<h4 className="text-xs font-medium">Estimated Cost</h4>
<div className="grid grid-cols-2 gap-1 text-xs">
<div>Input:</div>
<div className="text-right">{currency} {promptCost.toFixed(6)}</div>
<div>Output:</div>
<div className="text-right">{currency} {completionCost.toFixed(6)}</div>
<div>Total:</div>
<div className="text-right">{currency} {totalCost.toFixed(6)}</div>
</div>
</div>
</div>
<div className="text-xs text-muted-foreground mt-2">
<p>Pricing: {currency} {inputPerMillion} / {currency} {outputPerMillion} per million tokens (input/output)</p>
<p className="mt-1">Note: View detailed analytics by clicking the Analytics button.</p>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,45 @@
import React, { useState, useEffect } from 'react';
import { ConfigProvider } from '../contexts/ConfigContext';
import { ChatProvider } from '../contexts/ChatContext';
import { ThemeProvider } from '../contexts/ThemeContext';
import { SyncProvider } from '../contexts/SyncContext';
import { ChatInterface } from './ChatInterface';
import { Toaster } from './ui/toaster';
export function AppRoot() {
// Use client-side only rendering for components that depend on browser APIs
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
// This effect runs only in the browser, not during SSR
setIsMounted(true);
}, []);
// Return a minimal loading state until client-side code can take over
if (!isMounted) {
return (
<div className="h-screen flex items-center justify-center bg-background text-foreground">
<div className="animate-pulse flex flex-col items-center">
<div className="h-8 w-40 bg-muted rounded-md mb-4"></div>
<div className="h-4 w-60 bg-muted rounded-md"></div>
</div>
</div>
);
}
// Actual component rendering only happens client-side
return (
<div id="app-root" className="h-screen">
<ThemeProvider>
<ConfigProvider>
<ChatProvider>
<SyncProvider>
<ChatInterface />
</SyncProvider>
</ChatProvider>
<Toaster />
</ConfigProvider>
</ThemeProvider>
</div>
);
}

View File

@ -0,0 +1,412 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { useChat } from '../contexts/ChatContext';
import { useConfig } from '../contexts/ConfigContext';
import { FileUpload, type UploadedFile } from './FileUpload';
import { FilePreview } from './FilePreview';
import { VoiceInput } from './VoiceInput';
import { extractTextFromFile } from '../utils/documentExtraction';
import { useToast } from './ui/use-toast';
export function ChatInput() {
// Initialize with default values in case context isn't ready yet
const [firstRole, setFirstRole] = useState<string>('user');
const [firstContent, setFirstContent] = useState<string>('');
const [secondRole, setSecondRole] = useState<string>('developer');
const [secondContent, setSecondContent] = useState<string>('');
const [attachedFiles, setAttachedFiles] = useState<UploadedFile[]>([]);
const [processingDocument, setProcessingDocument] = useState(false);
const [activeInput, setActiveInput] = useState<'first' | 'second'>('first');
const { toast } = useToast();
// Safely access contexts
let chatContext, configContext;
try {
chatContext = useChat();
configContext = useConfig();
} catch (err) {
console.log("Contexts not ready yet in ChatInput");
// Return disabled form if contexts aren't available
return (
<div className="p-4 border-t">
<div className="opacity-50">
<div className="flex flex-col space-y-4 pointer-events-none">
<div className="flex flex-col space-y-2">
<div className="h-9 bg-muted rounded-md animate-pulse"></div>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<div className="h-9 bg-muted rounded-md animate-pulse"></div>
<div className="h-9 bg-muted rounded-md animate-pulse"></div>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<div className="h-9 bg-muted rounded-md animate-pulse"></div>
<div className="h-9 bg-muted rounded-md animate-pulse"></div>
</div>
</div>
<div className="flex justify-end mt-4">
<div className="h-9 w-20 bg-muted rounded-md animate-pulse"></div>
</div>
</div>
</div>
);
}
const { sendMessage, isLoading } = chatContext;
const { provider, selectedModel, setSelectedModel, availableModels } = configContext;
// Handle document text extraction button click
const handleExtractAndSend = async () => {
// Check if there are any document files attached
const documentFiles = attachedFiles.filter(file =>
file.type === 'application/pdf' ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
file.type.startsWith('text/') ||
['pdf', 'docx', 'txt', 'md', 'csv', 'json'].some(ext => file.name.toLowerCase().endsWith(`.${ext}`))
);
if (documentFiles.length === 0) {
toast({
title: "No Documents Found",
description: "Please attach a document file (PDF, DOCX, TXT, etc.)",
variant: "destructive",
});
return;
}
setProcessingDocument(true);
try {
// Generate message about the document
let extractedContent = "";
// Process each document file
for (const file of documentFiles) {
if (file.extractedText) {
// If text was already extracted during upload, use that
extractedContent += `Content from ${file.name}:\n\n${file.extractedText}\n\n`;
} else {
// Try to extract text from file now
try {
// We need to convert the dataURL back to a File object
const fileData = await dataURLtoFile(file.content as string, file.name, file.type);
const extractionResult = await extractTextFromFile(fileData);
if (extractionResult.text) {
extractedContent += `Content from ${file.name}:\n\n${extractionResult.text}\n\n`;
// Update the file with extracted text
file.extractedText = extractionResult.text;
file.extractionResult = extractionResult;
} else if (extractionResult.error) {
extractedContent += `Failed to extract text from ${file.name}: ${extractionResult.error}\n\n`;
}
} catch (extractError) {
console.error('Error extracting text:', extractError);
extractedContent += `Error extracting text from ${file.name}: ${
extractError instanceof Error ? extractError.message : "Unknown error"
}\n\n`;
}
}
}
// Create message with prompt and extracted content
const messageContent = firstContent.trim()
? `${firstContent.trim()}\n\n${extractedContent}`
: `Please analyze the following document:\n\n${extractedContent}`;
// Send message with the document content
sendMessage(
firstRole,
messageContent,
secondRole,
secondContent.trim(),
attachedFiles
);
// Clear inputs after sending
setFirstContent('');
setSecondContent('');
setAttachedFiles([]);
} catch (error) {
console.error('Error processing document:', error);
toast({
title: "Document Processing Failed",
description: error instanceof Error ? error.message : "Failed to process document",
variant: "destructive",
});
} finally {
setProcessingDocument(false);
}
};
// Helper function to convert data URL to File
const dataURLtoFile = async (dataURL: string, filename: string, mimeType: string): Promise<File> => {
const res: Response = await fetch(dataURL);
const blob: Blob = await res.blob();
return new File([blob], filename, { type: mimeType });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!firstContent.trim() && !secondContent.trim()) return;
sendMessage(
firstRole,
firstContent.trim(),
secondRole,
secondContent.trim(),
attachedFiles.length > 0 ? attachedFiles : undefined
);
// Clear inputs after sending
setFirstContent('');
setSecondContent('');
setAttachedFiles([]);
};
const handleFileUpload = (file: UploadedFile) => {
setAttachedFiles(prev => [...prev, file]);
};
const handleFileRemove = (fileId: string) => {
setAttachedFiles(prev => prev.filter(file => file.id !== fileId));
};
// Handle voice input transcript
const handleVoiceTranscript = (transcript: string) => {
if (activeInput === 'first') {
setFirstContent(prev => {
// Add space if there's already content and transcript doesn't start with space
const needsSpace = prev.length > 0 && !prev.endsWith(' ') && !transcript.startsWith(' ');
return prev + (needsSpace ? ' ' : '') + transcript;
});
} else {
setSecondContent(prev => {
const needsSpace = prev.length > 0 && !prev.endsWith(' ') && !transcript.startsWith(' ');
return prev + (needsSpace ? ' ' : '') + transcript;
});
}
// Show toast to confirm transcript
toast({
title: "Voice Transcript Added",
description: transcript,
});
};
// Track which input is active for voice input
const handleFirstInputFocus = () => setActiveInput('first');
const handleSecondInputFocus = () => setActiveInput('second');
// Provider-specific role handling
const getRoleOptions = () => {
// Claude doesn't support developer role, remap to user
if (provider === 'anthropic') {
return [
{ value: 'user', label: 'User' },
{ value: 'system', label: 'System' },
];
}
// Google has model instead of assistant
else if (provider === 'google') {
return [
{ value: 'user', label: 'User' },
{ value: 'system', label: 'System' },
];
}
// Default for OpenAI and others
return [
{ value: 'user', label: 'User' },
{ value: 'developer', label: 'Developer' },
{ value: 'system', label: 'System' },
];
};
// Provider-specific guidance
const getProviderGuidance = () => {
switch (provider) {
case 'anthropic':
return "Claude supports 'user' and 'system' roles only. System prompts will be sent as Claude system instructions.";
case 'google':
return "Gemini supports 'user' and 'system' roles. Developer inputs are treated as user inputs.";
default:
return "";
}
};
const providerGuidance = getProviderGuidance();
const roleOptions = getRoleOptions();
// Check if there are document files attached
const hasDocumentFiles = attachedFiles.some(file =>
file.type === 'application/pdf' ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
file.type.startsWith('text/') ||
['pdf', 'docx', 'txt', 'md', 'csv', 'json'].some(ext => file.name.toLowerCase().endsWith(`.${ext}`))
);
return (
<form onSubmit={handleSubmit} className="space-y-4 p-4 border-t">
<div className="flex flex-col space-y-4">
{/* Model selection */}
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">Model</label>
<Select
value={selectedModel}
onValueChange={setSelectedModel}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Provider-specific guidance */}
{providerGuidance && (
<div className="text-xs text-muted-foreground p-2 bg-muted/40 rounded">
<p>{providerGuidance}</p>
</div>
)}
{/* File upload section */}
<div className="space-y-2">
{attachedFiles.length > 0 && (
<div className="space-y-2">
{attachedFiles.map(file => (
<FilePreview
key={file.id}
file={file}
onRemove={handleFileRemove}
hasExtractedText={!!file.extractedText}
/>
))}
</div>
)}
</div>
{/* First input (usually user) */}
<div className="grid grid-cols-[120px_1fr] gap-2">
<Select
value={firstRole}
onValueChange={setFirstRole}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
{roleOptions.map(role => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Enter your message..."
value={firstContent}
onChange={(e) => setFirstContent(e.target.value)}
disabled={isLoading}
onFocus={handleFirstInputFocus}
/>
</div>
{/* Second input */}
<div className="grid grid-cols-[120px_1fr] gap-2">
<Select
value={secondRole}
onValueChange={setSecondRole}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
{roleOptions.map(role => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Optional second message..."
value={secondContent}
onChange={(e) => setSecondContent(e.target.value)}
disabled={isLoading}
onFocus={handleSecondInputFocus}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileUpload onFileUploaded={handleFileUpload} />
{/* Voice input button */}
<VoiceInput
onTranscript={handleVoiceTranscript}
isDisabled={isLoading}
/>
{hasDocumentFiles && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExtractAndSend}
disabled={isLoading || processingDocument}
className="flex items-center gap-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
<path d="M10 9H8" />
</svg>
{processingDocument ? 'Processing...' : 'Extract & Send Document'}
</Button>
)}
</div>
<Button
type="submit"
disabled={isLoading || (!firstContent.trim() && !secondContent.trim())}
>
{isLoading ? 'Sending...' : 'Send'}
</Button>
</div>
<div className="text-xs text-muted-foreground text-center">
<p>Voice input is targeting "{activeInput === 'first' ? 'first' : 'second'}" message. Click in an input field to select it.</p>
</div>
</form>
);
}

View File

@ -0,0 +1,192 @@
import React, { useState, useEffect } from 'react';
import { useChat } from '../contexts/ChatContext';
import { useConfig } from '../contexts/ConfigContext';
import { ConfigDialog } from './ConfigDialog';
import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { AnalyticsPanel } from './AnalyticsPanel';
import { ProviderInfo } from './ProviderInfo';
import { ThemeToggle } from './ThemeToggle';
import { SyncDialog } from './SyncDialog';
import { WebLLMStatus } from './WebLLMStatus';
import { Button } from './ui/button';
import { useToast } from './ui/use-toast';
export function ChatInterface() {
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
// Safely access the chat context
let chatContext, configContext;
try {
chatContext = useChat();
configContext = useConfig();
// If we get here, context is available
if (!isReady) setIsReady(true);
} catch (err) {
if (!error) {
console.error("Error accessing Chat context:", err);
setError("Error loading chat. Please refresh the page.");
}
// Return loading state if context isn't ready
return (
<div className="flex flex-col h-screen items-center justify-center">
<div className="text-center p-4">
<p className="text-muted-foreground">{error || "Loading chat interface..."}</p>
{error && (
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => window.location.reload()}
>
Refresh Page
</Button>
)}
</div>
</div>
);
}
// Destructure context values only if context is available
const {
messages,
lastTokenUsage,
clearChat,
isLoading,
isStreaming,
streamingText,
editMessage,
deleteMessage
} = chatContext;
const { provider } = configContext;
// Handle message edit
const handleEditMessage = async (id: string, newContent: string) => {
try {
await editMessage(id, newContent);
toast({
title: "Message Updated",
description: "Your message has been updated successfully.",
});
} catch (error) {
console.error('Error editing message:', error);
toast({
title: "Edit Failed",
description: error instanceof Error ? error.message : "Failed to edit message",
variant: "destructive",
});
}
};
// Handle message delete
const handleDeleteMessage = async (id: string) => {
try {
await deleteMessage(id);
toast({
title: "Message Deleted",
description: "Your message has been deleted.",
});
} catch (error) {
console.error('Error deleting message:', error);
toast({
title: "Delete Failed",
description: error instanceof Error ? error.message : "Failed to delete message",
variant: "destructive",
});
}
};
return (
<div className="flex flex-col h-screen">
<header className="py-3 px-4 border-b flex justify-between items-center">
<div className="flex flex-col">
<h1 className="text-xl font-bold">AI Chat Interface</h1>
<ProviderInfo />
</div>
<div className="flex items-center gap-2">
<SyncDialog />
<ThemeToggle />
<Button
variant="outline"
size="sm"
onClick={clearChat}
disabled={messages.length === 0 || isLoading}
>
Clear Chat
</Button>
<ConfigDialog />
</div>
</header>
<main className="flex-1 overflow-y-auto p-4">
{/* Show WebLLM Status for browser-based models */}
<WebLLMStatus />
{messages.length === 0 && !isStreaming ? (
<div className="h-full flex flex-col items-center justify-center text-center p-4">
<div className="max-w-md space-y-4">
<h2 className="text-2xl font-bold">Welcome to AI Chat</h2>
<p className="text-muted-foreground">
Configure your provider settings by clicking the gear icon in the
top right, then start chatting below.
</p>
{provider === 'webllm' && (
<div className="bg-blue-50 dark:bg-blue-950 p-4 rounded-lg border border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200 text-sm">
<p className="font-medium mb-2">Using WebLLM (Browser-Based AI)</p>
<p>This model runs 100% in your browser. Your data never leaves your device. The first use will download the model (could be 1-2GB).</p>
</div>
)}
</div>
</div>
) : (
<div className="space-y-4">
{/* Display existing messages */}
{messages.map((message) => (
<ChatMessage
key={message.id}
id={message.id}
role={message.role}
content={message.content}
timestamp={message.timestamp}
files={message.files}
onEdit={handleEditMessage}
onDelete={handleDeleteMessage}
isEditable={message.role !== 'assistant'}
/>
))}
{/* Display streaming message */}
{isStreaming && streamingText && (
<div className="animate-in fade-in-0">
<ChatMessage
role="assistant"
content={streamingText}
id="streaming"
timestamp={Date.now()}
isEditable={false}
/>
</div>
)}
{/* Show loading indicator */}
{isLoading && !isStreaming && (
<div className="flex justify-center py-4">
<div className="animate-pulse text-muted-foreground">
Thinking...
</div>
</div>
)}
</div>
)}
</main>
{lastTokenUsage && <AnalyticsPanel />}
<ChatInput />
</div>
);
}

View File

@ -0,0 +1,335 @@
import React, { useState, useRef, useEffect } from 'react';
import type { UploadedFile } from './FileUpload';
import { Textarea } from './ui/textarea';
import { Button } from './ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from './ui/dropdown-menu';
interface ChatMessageProps {
id?: string;
role: string;
content: string;
timestamp?: number;
files?: UploadedFile[];
onEdit?: (id: string, newContent: string) => Promise<void>;
onDelete?: (id: string) => Promise<void>;
isEditable?: boolean;
}
export function ChatMessage({
id,
role,
content,
timestamp,
files,
onEdit,
onDelete,
isEditable = true
}: ChatMessageProps) {
const [isEditing, setIsEditing] = useState(false);
const [editedContent, setEditedContent] = useState(content);
const [isDeleting, setIsDeleting] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// When editing starts, focus the textarea and move cursor to end
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length
);
}
}, [isEditing]);
// Adjust textarea height as content changes
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
}
}, [editedContent, isEditing]);
// Format the content with line breaks
const formattedContent = content.split('\n').map((line, i) => (
<React.Fragment key={i}>
{line}
{i < content.split('\n').length - 1 && <br />}
</React.Fragment>
));
// Format timestamp if available
const formattedTime = timestamp ? new Date(timestamp).toLocaleString() : '';
// Determine role display text
const getRoleDisplay = (role: string) => {
switch (role.toLowerCase()) {
case 'user':
return 'User';
case 'assistant':
return 'Assistant';
case 'system':
return 'System';
case 'developer':
return 'Developer';
default:
return role.charAt(0).toUpperCase() + role.slice(1);
}
};
// Handle editing submission
const handleEditSubmit = async () => {
if (!id || !onEdit) return;
try {
await onEdit(id, editedContent);
setIsEditing(false);
} catch (error) {
console.error('Error editing message:', error);
// Keep editing mode active if there's an error
}
};
// Handle message deletion
const handleDelete = async () => {
if (!id || !onDelete) return;
setIsDeleting(true);
try {
await onDelete(id);
// The parent component will remove this message from the UI
} catch (error) {
console.error('Error deleting message:', error);
setIsDeleting(false);
}
};
// Handle pressing Enter in the textarea
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleEditSubmit();
}
};
// Auto-resize textarea on input
const handleTextareaInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditedContent(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
};
return (
<div className={`p-4 rounded-lg ${
role === 'user' ? 'bg-muted/30' :
role === 'system' ? 'bg-amber-500/10 border border-amber-200/20' :
role === 'assistant' ? 'bg-primary/10 border border-primary/20' :
role === 'developer' ? 'bg-fuchsia-500/10 border border-fuchsia-200/20' :
'bg-muted/20'
}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`px-2 py-1 text-xs rounded-md ${
role === 'user' ? 'bg-muted text-muted-foreground' :
role === 'system' ? 'bg-amber-500/20 text-amber-700 dark:text-amber-300' :
role === 'assistant' ? 'bg-primary/20 text-primary dark:text-primary-foreground' :
role === 'developer' ? 'bg-fuchsia-500/20 text-fuchsia-700 dark:text-fuchsia-300' :
'bg-muted text-muted-foreground'
}`}>
{getRoleDisplay(role)}
</span>
{timestamp && (
<span className="text-xs text-muted-foreground">
{formattedTime}
</span>
)}
</div>
{isEditable && id && (onEdit || onDelete) && (
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
disabled={isEditing || isDeleting}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEdit && role !== 'assistant' && (
<DropdownMenuItem
onClick={() => {
setIsEditing(true);
setIsMenuOpen(false);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={handleDelete}
className="text-red-500 focus:text-red-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="prose prose-sm dark:prose-invert max-w-none">
{isEditing ? (
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={editedContent}
onChange={handleTextareaInput}
onKeyDown={handleKeyDown}
className="min-h-[100px] resize-none"
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditing(false);
setEditedContent(content); // Reset to original
}}
>
Cancel
</Button>
<Button
variant="default"
size="sm"
onClick={handleEditSubmit}
>
Save
</Button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap">
{formattedContent}
</div>
)}
</div>
{/* If there are file attachments, show them */}
{files && files.length > 0 && (
<div className="mt-3 space-y-2">
{files.map((file) => (
<div key={file.id} className="mt-2">
{file.type.startsWith('image/') && typeof file.content === 'string' ? (
<a
href={file.content}
target="_blank"
rel="noopener noreferrer"
className="inline-block"
>
<img
src={file.content}
alt={file.name}
className="max-h-60 max-w-full rounded-md border border-muted"
/>
</a>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span className="font-medium">{file.name}</span>
<span>({formatFileSize(file.size)})</span>
</div>
)}
</div>
))}
</div>
)}
{/* Loading indicator for delete operation */}
{isDeleting && (
<div className="absolute inset-0 bg-background/80 flex items-center justify-center rounded-lg">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span className="text-sm">Deleting...</span>
</div>
</div>
)}
</div>
);
}
// Helper function to format file size
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' bytes';
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
else return (bytes / 1048576).toFixed(1) + ' MB';
}

View File

@ -0,0 +1,224 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription,
} from './ui/dialog';
import { Button } from './ui/button';
import {
FormItem,
FormLabel,
FormControl,
FormDescription,
} from './ui/form';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from './ui/tabs';
import { Input } from './ui/input';
import { Switch } from './ui/switch';
import { useConfig, type Provider } from '../contexts/ConfigContext';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from './ui/select';
export function ConfigDialog() {
const [open, setOpen] = useState(false);
const {
provider,
setProvider,
apiKey,
setApiKey,
endpoint,
setEndpoint,
selectedModel,
setSelectedModel,
availableModels,
streamingEnabled,
setStreamingEnabled,
isWebLLMAvailable,
webLLMStatusMessage
} = useConfig();
const handleProviderChange = (newProvider: Provider) => {
setProvider(newProvider);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span className="sr-only">Settings</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Configuration</DialogTitle>
<DialogDescription>
Configure your AI provider and settings
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="provider">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="provider">Provider</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<TabsContent value="provider" className="space-y-4 mt-4">
<div className="space-y-4">
<FormItem>
<FormLabel>Provider</FormLabel>
<Select
value={provider}
onValueChange={(value) => handleProviderChange(value as Provider)}
>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>API Providers</SelectLabel>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic (Claude)</SelectItem>
<SelectItem value="google">Google (Gemini)</SelectItem>
<SelectItem value="deepseek">DeepSeek</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Browser-based Providers</SelectLabel>
<SelectItem
value="webllm"
disabled={!isWebLLMAvailable}
>
WebLLM (Local Browser)
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{provider === 'webllm' && (
<div className="text-sm mt-2 text-muted-foreground">
<p>{webLLMStatusMessage}</p>
{isWebLLMAvailable && (
<p className="mt-1">
WebLLM runs 100% in your browser. This provides complete privacy
as your data never leaves your device.
</p>
)}
</div>
)}
</FormItem>
{provider !== 'webllm' && (
<FormItem>
<FormLabel>API Key</FormLabel>
<Input
type="password"
placeholder="Enter your API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<FormDescription>
Your API key for the selected provider
</FormDescription>
</FormItem>
)}
{provider === 'custom' && (
<FormItem>
<FormLabel>Endpoint URL</FormLabel>
<Input
type="text"
placeholder="Enter API endpoint URL"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
/>
<FormDescription>
The API endpoint for your custom provider
</FormDescription>
</FormItem>
)}
<FormItem>
<FormLabel>Model</FormLabel>
<Select
value={selectedModel}
onValueChange={setSelectedModel}
disabled={availableModels.length === 0}
>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
{model.maxTokens && ` (${model.maxTokens} tokens)`}
</SelectItem>
))}
</SelectContent>
</Select>
{availableModels.length === 0 && (
<FormDescription>
No models available for this provider
</FormDescription>
)}
</FormItem>
</div>
</TabsContent>
<TabsContent value="advanced" className="space-y-4 mt-4">
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Streaming Responses
</FormLabel>
<FormDescription>
Show responses as they are generated
</FormDescription>
</div>
<FormControl>
<Switch
checked={streamingEnabled}
onCheckedChange={setStreamingEnabled}
/>
</FormControl>
</FormItem>
</TabsContent>
</Tabs>
<DialogFooter>
<Button onClick={() => setOpen(false)}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,182 @@
import React, { useState } from 'react';
import type { UploadedFile } from './FileUpload';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from './ui/dialog';
interface FilePreviewProps {
file: UploadedFile;
onRemove: (fileId: string) => void;
hasExtractedText?: boolean;
}
export function FilePreview({ file, onRemove, hasExtractedText = false }: FilePreviewProps) {
const [textPreviewOpen, setTextPreviewOpen] = useState(false);
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' bytes';
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
else return (bytes / 1048576).toFixed(1) + ' MB';
};
// Determine file type icon
const getFileIcon = () => {
if (file.type.startsWith('image/')) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
} else if (file.type === 'application/pdf') {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
} else if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
file.name.toLowerCase().endsWith('.docx')) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<path d="M8 16h8" />
<path d="M8 12h8" />
</svg>
);
} else if (file.type === 'text/csv' || file.name.toLowerCase().endsWith('.csv')) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<path d="M8 13h2" />
<path d="M8 17h2" />
<path d="M14 13h2" />
<path d="M14 17h2" />
</svg>
);
} else if (file.type.startsWith('text/') ||
['txt', 'md', 'json', 'xml', 'html', 'js', 'ts', 'css'].some(ext => file.name.toLowerCase().endsWith(`.${ext}`))) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
<path d="M10 9H8" />
</svg>
);
} else {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
}
};
// Render image preview if it's an image
const renderPreview = () => {
if (file.type.startsWith('image/') && typeof file.content === 'string') {
return (
<div className="mt-2 rounded-md overflow-hidden border border-gray-200 dark:border-gray-700">
<img
src={file.content}
alt={file.name}
className="max-h-48 w-auto object-contain"
/>
</div>
);
}
return null;
};
// Format the extracted text preview (truncated)
const formatTextPreview = (text: string, maxLength: number = 1000) => {
if (!text) return '';
const truncated = text.length > maxLength;
const preview = truncated ? text.substring(0, maxLength) + '...' : text;
// Replace newlines with <br> for HTML display
return preview.replace(/\n/g, '<br>');
};
return (
<div className="rounded-md border border-gray-200 dark:border-gray-700 p-3 mb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative">
{getFileIcon()}
{hasExtractedText && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-green-500"></div>
)}
</div>
<div>
<div className="font-medium text-sm flex items-center gap-1">
{file.name}
{hasExtractedText && (
<Dialog open={textPreviewOpen} onOpenChange={setTextPreviewOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 py-0 text-xs"
>
View Text
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Extracted Text from {file.name}</DialogTitle>
<DialogDescription>
{file.extractionResult?.charCount.toLocaleString()} characters extracted
{file.extractionResult?.truncated && " (truncated)"}
</DialogDescription>
</DialogHeader>
<div className="mt-2 max-h-[400px] overflow-y-auto border rounded-md p-4 text-sm whitespace-pre-wrap">
{file.extractedText}
</div>
<DialogFooter className="gap-2 mt-2">
<Button
variant="outline"
onClick={() => setTextPreviewOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
<div className="text-xs text-gray-500">{formatFileSize(file.size)}</div>
</div>
</div>
<button
onClick={() => onRemove(file.id)}
className="text-gray-500 hover:text-gray-700"
aria-label="Remove file"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
{renderPreview()}
</div>
);
}

View File

@ -0,0 +1,189 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { useConfig } from '../contexts/ConfigContext';
import { useToast } from '../components/ui/use-toast';
import { extractTextFromFile, type ExtractionResult } from '../utils/documentExtraction';
export interface UploadedFile {
id: string;
name: string;
type: string;
size: number;
content: string | ArrayBuffer | null;
extractedText?: string;
extractionResult?: ExtractionResult;
}
interface FileUploadProps {
onFileUploaded: (file: UploadedFile) => void;
}
export function FileUpload({ onFileUploaded }: FileUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const { provider } = useConfig();
const { toast } = useToast();
// Currently OpenAI has good support for file analysis
const supportsFileAnalysis = provider === 'openai';
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
setIsUploading(true);
try {
// Check if document is extractable
const isExtractable = isDocumentExtractable(file);
// Read the file
const fileContent = await readFileAsync(file);
// Generate a unique ID
const fileId = `file-${Date.now()}`;
// Create file object
const uploadedFile: UploadedFile = {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
content: fileContent
};
// If it's a document type that we can extract text from, do that
if (isExtractable) {
try {
// Extract text from file
const extractionResult = await extractTextFromFile(file);
if (extractionResult.text) {
uploadedFile.extractedText = extractionResult.text;
uploadedFile.extractionResult = extractionResult;
// Show success toast
toast({
title: "Document Text Extracted",
description: `Extracted ${extractionResult.charCount.toLocaleString()} characters from ${file.name}`,
});
} else if (extractionResult.error) {
toast({
title: "Text Extraction Failed",
description: extractionResult.error,
variant: "destructive",
});
}
} catch (extractError) {
console.error('Error during text extraction:', extractError);
toast({
title: "Text Extraction Error",
description: extractError instanceof Error ? extractError.message : "Failed to extract text from document",
variant: "destructive",
});
}
}
// Notify parent component
onFileUploaded(uploadedFile);
// Reset the input
e.target.value = '';
} catch (error) {
console.error('Error uploading file:', error);
toast({
title: "Upload Failed",
description: error instanceof Error ? error.message : "Failed to upload file",
variant: "destructive",
});
} finally {
setIsUploading(false);
}
};
// Helper function to check if file is a document type we can extract text from
const isDocumentExtractable = (file: File): boolean => {
const fileName = file.name.toLowerCase();
const fileType = file.type.toLowerCase();
// Check if it's a PDF
if (fileType === 'application/pdf' || fileName.endsWith('.pdf')) {
return true;
}
// Check if it's a DOCX
if (fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
fileName.endsWith('.docx')) {
return true;
}
// Check if it's a text file
if (fileType.startsWith('text/') ||
['txt', 'md', 'csv', 'json', 'xml', 'html', 'js', 'ts', 'css'].some(ext => fileName.endsWith(`.${ext}`))) {
return true;
}
return false;
};
// Helper function to read file as data URL
const readFileAsync = (file: File): Promise<string | ArrayBuffer | null> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsDataURL(file);
});
};
if (!supportsFileAnalysis) {
return null;
}
return (
<div className="flex items-center gap-2">
<Input
type="file"
id="file-upload"
className="hidden"
onChange={handleFileChange}
disabled={isUploading}
accept=".txt,.pdf,.doc,.docx,.csv,.json,.jpg,.jpeg,.png,.md,.xml,.html"
/>
<Button
variant="outline"
size="sm"
onClick={() => document.getElementById('file-upload')?.click()}
disabled={isUploading}
className="flex items-center gap-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M12 12v6" />
<path d="m15 15-3-3-3 3" />
</svg>
{isUploading ? 'Uploading...' : 'Attach File'}
</Button>
</div>
);
}

View File

@ -0,0 +1,30 @@
import React from 'react';
import { useConfig } from '../contexts/ConfigContext';
export function ProviderInfo() {
try {
const { provider, selectedModel, availableModels } = useConfig();
// Get the display name of the selected model
const modelName = availableModels.find(m => m.id === selectedModel)?.name || selectedModel;
// Capitalize provider name
const providerDisplayName = provider.charAt(0).toUpperCase() + provider.slice(1);
return (
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium">Provider:</span>
<span>{providerDisplayName}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Model:</span>
<span>{modelName}</span>
</div>
</div>
);
} catch (error) {
// Return empty div if context isn't available yet
return <div className="text-xs text-muted-foreground">Loading provider info...</div>;
}
}

View File

@ -0,0 +1,254 @@
import React, { useState } from 'react';
import { useSync } from '../contexts/SyncContext';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from './ui/select';
export function SyncDialog() {
const {
syncConfig,
setSyncConfig,
isConnected,
isSyncing,
syncStatus,
lastSyncTime,
connectToMongo,
manualSync,
toggleSyncEnabled,
savedConversations,
loadConversation,
createNewConversation
} = useSync();
const [isOpen, setIsOpen] = useState(false);
const [serverUrl, setServerUrl] = useState(syncConfig.serverUrl);
const [apiKey, setApiKey] = useState(syncConfig.apiKey || '');
const [userId, setUserId] = useState(syncConfig.userId || '');
const [syncFrequency, setSyncFrequency] = useState<'realtime' | 'manual' | 'periodic'>(
syncConfig.syncFrequency
);
const handleSaveConfig = () => {
setSyncConfig({
...syncConfig,
serverUrl,
apiKey: apiKey || undefined,
userId: userId || undefined,
syncFrequency
});
// If connected, sync changes
if (isConnected) {
manualSync();
}
};
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="relative"
onClick={() => setIsOpen(true)}
>
{isConnected && (
<span className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-green-500">
<span className="absolute inset-0 h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
</span>
)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c-1.5 0-3-4-3-9s1.5-9 3-9m0 18c1.5 0 3-4 3-9s-1.5-9-3-9" />
</svg>
MongoDB Sync
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>MongoDB Synchronization</DialogTitle>
<DialogDescription>
Configure synchronization with a MongoDB server to save your chat history and stats.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<div className="font-medium">Sync Status</div>
<div className="flex items-center gap-2">
{isConnected ? (
<span className="inline-block h-2 w-2 rounded-full bg-green-500"></span>
) : (
<span className="inline-block h-2 w-2 rounded-full bg-gray-300"></span>
)}
<span className="text-sm text-muted-foreground">{syncStatus}</span>
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
id="sync-enabled"
checked={syncConfig.syncEnabled}
onCheckedChange={toggleSyncEnabled}
disabled={isSyncing}
/>
<Label htmlFor="sync-enabled">Enable MongoDB Sync</Label>
</div>
<div className="space-y-2">
<Label htmlFor="server-url">MongoDB API Server URL</Label>
<Input
id="server-url"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="https://your-mongodb-api-server.com"
disabled={isSyncing}
/>
</div>
<div className="space-y-2">
<Label htmlFor="api-key">API Key (Optional)</Label>
<Input
id="api-key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Your API key"
type="password"
disabled={isSyncing}
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-id">User ID (Optional)</Label>
<Input
id="user-id"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Your user ID for multi-user sync"
disabled={isSyncing}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sync-frequency">Sync Frequency</Label>
<Select
value={syncFrequency}
onValueChange={(value) => setSyncFrequency(value as 'realtime' | 'manual' | 'periodic')}
disabled={isSyncing}
>
<SelectTrigger id="sync-frequency">
<SelectValue placeholder="Select sync frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="realtime">Real-time</SelectItem>
<SelectItem value="periodic">Periodic (every 5min)</SelectItem>
<SelectItem value="manual">Manual only</SelectItem>
</SelectContent>
</Select>
</div>
{lastSyncTime && (
<div className="text-sm text-muted-foreground">
Last synced: {formatTime(lastSyncTime)}
</div>
)}
{/* Saved conversations section */}
{syncConfig.syncEnabled && savedConversations.length > 0 && (
<div className="border rounded-md p-3 space-y-2">
<h3 className="text-sm font-medium">Saved Conversations</h3>
<div className="max-h-[200px] overflow-y-auto space-y-2">
{savedConversations.map(conv => (
<div
key={conv.id}
className="flex justify-between items-center text-sm p-2 border rounded-md cursor-pointer hover:bg-accent"
onClick={() => {
loadConversation(conv.id);
setIsOpen(false);
}}
>
<div className="truncate flex-1">
<div className="font-medium">{conv.title || 'Untitled'}</div>
<div className="text-xs text-muted-foreground truncate">{conv.lastMessage}</div>
</div>
<div className="text-xs text-muted-foreground">
{formatTime(conv.timestamp)}
</div>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={createNewConversation}
disabled={isSyncing}
>
New Conversation
</Button>
<Button
variant="outline"
size="sm"
onClick={() => manualSync()}
disabled={!syncConfig.syncEnabled || isSyncing}
>
{isSyncing ? 'Syncing...' : 'Sync Now'}
</Button>
</div>
<div className="flex gap-2">
<Button
variant="default"
onClick={() => {
handleSaveConfig();
if (syncConfig.syncEnabled && !isConnected) {
connectToMongo();
}
}}
disabled={isSyncing}
>
Save Settings
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,125 @@
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
import { Button } from './ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 px-0">
{theme === 'light' ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-[1.2rem] w-[1.2rem]"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-[1.2rem] w-[1.2rem]"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
)}
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,25 @@
import React from 'react';
interface TokenUsageProps {
prompt: number;
completion: number;
total: number;
}
export function TokenUsage({ prompt, completion, total }: TokenUsageProps) {
return (
<div className="flex flex-col p-3 rounded-md bg-secondary/50 text-sm text-secondary-foreground">
<h3 className="font-semibold mb-1">Token Usage</h3>
<div className="grid grid-cols-2 gap-1">
<span>Prompt:</span>
<span className="text-right">{prompt.toLocaleString()}</span>
<span>Completion:</span>
<span className="text-right">{completion.toLocaleString()}</span>
<span className="font-semibold">Total:</span>
<span className="text-right font-semibold">{total.toLocaleString()}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,178 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from './ui/button';
import { useToast } from './ui/use-toast';
interface VoiceInputProps {
onTranscript: (text: string) => void;
isDisabled?: boolean;
}
export function VoiceInput({ onTranscript, isDisabled = false }: VoiceInputProps) {
const [isListening, setIsListening] = useState(false);
const [isSupported, setIsSupported] = useState(true);
const recognitionRef = useRef<any>(null);
const { toast } = useToast();
// Initialize speech recognition on component mount
useEffect(() => {
// Check for browser support
if (!('webkitSpeechRecognition' in window) &&
!('SpeechRecognition' in window)) {
setIsSupported(false);
return;
}
// Create speech recognition instance
const SpeechRecognition = window.webkitSpeechRecognition || window.SpeechRecognition;
const recognition = new SpeechRecognition();
// Configure recognition
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US'; // Default to English
// Set up event handlers
recognition.onstart = () => {
setIsListening(true);
};
recognition.onerror = (event: any) => {
console.error('Speech recognition error:', event.error);
// Handle specific errors
if (event.error === 'not-allowed') {
toast({
title: "Microphone Access Denied",
description: "Please allow microphone access to use voice input.",
variant: "destructive",
});
} else {
toast({
title: "Voice Recognition Error",
description: `Error: ${event.error}`,
variant: "destructive",
});
}
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
recognition.onresult = (event: any) => {
let interimTranscript = '';
let finalTranscript = '';
// Collect results
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcript;
} else {
interimTranscript += transcript;
}
}
// Only send final results to parent component
if (finalTranscript) {
onTranscript(finalTranscript);
}
};
// Store recognition instance in ref
recognitionRef.current = recognition;
// Clean up on unmount
return () => {
if (recognitionRef.current) {
try {
recognitionRef.current.stop();
} catch (error) {
// Ignore errors when stopping (recognition might not be started)
}
}
};
}, [onTranscript, toast]);
// Toggle listening state
const toggleListening = () => {
if (!recognitionRef.current) return;
if (isListening) {
recognitionRef.current.stop();
} else {
try {
recognitionRef.current.start();
// Auto-stop after 30 seconds to prevent indefinite recording
setTimeout(() => {
if (isListening) {
recognitionRef.current.stop();
toast({
title: "Voice Recording Stopped",
description: "Reached maximum recording time of 30 seconds.",
});
}
}, 30000);
} catch (error) {
console.error('Error starting speech recognition:', error);
toast({
title: "Voice Recognition Error",
description: "Could not start microphone. Try refreshing the page.",
variant: "destructive",
});
}
}
};
// Don't render if not supported
if (!isSupported) {
return null;
}
return (
<Button
type="button"
size="sm"
variant={isListening ? "default" : "outline"}
onClick={toggleListening}
disabled={isDisabled}
className={`relative ${isListening ? 'bg-red-500 hover:bg-red-600 text-white' : ''}`}
aria-label={isListening ? "Stop listening" : "Start voice input"}
>
{isListening && (
<span className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-red-500">
<span className="absolute inset-0 h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
</span>
)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
{isListening ? "Listening..." : "Voice Input"}
</Button>
);
}
// Add TypeScript declarations for Web Speech API
declare global {
interface Window {
webkitSpeechRecognition: any;
SpeechRecognition: any;
}
}

View File

@ -0,0 +1,71 @@
import React from 'react';
import { useChat } from '../contexts/ChatContext';
import { useConfig } from '../contexts/ConfigContext';
import { Progress } from './ui/progress';
import { Alert, AlertTitle, AlertDescription } from './ui/alert';
export function WebLLMStatus() {
const { webLLMStatus, isWebLLMLoading } = useChat();
const { provider, isWebLLMAvailable, selectedModel } = useConfig();
// Don't show anything if WebLLM is not the selected provider or if the status is null
if (provider !== 'webllm' || !webLLMStatus) {
return null;
}
// Show browser compatibility warning if WebLLM is not available
if (!isWebLLMAvailable) {
return (
<Alert variant="destructive" className="mb-4">
<AlertTitle>WebLLM Not Supported</AlertTitle>
<AlertDescription>
Your browser doesn't support WebLLM. Features like SharedArrayBuffer or WebAssembly might be missing.
Try using Chrome or Edge browser.
</AlertDescription>
</Alert>
);
}
// Show error message if there's an error
if (webLLMStatus.error) {
return (
<Alert variant="destructive" className="mb-4">
<AlertTitle>WebLLM Error</AlertTitle>
<AlertDescription>
{webLLMStatus.error}
</AlertDescription>
</Alert>
);
}
// Show loading progress if loading
if (isWebLLMLoading || webLLMStatus.isLoading) {
return (
<div className="mb-4 p-4 border rounded-lg bg-muted/20">
<h3 className="font-medium mb-2">Loading {selectedModel}</h3>
<div className="text-sm text-muted-foreground mb-2">
{webLLMStatus.stage || 'Initializing...'}
</div>
<Progress value={webLLMStatus.progress * 100} className="h-2" />
<p className="text-xs text-muted-foreground mt-2">
Model files are downloaded to your browser and run locally. This may take a few minutes the first time.
</p>
</div>
);
}
// Show ready status if ready
if (webLLMStatus.isReady) {
return (
<Alert className="mb-4 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
<AlertTitle className="text-green-800 dark:text-green-300">WebLLM Ready</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-400">
Model {selectedModel} is loaded and running locally in your browser. Your data stays private.
</AlertDescription>
</Alert>
);
}
// Default return
return null;
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,115 @@
import React from 'react';
import { cn } from '../../lib/utils';
interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> {
role: string;
}
export function Avatar({ role, className, ...props }: AvatarProps) {
const getAvatarContent = () => {
const lowerRole = role.toLowerCase();
if (lowerRole === 'assistant' || lowerRole === 'bot') {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M12 2a5 5 0 0 1 5 5v6a5 5 0 0 1-10 0V7a5 5 0 0 1 5-5Z"/>
<path d="M15 9.34V9c0-1.66-1.34-3-3-3s-3 1.34-3 3v.34"/>
<path d="m12 17 1 4 2-4"/>
<path d="m9 17-1 4-2-4"/>
</svg>
);
} else if (lowerRole === 'system') {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M12 2v6"/>
<path d="M12 16v6"/>
<path d="M2 12h6"/>
<path d="M16 12h6"/>
<path d="m4.9 4.9 4.2 4.2"/>
<path d="m14.9 14.9 4.2 4.2"/>
<path d="m14.9 4.9-4.2 4.2"/>
<path d="m4.9 19.1 4.2-4.2"/>
</svg>
);
} else if (lowerRole === 'developer') {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="m8 2 1.88 1.88"/>
<path d="M14.12 3.88 16 2"/>
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/>
<path d="M12 20h-2a6 6 0 0 1-6-6v-3a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v3a6 6 0 0 1-6 6h-2z"/>
<path d="m6 15 6 6 6-6"/>
</svg>
);
} else {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
);
}
};
// Determine background color based on role
const getBgColorClass = () => {
const lowerRole = role.toLowerCase();
if (lowerRole === 'assistant' || lowerRole === 'bot') {
return 'bg-blue-100 text-blue-700 dark:bg-blue-800 dark:text-blue-200';
} else if (lowerRole === 'system') {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-200';
} else if (lowerRole === 'developer') {
return 'bg-purple-100 text-purple-700 dark:bg-purple-800 dark:text-purple-200';
} else {
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200';
}
};
return (
<div
className={cn(
"flex items-center justify-center rounded-full",
getBgColorClass(),
className
)}
{...props}
>
{getAvatarContent()}
</div>
);
}

View File

@ -0,0 +1,55 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,115 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "../../lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,145 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "../../lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-auto h-4 w-4"
>
<path d="m9 18 6-6-6-6"/>
</svg>
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

105
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,105 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "../../lib/utils"
import { Label } from "./label"
// Simplified FormContext for our usage
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>({
id: ''
})
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { id } = React.useContext(FormItemContext)
return (
<Label
ref={ref}
className={className}
htmlFor={id}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { id } = React.useContext(FormItemContext)
return (
<Slot
ref={ref}
id={id}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { id } = React.useContext(FormItemContext)
return (
<p
ref={ref}
id={`${id}-description`}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { id } = React.useContext(FormItemContext)
return (
<p
ref={ref}
id={`${id}-message`}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{children}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
}

View File

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "../../lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,144 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "../../lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
}

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "../../lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

123
src/components/ui/toast.tsx Normal file
View File

@ -0,0 +1,123 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cn } from "../../lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root>
>(({ className, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
className
)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "./toast"
import { useToast } from "./use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,172 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "./toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case actionTypes.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case actionTypes.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case actionTypes.DISMISS_TOAST: {
const { toastId } = action
// If no toast id, dismiss all
if (toastId === undefined) {
return {
...state,
toasts: state.toasts.map((t) => ({
...t,
open: false,
})),
}
}
// Dismiss single toast
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId ? { ...t, open: false } : t
),
}
}
case actionTypes.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
interface Toast extends Omit<ToasterToast, "id"> {}
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: actionTypes.UPDATE_TOAST,
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id })
dispatch({
type: actionTypes.ADD_TOAST,
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
}
}
export { useToast, toast }

View File

@ -0,0 +1,801 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { useConfig } from './ConfigContext';
import { useToast } from '../components/ui/use-toast';
import {
processStream,
decodeOpenAIStreamChunk,
decodeAnthropicStreamChunk,
decodeGoogleStreamChunk
} from '../utils/streamUtils';
import {
generateCompletion as webLLMGenerate,
initWebLLM,
getModelStatus,
} from '../utils/webLLMUtils';
// Import ModelLoadingStatus interface directly
import type { ModelLoadingStatus } from '../utils/webLLMUtils';
import type { UploadedFile } from '../components/FileUpload';
export interface Message {
id: string;
role: string;
content: string;
files?: UploadedFile[];
timestamp: number;
}
export interface TokenUsage {
prompt: number;
completion: number;
total: number;
}
export interface ChatResponse {
text: string;
tokenUsage?: TokenUsage;
}
export interface UsageStats {
totalPromptTokens: number;
totalCompletionTokens: number;
totalTokens: number;
messageCount: number;
requestCount: number;
// Track per model usage
modelUsage: {
[modelId: string]: {
totalTokens: number;
requestCount: number;
};
};
}
interface ChatContextType {
messages: Message[];
isLoading: boolean;
isStreaming: boolean;
streamingText: string;
lastTokenUsage: TokenUsage | null;
sessionUsage: UsageStats;
persistentUsage: UsageStats;
addMessage: (role: string, content: string, files?: UploadedFile[]) => void;
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
sendMessage: (role1: string, content1: string, role2: string, content2: string, files?: UploadedFile[]) => Promise<void>;
editMessage: (id: string, newContent: string) => Promise<void>;
deleteMessage: (id: string) => Promise<void>;
clearChat: () => void;
resetSessionStats: () => void;
resetAllStats: () => void;
webLLMStatus: ModelLoadingStatus | null;
isWebLLMLoading: boolean;
}
const DEFAULT_USAGE_STATS: UsageStats = {
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalTokens: 0,
messageCount: 0,
requestCount: 0,
modelUsage: {}
};
// Generate a unique ID for messages
const generateMessageId = (): string => {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
};
const ChatContext = createContext<ChatContextType | undefined>(undefined);
export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState('');
const [lastTokenUsage, setLastTokenUsage] = useState<TokenUsage | null>(null);
// Track usage stats for the current session
const [sessionUsage, setSessionUsage] = useState<UsageStats>(DEFAULT_USAGE_STATS);
// Track persistent usage stats across all sessions
const [persistentUsage, setPersistentUsage] = useState<UsageStats>(() => {
const saved = localStorage.getItem('chatUsageStats');
return saved ? JSON.parse(saved) : DEFAULT_USAGE_STATS;
});
// WebLLM status
const [webLLMStatus, setWebLLMStatus] = useState<ModelLoadingStatus | null>(null);
const [isWebLLMLoading, setIsWebLLMLoading] = useState(false);
const { provider, endpoint, apiKey, selectedModel, streamingEnabled } = useConfig();
const { toast } = useToast();
// Save usage stats to localStorage when they change
useEffect(() => {
localStorage.setItem('chatUsageStats', JSON.stringify(persistentUsage));
}, [persistentUsage]);
const addMessage = useCallback((role: string, content: string, files?: UploadedFile[]) => {
const newMessage: Message = {
id: generateMessageId(),
role,
content,
files,
timestamp: Date.now()
};
setMessages(prev => [...prev, newMessage]);
// Update message count in stats
setSessionUsage(prev => ({
...prev,
messageCount: prev.messageCount + 1
}));
setPersistentUsage(prev => ({
...prev,
messageCount: prev.messageCount + 1
}));
}, []);
const clearChat = useCallback(() => {
setMessages([]);
setLastTokenUsage(null);
}, []);
const resetSessionStats = useCallback(() => {
setSessionUsage(DEFAULT_USAGE_STATS);
}, []);
const resetAllStats = useCallback(() => {
setSessionUsage(DEFAULT_USAGE_STATS);
setPersistentUsage(DEFAULT_USAGE_STATS);
localStorage.removeItem('chatUsageStats');
}, []);
// Edit a message by ID
const editMessage = useCallback(async (id: string, newContent: string): Promise<void> => {
return new Promise((resolve, reject) => {
try {
setMessages(prev =>
prev.map(message =>
message.id === id
? { ...message, content: newContent, timestamp: Date.now() }
: message
)
);
resolve();
} catch (error) {
console.error('Error editing message:', error);
reject(error);
}
});
}, []);
// Delete a message by ID
const deleteMessage = useCallback(async (id: string): Promise<void> => {
return new Promise((resolve, reject) => {
try {
setMessages(prev => prev.filter(message => message.id !== id));
resolve();
} catch (error) {
console.error('Error deleting message:', error);
reject(error);
}
});
}, []);
// Update usage statistics
const updateUsageStats = useCallback((tokenUsage: TokenUsage) => {
const { prompt, completion, total } = tokenUsage;
// Update session stats
setSessionUsage(prev => {
// Initialize model usage if it doesn't exist
const currentModelUsage = prev.modelUsage[selectedModel] || { totalTokens: 0, requestCount: 0 };
return {
totalPromptTokens: prev.totalPromptTokens + prompt,
totalCompletionTokens: prev.totalCompletionTokens + completion,
totalTokens: prev.totalTokens + total,
messageCount: prev.messageCount,
requestCount: prev.requestCount + 1,
modelUsage: {
...prev.modelUsage,
[selectedModel]: {
totalTokens: currentModelUsage.totalTokens + total,
requestCount: currentModelUsage.requestCount + 1
}
}
};
});
// Update persistent stats
setPersistentUsage(prev => {
// Initialize model usage if it doesn't exist
const currentModelUsage = prev.modelUsage[selectedModel] || { totalTokens: 0, requestCount: 0 };
return {
totalPromptTokens: prev.totalPromptTokens + prompt,
totalCompletionTokens: prev.totalCompletionTokens + completion,
totalTokens: prev.totalTokens + total,
messageCount: prev.messageCount,
requestCount: prev.requestCount + 1,
modelUsage: {
...prev.modelUsage,
[selectedModel]: {
totalTokens: currentModelUsage.totalTokens + total,
requestCount: currentModelUsage.requestCount + 1
}
}
};
});
}, [selectedModel]);
// Handler for OpenAI and OpenAI-compatible APIs (like DeepSeek) - with streaming
const fetchOpenAIResponse = async (messages: Message[], stream = false): Promise<ChatResponse> => {
try {
// Format the messages for OpenAI API
const formattedMessages = messages.map(msg => {
// For messages with files, format as content list for OpenAI
if (msg.files && msg.files.length > 0 && msg.role === 'user') {
const contentList = [
{ type: 'text', text: msg.content }
];
// Add image attachments
msg.files.forEach(file => {
if (file.type.startsWith('image/') && typeof file.content === 'string') {
contentList.push({
type: 'image_url',
image_url: {
url: file.content,
detail: 'auto'
}
});
}
});
return {
role: msg.role,
content: contentList
};
}
// Regular message without files
return {
role: msg.role,
content: msg.content
};
});
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: selectedModel,
messages: formattedMessages,
stream: stream
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || 'Failed to fetch response');
}
// Handle streaming response
if (stream && response.body) {
setIsStreaming(true);
setStreamingText('');
let fullText = '';
// Process the stream
await processStream(
response.body,
(text) => {
fullText += text;
setStreamingText(fullText);
},
decodeOpenAIStreamChunk
);
setIsStreaming(false);
// Use approximated token counts for streaming
const approxPromptTokens = JSON.stringify(messages).length / 4;
const approxCompletionTokens = fullText.length / 4;
const tokenUsage = {
prompt: Math.round(approxPromptTokens),
completion: Math.round(approxCompletionTokens),
total: Math.round(approxPromptTokens + approxCompletionTokens)
};
// Update usage stats
updateUsageStats(tokenUsage);
return {
text: fullText,
tokenUsage
};
}
// Handle non-streaming response
const data = await response.json();
const text = data.choices[0]?.message?.content || '';
const tokenUsage = data.usage ? {
prompt: data.usage.prompt_tokens,
completion: data.usage.completion_tokens,
total: data.usage.total_tokens
} : {
prompt: Math.round(JSON.stringify(messages).length / 4),
completion: Math.round(text.length / 4),
total: Math.round((JSON.stringify(messages).length + text.length) / 4)
};
// Update usage stats
updateUsageStats(tokenUsage);
return { text, tokenUsage };
} catch (error) {
console.error('Error fetching response:', error);
throw error;
}
};
// Handler for Anthropic's API with streaming
const fetchAnthropicResponse = async (messages: Message[], stream = false): Promise<ChatResponse> => {
try {
// Convert to Anthropic format
const anthropicMessages = messages.map(msg => ({
role: msg.role === 'assistant' ? 'assistant' : 'user',
content: msg.content
}));
// Filter out system messages and add them as a system parameter if present
const systemMessages = messages.filter(msg => msg.role === 'system');
const systemPrompt = systemMessages.length > 0
? systemMessages.map(msg => msg.content).join('\n')
: undefined;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: selectedModel,
messages: anthropicMessages.filter(msg => msg.role !== 'system'),
system: systemPrompt,
max_tokens: 1000,
stream
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || 'Failed to fetch response');
}
// Handle streaming response
if (stream && response.body) {
setIsStreaming(true);
setStreamingText('');
let fullText = '';
// Process the stream
await processStream(
response.body,
(text) => {
fullText += text;
setStreamingText(fullText);
},
decodeAnthropicStreamChunk
);
setIsStreaming(false);
// Use approximated token counts for streaming
const approxPromptTokens = JSON.stringify(messages).length / 4;
const approxCompletionTokens = fullText.length / 4;
const tokenUsage = {
prompt: Math.round(approxPromptTokens),
completion: Math.round(approxCompletionTokens),
total: Math.round(approxPromptTokens + approxCompletionTokens)
};
// Update usage stats
updateUsageStats(tokenUsage);
return {
text: fullText,
tokenUsage
};
}
// Handle non-streaming response
const data = await response.json();
const text = data.content?.[0]?.text || '';
// Anthropic doesn't provide token usage in the same format, so estimate
const tokenUsage = data.usage ? {
prompt: data.usage.input_tokens || 0,
completion: data.usage.output_tokens || 0,
total: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0)
} : {
prompt: Math.round(JSON.stringify(messages).length / 4),
completion: Math.round(text.length / 4),
total: Math.round((JSON.stringify(messages).length + text.length) / 4)
};
// Update usage stats
updateUsageStats(tokenUsage);
return { text, tokenUsage };
} catch (error) {
console.error('Error fetching Anthropic response:', error);
throw error;
}
};
// Handler for Google AI's API with streaming
const fetchGoogleResponse = async (messages: Message[], stream = false): Promise<ChatResponse> => {
try {
// Convert to Google format
const promptContent = messages.map(msg => {
return {
role: msg.role === 'assistant' ? 'model' : msg.role,
parts: [{ text: msg.content }]
};
});
// Google Gemini API structure
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey
},
body: JSON.stringify({
contents: promptContent,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 1000
},
// Add stream parameter for Google
stream
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || 'Failed to fetch response');
}
// Handle streaming response
if (stream && response.body) {
setIsStreaming(true);
setStreamingText('');
let fullText = '';
// Process the stream
await processStream(
response.body,
(text) => {
fullText += text;
setStreamingText(fullText);
},
decodeGoogleStreamChunk
);
setIsStreaming(false);
// Use approximated token counts for streaming
const approxPromptTokens = JSON.stringify(messages).length / 4;
const approxCompletionTokens = fullText.length / 4;
const tokenUsage = {
prompt: Math.round(approxPromptTokens),
completion: Math.round(approxCompletionTokens),
total: Math.round(approxPromptTokens + approxCompletionTokens)
};
// Update usage stats
updateUsageStats(tokenUsage);
return {
text: fullText,
tokenUsage
};
}
// Handle non-streaming response
const data = await response.json();
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
// Google may not provide token usage data in the same format
const tokenUsage = data.usageMetadata ? {
prompt: data.usageMetadata.promptTokenCount || 0,
completion: data.usageMetadata.candidatesTokenCount || 0,
total: (data.usageMetadata.promptTokenCount || 0) + (data.usageMetadata.candidatesTokenCount || 0)
} : {
prompt: Math.round(JSON.stringify(messages).length / 4),
completion: Math.round(text.length / 4),
total: Math.round((JSON.stringify(messages).length + text.length) / 4)
};
// Update usage stats
updateUsageStats(tokenUsage);
return { text, tokenUsage };
} catch (error) {
console.error('Error fetching Google response:', error);
throw error;
}
};
// Handler for WebLLM browser-based models
const fetchWebLLMResponse = async (messages: Message[], stream = false): Promise<ChatResponse> => {
try {
// Check if model is loaded
const status = getModelStatus();
// Initialize model if not ready
if (!status.isReady) {
setIsWebLLMLoading(true);
setWebLLMStatus({ ...status, isLoading: true });
try {
// Initialize the model
const newStatus = await initWebLLM(selectedModel, (progress) => {
// Update status on progress
setWebLLMStatus({
isLoading: true,
progress: progress.progress,
stage: progress.stage,
isReady: false,
error: null
});
});
setWebLLMStatus(newStatus);
if (!newStatus.isReady) {
throw new Error('Failed to load WebLLM model: ' + (newStatus.error || 'Unknown error'));
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error initializing WebLLM';
setWebLLMStatus({
isLoading: false,
progress: 0,
stage: 'error',
isReady: false,
error: errorMsg
});
throw new Error('Failed to load model: ' + errorMsg);
} finally {
setIsWebLLMLoading(false);
}
}
// Format messages for WebLLM
const formattedMessages = messages.map(msg => ({
role: msg.role,
content: msg.content
}));
// Set up streaming if needed
const streamCallback = stream ? (token: string) => {
setStreamingText(prev => prev + token);
} : undefined;
// Start streaming UI if needed
if (stream) {
setIsStreaming(true);
setStreamingText('');
}
// Generate completion
const result = await webLLMGenerate(
formattedMessages,
{
onTokenCallback: streamCallback,
temperature: 0.7,
max_tokens: 1000
}
);
// End streaming UI
setIsStreaming(false);
// Update usage stats
const tokenUsage = {
prompt: result.usage.prompt_tokens,
completion: result.usage.completion_tokens,
total: result.usage.total_tokens
};
updateUsageStats(tokenUsage);
return {
text: result.text,
tokenUsage
};
} catch (error) {
console.error('Error generating WebLLM response:', error);
throw error;
}
};
const sendMessage = async (role1: string, content1: string, role2: string, content2: string, files?: UploadedFile[]) => {
if ((provider !== 'webllm' && !apiKey) || (provider === 'custom' && !endpoint)) {
toast({
title: "Configuration Required",
description: provider === 'webllm'
? "WebLLM model will be loaded on first request"
: "Please configure your API key in settings",
variant: "destructive",
});
if (provider !== 'webllm') {
return;
}
}
setIsLoading(true);
// Add user messages to chat
const userMessages = [];
if (role1 && content1) {
const message: Message = {
id: generateMessageId(),
role: role1,
content: content1,
files,
timestamp: Date.now()
};
userMessages.push(message);
setMessages(prev => [...prev, message]);
// Update message count
setSessionUsage(prev => ({
...prev,
messageCount: prev.messageCount + 1
}));
setPersistentUsage(prev => ({
...prev,
messageCount: prev.messageCount + 1
}));
}
if (role2 && content2) {
const message: Message = {
id: generateMessageId(),
role: role2,
content: content2,
timestamp: Date.now()
};
userMessages.push(message);
setMessages(prev => [...prev, message]);
// Update message count
setSessionUsage(prev => ({
...prev,
messageCount: prev.messageCount + 1
}));
setPersistentUsage(prev => ({
...prev,
messageCount: prev.messageCount + 1
}));
}
try {
// Combine all messages including history for the API call
const allMessages = [...messages, ...userMessages];
// Get streaming setting from config context
const enableStreaming = streamingEnabled;
let response: ChatResponse;
// Select the appropriate handler based on the provider
switch (provider) {
case 'openai':
case 'deepseek':
case 'custom':
// DeepSeek and custom providers might use OpenAI-compatible format
response = await fetchOpenAIResponse(allMessages, enableStreaming);
break;
case 'anthropic':
response = await fetchAnthropicResponse(allMessages, enableStreaming);
break;
case 'google':
response = await fetchGoogleResponse(allMessages, enableStreaming);
break;
case 'webllm':
response = await fetchWebLLMResponse(allMessages, enableStreaming);
break;
default:
response = await fetchOpenAIResponse(allMessages, enableStreaming);
break;
}
// Add assistant response to chat
const assistantMessage: Message = {
id: generateMessageId(),
role: 'assistant',
content: response.text,
timestamp: Date.now()
};
setMessages(prev => [...prev, assistantMessage]);
// Update message count
setSessionUsage(prev => ({
...prev,
messageCount: prev.messageCount + 1
}));
setPersistentUsage(prev => ({
...prev,
messageCount: prev.messageCount + 1
}));
// Store token usage
if (response.tokenUsage) {
setLastTokenUsage(response.tokenUsage);
}
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Failed to get response",
variant: "destructive",
});
} finally {
setIsLoading(false);
setIsStreaming(false);
setStreamingText('');
}
};
return (
<ChatContext.Provider value={{
messages,
isLoading,
isStreaming,
streamingText,
lastTokenUsage,
sessionUsage,
persistentUsage,
addMessage,
setMessages,
sendMessage,
editMessage,
deleteMessage,
clearChat,
resetSessionStats,
resetAllStats,
webLLMStatus,
isWebLLMLoading
}}>
{children}
</ChatContext.Provider>
);
};
export const useChat = () => {
const context = useContext(ChatContext);
if (context === undefined) {
throw new Error('useChat must be used within a ChatProvider');
}
return context;
};

View File

@ -0,0 +1,255 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { WEBLLM_MODELS, isWebLLMSupported } from '../utils/webLLMUtils';
// Define types for provider
export type Provider = 'openai' | 'anthropic' | 'google' | 'deepseek' | 'webllm' | 'custom';
// Model interface
export interface Model {
id: string;
name: string;
maxTokens?: number;
provider: Provider;
}
// Configuration context interface
interface ConfigContextType {
provider: Provider;
setProvider: (provider: Provider) => void;
apiKey: string;
setApiKey: (key: string) => void;
endpoint: string;
setEndpoint: (endpoint: string) => void;
selectedModel: string;
setSelectedModel: (model: string) => void;
availableModels: Model[];
streamingEnabled: boolean;
setStreamingEnabled: (enabled: boolean) => void;
isWebLLMAvailable: boolean;
webLLMStatusMessage: string;
}
// Create the context
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
// Default OpenAI models
const OPENAI_MODELS: Model[] = [
{ id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' },
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' },
{ id: 'gpt-4', name: 'GPT-4', provider: 'openai' },
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'openai' }
];
// Default Anthropic models
const ANTHROPIC_MODELS: Model[] = [
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', provider: 'anthropic' },
{ id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet', provider: 'anthropic' },
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku', provider: 'anthropic' },
{ id: 'claude-2.1', name: 'Claude 2.1', provider: 'anthropic' },
{ id: 'claude-2.0', name: 'Claude 2.0', provider: 'anthropic' },
{ id: 'claude-instant-1.2', name: 'Claude Instant 1.2', provider: 'anthropic' }
];
// Default Google models
const GOOGLE_MODELS: Model[] = [
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', provider: 'google' },
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', provider: 'google' },
{ id: 'gemini-1.0-pro', name: 'Gemini 1.0 Pro', provider: 'google' },
{ id: 'gemini-1.0-ultra', name: 'Gemini 1.0 Ultra', provider: 'google' }
];
// Default DeepSeek models
const DEEPSEEK_MODELS: Model[] = [
{ id: 'deepseek-chat', name: 'DeepSeek Chat', provider: 'deepseek' },
{ id: 'deepseek-coder', name: 'DeepSeek Coder', provider: 'deepseek' }
];
// WebLLM models (from WebLLMUtils)
const webLLMModels: Model[] = WEBLLM_MODELS.map(model => ({
id: model.id,
name: model.name,
maxTokens: model.contextLength,
provider: 'webllm' as Provider
}));
// Get provider endpoint
const getProviderEndpoint = (provider: Provider): string => {
switch (provider) {
case 'openai':
return 'https://api.openai.com/v1/chat/completions';
case 'anthropic':
return 'https://api.anthropic.com/v1/messages';
case 'google':
return 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.0-pro:generateContent';
case 'deepseek':
return 'https://api.deepseek.com/v1/chat/completions';
case 'webllm':
return 'browser'; // Special marker for browser-based models
case 'custom':
return '';
default:
return '';
}
};
// Get models for provider
const getModelsForProvider = (provider: Provider): Model[] => {
switch (provider) {
case 'openai':
return OPENAI_MODELS;
case 'anthropic':
return ANTHROPIC_MODELS;
case 'google':
return GOOGLE_MODELS;
case 'deepseek':
return DEEPSEEK_MODELS;
case 'webllm':
return webLLMModels;
case 'custom':
return []; // Custom provider requires user to specify models
default:
return [];
}
};
export const ConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Initialize from localStorage if available
const [provider, setProvider] = useState<Provider>(() => {
if (typeof window !== 'undefined') {
const savedProvider = localStorage.getItem('provider');
return (savedProvider as Provider) || 'openai';
}
return 'openai';
});
const [apiKey, setApiKey] = useState<string>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('apiKey') || '';
}
return '';
});
const [endpoint, setEndpoint] = useState<string>(() => {
if (typeof window !== 'undefined') {
const savedEndpoint = localStorage.getItem('endpoint');
if (savedEndpoint) return savedEndpoint;
}
return getProviderEndpoint(provider);
});
const [selectedModel, setSelectedModel] = useState<string>(() => {
if (typeof window !== 'undefined') {
const savedModel = localStorage.getItem('selectedModel');
if (savedModel) return savedModel;
}
// Default models for each provider
const defaultModels: Record<Provider, string> = {
'openai': 'gpt-3.5-turbo',
'anthropic': 'claude-3-haiku-20240307',
'google': 'gemini-1.0-pro',
'deepseek': 'deepseek-chat',
'webllm': 'TinyLlama-1.1B-Chat-v1.0-q4f16_1',
'custom': ''
};
return defaultModels[provider];
});
const [streamingEnabled, setStreamingEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('streamingEnabled');
return saved ? saved === 'true' : true; // default to true
}
return true;
});
// WebLLM support
const [isWebLLMAvailable, setIsWebLLMAvailable] = useState<boolean>(false);
const [webLLMStatusMessage, setWebLLMStatusMessage] = useState<string>('Checking WebLLM support...');
// Update available models when provider changes
const [availableModels, setAvailableModels] = useState<Model[]>(
getModelsForProvider(provider)
);
// Check WebLLM support on component mount
useEffect(() => {
// Only run in browser environment
if (typeof window !== 'undefined') {
try {
const supported = isWebLLMSupported();
setIsWebLLMAvailable(supported);
setWebLLMStatusMessage(supported
? 'WebLLM is supported in this browser'
: 'WebLLM is not supported in this browser (requires WebAssembly, SharedArrayBuffer, and Atomics API)');
} catch (error) {
setIsWebLLMAvailable(false);
setWebLLMStatusMessage('Error checking WebLLM support: ' +
(error instanceof Error ? error.message : 'Unknown error'));
}
}
}, []);
// Update models when provider changes
useEffect(() => {
const models = getModelsForProvider(provider);
setAvailableModels(models);
// If the selected model is not in the new provider's models, select the first one
const modelExists = models.some(model => model.id === selectedModel);
if (!modelExists && models.length > 0) {
setSelectedModel(models[0].id);
}
// Update endpoint when provider changes (unless custom)
if (provider !== 'custom') {
setEndpoint(getProviderEndpoint(provider));
}
// Save to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('provider', provider);
}
}, [provider, selectedModel]);
// Save settings to localStorage when they change
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('apiKey', apiKey);
localStorage.setItem('endpoint', endpoint);
localStorage.setItem('selectedModel', selectedModel);
localStorage.setItem('streamingEnabled', String(streamingEnabled));
}
}, [apiKey, endpoint, selectedModel, streamingEnabled]);
const value = {
provider,
setProvider,
apiKey,
setApiKey,
endpoint,
setEndpoint,
selectedModel,
setSelectedModel,
availableModels,
streamingEnabled,
setStreamingEnabled,
isWebLLMAvailable,
webLLMStatusMessage
};
return (
<ConfigContext.Provider value={value}>
{children}
</ConfigContext.Provider>
);
};
export const useConfig = () => {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
};

View File

@ -0,0 +1,357 @@
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
import { useChat, type Message, type UsageStats } from './ChatContext';
import { useToast } from '../components/ui/use-toast';
import {
DEFAULT_SYNC_CONFIG,
type SyncConfig,
initializeMongoSync,
syncMessages,
syncUsageStats,
fetchConversations,
fetchMessages,
generateConversationId
} from '../utils/mongoSync';
interface Conversation {
id: string;
title: string;
lastMessage: string;
timestamp: number;
}
interface SyncContextValue {
syncEnabled: boolean;
toggleSync: (enabled: boolean) => void;
isSyncing: boolean;
lastSyncTime: Date | null;
serverConnected: boolean;
syncConfig: SyncConfig;
updateSyncConfig: (config: Partial<SyncConfig>) => void;
syncNow: () => Promise<void>;
createNewConversation: (title?: string) => void;
loadConversation: (conversationId: string) => Promise<void>;
conversations: Conversation[];
currentConversationId: string | null;
refreshConversations: () => Promise<void>;
}
const SyncContext = createContext<SyncContextValue | undefined>(undefined);
export function SyncProvider({ children }: { children: React.ReactNode }) {
const { toast } = useToast();
const chatContext = useChat();
// Sync configuration state
const [syncConfig, setSyncConfig] = useState<SyncConfig>(() => {
// Try to load from localStorage
const savedConfig = localStorage.getItem('syncConfig');
return savedConfig ? JSON.parse(savedConfig) : DEFAULT_SYNC_CONFIG;
});
// Sync status state
const [isSyncing, setIsSyncing] = useState(false);
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
const [serverConnected, setServerConnected] = useState(false);
// Conversation management
const [conversations, setConversations] = useState<Conversation[]>([]);
const [currentConversationId, setCurrentConversationId] = useState<string | null>(
localStorage.getItem('currentConversationId')
);
// Save sync config to localStorage whenever it changes
useEffect(() => {
localStorage.setItem('syncConfig', JSON.stringify(syncConfig));
}, [syncConfig]);
// Save current conversation ID to localStorage
useEffect(() => {
if (currentConversationId) {
localStorage.setItem('currentConversationId', currentConversationId);
} else {
localStorage.removeItem('currentConversationId');
}
}, [currentConversationId]);
// Initialize MongoDB connection when config changes
useEffect(() => {
if (syncConfig.syncEnabled && syncConfig.serverUrl) {
initializeConnection();
} else {
setServerConnected(false);
}
async function initializeConnection() {
setIsSyncing(true);
try {
const result = await initializeMongoSync(syncConfig);
if (result.success) {
setServerConnected(true);
// If connection is successful, fetch conversations
await refreshConversationsList();
} else {
setServerConnected(false);
console.error('Failed to connect to MongoDB:', result.error);
toast({
title: "Connection Failed",
description: result.error,
variant: "destructive",
});
}
} catch (error) {
setServerConnected(false);
console.error('Error connecting to MongoDB:', error);
} finally {
setIsSyncing(false);
}
}
}, [syncConfig.syncEnabled, syncConfig.serverUrl, syncConfig.apiKey, toast]);
// Set up periodic sync if enabled
useEffect(() => {
let intervalId: number | null = null;
if (syncConfig.syncEnabled && syncConfig.syncFrequency === 'periodic' && serverConnected) {
// Sync every 5 minutes
intervalId = window.setInterval(() => {
syncNow();
}, 5 * 60 * 1000);
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [syncConfig.syncEnabled, syncConfig.syncFrequency, serverConnected]);
// Refresh conversations list
const refreshConversationsList = async () => {
if (!syncConfig.syncEnabled || !syncConfig.serverUrl || !serverConnected) {
return;
}
try {
const result = await fetchConversations(syncConfig);
if (result.success) {
setConversations(result.data.conversations);
} else {
console.error('Failed to fetch conversations:', result.error);
toast({
title: "Failed to Load Conversations",
description: result.error,
variant: "destructive",
});
}
} catch (error) {
console.error('Error fetching conversations:', error);
}
};
// Create a new conversation
const createNewConversation = useCallback((title?: string) => {
const conversationId = generateConversationId();
setCurrentConversationId(conversationId);
// Clear chat messages when creating a new conversation
if (chatContext) {
chatContext.clearChat();
}
// Add to local conversations list
const newConversation: Conversation = {
id: conversationId,
title: title || `Conversation ${new Date().toLocaleString()}`,
lastMessage: '',
timestamp: Date.now()
};
setConversations(prev => [newConversation, ...prev]);
toast({
title: "New Conversation Created",
description: `ID: ${conversationId}`,
});
}, [chatContext, toast]);
// Load a conversation
const loadConversation = async (conversationId: string) => {
if (!syncConfig.syncEnabled || !syncConfig.serverUrl || !serverConnected) {
return;
}
setIsSyncing(true);
try {
const result = await fetchMessages(syncConfig, conversationId);
if (result.success && chatContext) {
// Convert messages to the format expected by ChatContext
const messages: Message[] = result.data.messages.map(msg => ({
id: msg.id || `msg_${Date.now()}_${Math.random().toString(36).slice(2)}`,
role: msg.role,
content: msg.content,
files: msg.files,
timestamp: msg.createdAt || Date.now()
}));
// Set the messages in ChatContext
chatContext.setMessages(messages);
// Update current conversation ID
setCurrentConversationId(conversationId);
toast({
title: "Conversation Loaded",
description: `Loaded ${messages.length} messages`,
});
} else if (!result.success) {
console.error('Failed to load conversation:', result.error);
toast({
title: "Failed to Load Conversation",
description: result.error,
variant: "destructive",
});
}
} catch (error) {
console.error('Error loading conversation:', error);
toast({
title: "Error Loading Conversation",
description: error instanceof Error ? error.message : "Unknown error",
variant: "destructive",
});
} finally {
setIsSyncing(false);
}
};
// Manual sync function
const syncNow = async () => {
if (!syncConfig.syncEnabled || !syncConfig.serverUrl || !chatContext) {
return;
}
setIsSyncing(true);
try {
// Ensure we have a conversation ID
const conversationId = currentConversationId || generateConversationId();
if (!currentConversationId) {
setCurrentConversationId(conversationId);
}
// Sync messages
const messagesResult = await syncMessages(
syncConfig,
chatContext.messages,
conversationId
);
// Sync usage statistics
const usageResult = await syncUsageStats(
syncConfig,
chatContext.persistentUsage
);
if (messagesResult.success && usageResult.success) {
// Update last sync time
setLastSyncTime(new Date());
// Refresh conversations list
await refreshConversationsList();
toast({
title: "Sync Completed",
description: `Synced ${messagesResult.data.synced} messages and usage statistics`,
});
} else {
console.error('Sync failed:',
messagesResult.success ? '' : messagesResult.error,
usageResult.success ? '' : usageResult.error
);
toast({
title: "Sync Failed",
description: messagesResult.success ? usageResult.error : messagesResult.error,
variant: "destructive",
});
}
} catch (error) {
console.error('Error during sync:', error);
toast({
title: "Sync Error",
description: error instanceof Error ? error.message : "Unknown error during sync",
variant: "destructive",
});
} finally {
setIsSyncing(false);
}
};
// Toggle sync enabled/disabled
const toggleSync = (enabled: boolean) => {
setSyncConfig(prev => ({
...prev,
syncEnabled: enabled
}));
if (!enabled) {
setServerConnected(false);
}
};
// Update sync configuration
const updateSyncConfig = (config: Partial<SyncConfig>) => {
setSyncConfig(prev => ({
...prev,
...config
}));
};
// Refresh conversations list function for external use
const refreshConversations = async () => {
await refreshConversationsList();
};
const contextValue: SyncContextValue = {
syncEnabled: syncConfig.syncEnabled,
toggleSync,
isSyncing,
lastSyncTime,
serverConnected,
syncConfig,
updateSyncConfig,
syncNow,
createNewConversation,
loadConversation,
conversations,
currentConversationId,
refreshConversations
};
return (
<SyncContext.Provider value={contextValue}>
{children}
</SyncContext.Provider>
);
}
export function useSync() {
const context = useContext(SyncContext);
if (context === undefined) {
throw new Error('useSync must be used within a SyncProvider');
}
return context;
}

View File

@ -0,0 +1,113 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const initialState: ThemeContextType = {
theme: 'system',
setTheme: () => null
};
const ThemeContext = createContext<ThemeContextType>(initialState);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(defaultTheme);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
// This effect runs only in the browser, not during SSR
setIsMounted(true);
// Get stored theme from localStorage if available
const storedTheme = localStorage.getItem(storageKey) as Theme | null;
if (storedTheme) {
setTheme(storedTheme);
} else if (defaultTheme === 'system') {
// If no theme is stored and default is system, check system preference
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
setTheme(systemTheme);
}
}, [defaultTheme, storageKey]);
useEffect(() => {
if (!isMounted) return;
const root = window.document.documentElement;
// Remove both classes first
root.classList.remove('light', 'dark');
// Add the appropriate class based on theme
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
// Persist theme choice to localStorage
localStorage.setItem(storageKey, theme);
}, [theme, storageKey, isMounted]);
// Listen for system theme changes if using system theme
useEffect(() => {
if (!isMounted) return;
if (theme !== 'system') return;
function handleSystemThemeChange(event: MediaQueryListEvent) {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(event.matches ? 'dark' : 'light');
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', handleSystemThemeChange);
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange);
};
}, [theme, isMounted]);
// If not mounted yet, provide the initial state without any side effects
const value = {
theme,
setTheme
};
return (
<ThemeContext.Provider {...props} value={value}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

31
src/layouts/Layout.astro Normal file
View File

@ -0,0 +1,31 @@
---
import "../styles/globals.css";
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<main class="min-h-screen">
<slot />
</main>
<script>
// Make sure React has initialized before rendering components
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM fully loaded and parsed');
});
</script>
</body>
</html>

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

15
src/pages/index.astro Normal file
View File

@ -0,0 +1,15 @@
---
import Layout from '../layouts/Layout.astro';
import { AppRoot } from '../components/AppRoot';
---
<Layout title="AI Chat Interface">
<AppRoot client:only="react" />
</Layout>
<script>
// Handle any client-side initialization here
document.addEventListener('astro:page-load', () => {
console.log('Page loaded and ready for interaction');
});
</script>

115
src/styles/globals.css Normal file
View File

@ -0,0 +1,115 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Default light theme */
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 87 49% 42%;
--primary-foreground: 0 0% 100%;
--secondary: 87 49% 35%;
--secondary-foreground: 0 0% 100%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222 47% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222 47% 11%;
--radius: 0.5rem;
}
.dark {
/* Custom dark theme with provided colors */
--background: 222 17% 20%;
--foreground: 220 13% 82%;
--card: 222 17% 18%;
--card-foreground: 220 13% 82%;
--popover: 222 17% 18%;
--popover-foreground: 220 13% 82%;
--primary: 87 49% 42%;
--primary-foreground: 0 0% 100%;
--secondary: 87 49% 35%;
--secondary-foreground: 0 0% 100%;
--muted: 222 17% 15%;
--muted-foreground: 220 13% 65%;
--accent: 222 17% 25%;
--accent-foreground: 220 13% 82%;
--destructive: 0 62.8% 45.6%;
--destructive-foreground: 220 13% 82%;
--border: 222 17% 28%;
--input: 222 17% 28%;
--ring: 87 49% 42%;
}
}
/* Force dark theme by default */
:root {
color-scheme: dark;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Animation utilities */
@layer utilities {
.animate-in {
animation: animateIn 0.3s ease-in-out;
}
.fade-in-0 {
opacity: 0;
animation: fadeIn 0.5s ease-in-out forwards;
}
@keyframes animateIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}

View File

@ -0,0 +1,353 @@
/**
* Document text extraction utilities for various file types
* (Browser-friendly version)
*/
import * as pdfjs from 'pdfjs-dist';
// Set worker path for PDF.js
pdfjs.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
export interface ExtractionResult {
text: string;
metadata?: Record<string, any>;
pages?: number;
fileType: string;
error?: string;
truncated?: boolean;
charCount: number;
}
/**
* Extract text from a PDF Buffer
*/
export async function extractPdfText(buffer: ArrayBuffer): Promise<ExtractionResult> {
try {
// Load the PDF document
const pdf = await pdfjs.getDocument({ data: buffer }).promise;
const numPages = pdf.numPages;
let text = '';
// Set a reasonable limit to avoid processing extremely large PDFs
// This can be adjusted based on your needs
const MAX_PAGES = 50;
const processPages = Math.min(numPages, MAX_PAGES);
const truncated = numPages > MAX_PAGES;
// Extract text from each page
for (let i = 1; i <= processPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
const pageText = content.items
.map((item: any) => item.str)
.join(' ');
text += `${pageText}\n\n`;
}
// Add a note if the document was truncated
if (truncated) {
text += `[Note: Document truncated. Only showing ${MAX_PAGES} of ${numPages} pages.]\n`;
}
return {
text,
pages: numPages,
fileType: 'pdf',
truncated,
charCount: text.length
};
} catch (error) {
console.error('Error extracting PDF text:', error);
return {
text: '',
fileType: 'pdf',
error: error instanceof Error ? error.message : 'Unknown error extracting PDF text',
charCount: 0
};
}
}
/**
* Extract text from a DOCX file
* Note: This is a simplified version for browser compatibility
* Full DOCX parsing is difficult in the browser
*/
export async function extractDocxText(buffer: ArrayBuffer): Promise<ExtractionResult> {
try {
// For browser compatibility, we'll use a simple approach
// to extract readable text from DOCX files
const decoder = new TextDecoder('utf-8');
const bytes = new Uint8Array(buffer);
let text = '';
// DOCX files are ZIP files containing XML
// We'll look for text content in the raw bytes
// This is very simple and won't work well for most DOCX files
// But it's better than nothing for browser compatibility
for (let i = 0; i < bytes.length; i++) {
// Look for text between XML tags
if (bytes[i] === 60 && bytes[i + 1] === 119 && bytes[i + 2] === 58 && bytes[i + 3] === 116) { // <w:t
// Find the closing >
let j = i + 4;
while (j < bytes.length && bytes[j] !== 62) j++;
j++; // Skip over the >
// Extract text up to </w:t>
let textChunk = '';
while (j < bytes.length &&
!(bytes[j] === 60 && bytes[j + 1] === 47 && bytes[j + 2] === 119 &&
bytes[j + 3] === 58 && bytes[j + 4] === 116)) {
textChunk += String.fromCharCode(bytes[j]);
j++;
}
if (textChunk) {
text += textChunk + ' ';
}
}
}
// If we couldn't extract any text, provide an informative message
if (!text) {
text = "DOCX text extraction in the browser is limited. " +
"The file was uploaded successfully, but extracting its contents requires more advanced processing. " +
"You can still use the file, but you may need to describe its contents in your message.";
}
return {
text,
fileType: 'docx',
charCount: text.length
};
} catch (error) {
console.error('Error extracting DOCX text:', error);
return {
text: 'Error extracting text from DOCX file. DOCX parsing in the browser is limited.',
fileType: 'docx',
error: error instanceof Error ? error.message : 'Unknown error extracting DOCX text',
charCount: 0
};
}
}
/**
* Extract text from a plain text file
*/
export function extractTextFileContent(buffer: ArrayBuffer): ExtractionResult {
try {
// Convert ArrayBuffer to string
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(buffer);
return {
text,
fileType: 'txt',
charCount: text.length
};
} catch (error) {
console.error('Error extracting text file content:', error);
return {
text: '',
fileType: 'txt',
error: error instanceof Error ? error.message : 'Unknown error extracting text',
charCount: 0
};
}
}
/**
* Extract text from CSV file
*/
export function extractCsvContent(buffer: ArrayBuffer): ExtractionResult {
try {
// Convert ArrayBuffer to string
const decoder = new TextDecoder('utf-8');
const csvText = decoder.decode(buffer);
// For CSV, we'll return both raw CSV and a formatted version
// that might be more readable for AI processing
let formattedText = 'CSV DATA:\n\n';
// Simple CSV parsing (handles basic cases)
const rows = csvText.split('\n');
const headers = rows[0].split(',').map(h => h.trim());
// Add headers
formattedText += `Headers: ${headers.join(', ')}\n\n`;
// Process a sample of rows (to avoid extremely large outputs)
const MAX_ROWS = 100;
const processRows = Math.min(rows.length - 1, MAX_ROWS);
const truncated = rows.length - 1 > MAX_ROWS;
formattedText += 'Data:\n';
for (let i = 1; i <= processRows; i++) {
if (!rows[i].trim()) continue;
const values = rows[i].split(',').map(v => v.trim());
let rowText = '';
for (let j = 0; j < headers.length; j++) {
if (j < values.length) {
rowText += `${headers[j]}: ${values[j]}; `;
}
}
formattedText += `Row ${i}: ${rowText}\n`;
}
if (truncated) {
formattedText += `\n[Note: CSV truncated. Only showing ${MAX_ROWS} of ${rows.length - 1} data rows.]\n`;
}
return {
text: formattedText,
fileType: 'csv',
truncated,
metadata: {
headers,
totalRows: rows.length - 1
},
charCount: formattedText.length
};
} catch (error) {
console.error('Error extracting CSV content:', error);
return {
text: '',
fileType: 'csv',
error: error instanceof Error ? error.message : 'Unknown error extracting CSV',
charCount: 0
};
}
}
/**
* Extract JSON content
*/
export function extractJsonContent(buffer: ArrayBuffer): ExtractionResult {
try {
// Convert ArrayBuffer to string
const decoder = new TextDecoder('utf-8');
const jsonText = decoder.decode(buffer);
// Parse JSON to validate it and for potential formatting
const jsonData = JSON.parse(jsonText);
// For large JSON objects, we'll summarize
const jsonString = JSON.stringify(jsonData, null, 2);
let text = jsonString;
let truncated = false;
// Truncate if very large
const MAX_CHARS = 10000;
if (jsonString.length > MAX_CHARS) {
text = jsonString.substring(0, MAX_CHARS) +
`\n\n[Note: JSON content truncated. Showing ${MAX_CHARS} of ${jsonString.length} characters.]\n`;
truncated = true;
}
return {
text,
fileType: 'json',
truncated,
charCount: text.length
};
} catch (error) {
console.error('Error extracting JSON content:', error);
// If JSON parsing fails, return the raw text
try {
const decoder = new TextDecoder('utf-8');
const rawText = decoder.decode(buffer);
return {
text: rawText,
fileType: 'json',
error: 'Invalid JSON format, showing raw content',
charCount: rawText.length
};
} catch (fallbackError) {
return {
text: '',
fileType: 'json',
error: error instanceof Error ? error.message : 'Unknown error extracting JSON',
charCount: 0
};
}
}
}
/**
* Detect file type from file extension
*/
export function detectFileType(file: File): string {
const fileName = file.name.toLowerCase();
const fileType = file.type.toLowerCase();
if (fileType.includes('pdf') || fileName.endsWith('.pdf')) {
return 'pdf';
} else if (fileType.includes('wordprocessingml') || fileName.endsWith('.docx') || fileName.endsWith('.doc')) {
return 'docx';
} else if (fileType.includes('csv') || fileName.endsWith('.csv')) {
return 'csv';
} else if (fileType.includes('json') || fileName.endsWith('.json')) {
return 'json';
} else if (fileType.startsWith('text/') ||
['txt', 'md', 'log', 'xml', 'html', 'js', 'ts', 'css', 'py', 'java'].some(ext => fileName.endsWith(`.${ext}`))) {
return 'text';
}
return 'unknown';
}
/**
* Main function to extract text from a file based on its type
*/
export async function extractTextFromFile(file: File): Promise<ExtractionResult> {
try {
// Read file as ArrayBuffer
const buffer = await file.arrayBuffer();
// Detect file type
const fileExtType = detectFileType(file);
// Extract text based on file type
if (fileExtType === 'pdf') {
return await extractPdfText(buffer);
} else if (fileExtType === 'docx') {
return await extractDocxText(buffer);
} else if (fileExtType === 'csv') {
return extractCsvContent(buffer);
} else if (fileExtType === 'json') {
return extractJsonContent(buffer);
} else if (fileExtType === 'text') {
return extractTextFileContent(buffer);
} else {
return {
text: `File type '${file.type}' is not supported for text extraction.`,
fileType: fileExtType,
error: 'Unsupported file type',
charCount: 0
};
}
} catch (error) {
console.error('Error extracting text from file:', error);
return {
text: '',
fileType: 'unknown',
error: error instanceof Error ? error.message : 'Unknown error extracting text',
charCount: 0
};
}
}
/**
* Get a summary of the extracted text, useful for large documents
*/
export function getTextSummary(result: ExtractionResult, maxLength: number = 200): string {
if (!result.text) return 'No text content extracted.';
const summary = result.text.substring(0, maxLength);
return summary + (result.text.length > maxLength ? '...' : '');
}

275
src/utils/mongoSync.ts Normal file
View File

@ -0,0 +1,275 @@
/**
* MongoDB synchronization utilities
* Provides functions to sync chat data to a MongoDB server
*/
import type { Message } from '../contexts/ChatContext';
import type { UsageStats } from '../contexts/ChatContext';
// Define interfaces for MongoDB data models
export interface SyncConfig {
syncEnabled: boolean;
serverUrl: string;
apiKey?: string;
userId?: string;
syncFrequency: 'realtime' | 'manual' | 'periodic';
lastSyncTimestamp?: number;
collections: {
messages: string;
usage: string;
};
}
export interface MessageDocument extends Message {
createdAt: number;
conversationId: string;
userId?: string;
}
export interface ErrorResponse {
success: false;
error: string;
}
export interface SuccessResponse<T> {
success: true;
data: T;
}
export type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
// Default configuration
export const DEFAULT_SYNC_CONFIG: SyncConfig = {
syncEnabled: false,
serverUrl: '',
syncFrequency: 'manual',
collections: {
messages: 'messages',
usage: 'usage'
}
};
/**
* Initialize MongoDB connection
*/
export const initializeMongoSync = async (config: SyncConfig): Promise<ApiResponse<{ connected: boolean }>> => {
try {
// Test connection to MongoDB server
const response = await fetch(`${config.serverUrl}/api/ping`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(config.apiKey ? { 'X-API-Key': config.apiKey } : {})
}
});
if (!response.ok) {
throw new Error(`Failed to connect to MongoDB server: ${response.statusText}`);
}
return {
success: true,
data: { connected: true }
};
} catch (error) {
console.error('MongoDB connection error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown connection error'
};
}
};
/**
* Sync messages to MongoDB
*/
export const syncMessages = async (
config: SyncConfig,
messages: Message[],
conversationId: string
): Promise<ApiResponse<{ synced: number }>> => {
if (!config.syncEnabled || !config.serverUrl) {
return {
success: false,
error: 'Sync is not enabled or server URL is not configured'
};
}
try {
// Format messages for MongoDB
const messageDocuments: MessageDocument[] = messages.map(message => ({
...message,
createdAt: Date.now(),
conversationId,
userId: config.userId
}));
// Send messages to MongoDB server
const response = await fetch(`${config.serverUrl}/api/${config.collections.messages}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(config.apiKey ? { 'X-API-Key': config.apiKey } : {})
},
body: JSON.stringify({ messages: messageDocuments })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to sync messages');
}
const data = await response.json();
return {
success: true,
data: { synced: data.inserted || messageDocuments.length }
};
} catch (error) {
console.error('Error syncing messages:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown sync error'
};
}
};
/**
* Sync usage statistics to MongoDB
*/
export const syncUsageStats = async (
config: SyncConfig,
usageStats: UsageStats
): Promise<ApiResponse<{ synced: boolean }>> => {
if (!config.syncEnabled || !config.serverUrl) {
return {
success: false,
error: 'Sync is not enabled or server URL is not configured'
};
}
try {
const usageDocument = {
...usageStats,
userId: config.userId,
syncedAt: Date.now()
};
const response = await fetch(`${config.serverUrl}/api/${config.collections.usage}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(config.apiKey ? { 'X-API-Key': config.apiKey } : {})
},
body: JSON.stringify({ usage: usageDocument })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to sync usage statistics');
}
return {
success: true,
data: { synced: true }
};
} catch (error) {
console.error('Error syncing usage stats:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown sync error'
};
}
};
/**
* Fetch conversation history from MongoDB
*/
export const fetchConversations = async (
config: SyncConfig
): Promise<ApiResponse<{ conversations: { id: string, title: string, lastMessage: string, timestamp: number }[] }>> => {
if (!config.syncEnabled || !config.serverUrl) {
return {
success: false,
error: 'Sync is not enabled or server URL is not configured'
};
}
try {
const response = await fetch(`${config.serverUrl}/api/conversations${config.userId ? `?userId=${config.userId}` : ''}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(config.apiKey ? { 'X-API-Key': config.apiKey } : {})
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch conversations');
}
const data = await response.json();
return {
success: true,
data: { conversations: data.conversations || [] }
};
} catch (error) {
console.error('Error fetching conversations:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch conversations'
};
}
};
/**
* Fetch messages for a specific conversation
*/
export const fetchMessages = async (
config: SyncConfig,
conversationId: string
): Promise<ApiResponse<{ messages: MessageDocument[] }>> => {
if (!config.syncEnabled || !config.serverUrl) {
return {
success: false,
error: 'Sync is not enabled or server URL is not configured'
};
}
try {
const response = await fetch(
`${config.serverUrl}/api/${config.collections.messages}?conversationId=${conversationId}${config.userId ? `&userId=${config.userId}` : ''}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(config.apiKey ? { 'X-API-Key': config.apiKey } : {})
}
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch messages');
}
const data = await response.json();
return {
success: true,
data: { messages: data.messages || [] }
};
} catch (error) {
console.error('Error fetching messages:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch messages'
};
}
};
/**
* Generate a new conversation ID
*/
export const generateConversationId = (): string => {
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
};

107
src/utils/streamUtils.ts Normal file
View File

@ -0,0 +1,107 @@
/**
* Utility functions for handling streaming responses from different providers
*/
// Function to decode OpenAI streaming data
export function decodeOpenAIStreamChunk(chunk: Uint8Array): string {
const decoder = new TextDecoder('utf-8');
const decoded = decoder.decode(chunk);
// OpenAI sends 'data: ' prefixed chunks, one per line
// Extract the actual JSON data
const lines = decoded
.split('\n')
.filter(line => line.trim() !== '' && line.trim() !== 'data: [DONE]')
.map(line => line.replace(/^data: /, ''));
if (lines.length === 0) return '';
try {
// Each line should be a JSON object with choices
const parsedLines = lines.map(line => JSON.parse(line));
// Extract content delta from each chunk
const contents = parsedLines
.map(obj => obj.choices?.[0]?.delta?.content || '')
.join('');
return contents;
} catch (error) {
console.error('Error parsing stream data:', error);
return '';
}
}
// Function to decode Anthropic streaming data
export function decodeAnthropicStreamChunk(chunk: Uint8Array): string {
const decoder = new TextDecoder('utf-8');
const decoded = decoder.decode(chunk);
// Anthropic sends 'event: content_block_delta' and 'data: {"type":"content_block_delta",...}'
const contentLines = decoded
.split('\n')
.filter(line => line.startsWith('data:'))
.map(line => line.replace(/^data: /, ''));
if (contentLines.length === 0) return '';
try {
const parsedLines = contentLines.map(line => JSON.parse(line));
// Extract content delta from each chunk
const contents = parsedLines
.map(obj => {
if (obj.type === 'content_block_delta') {
return obj.delta?.text || '';
}
return '';
})
.join('');
return contents;
} catch (error) {
console.error('Error parsing Anthropic stream data:', error);
return '';
}
}
// Function to decode Google streaming data
export function decodeGoogleStreamChunk(chunk: Uint8Array): string {
const decoder = new TextDecoder('utf-8');
const decoded = decoder.decode(chunk);
try {
const data = JSON.parse(decoded);
// Extract content from Google's response format
return data.candidates?.[0]?.content?.parts?.[0]?.text ||
data.candidates?.[0]?.content?.parts?.[0]?.delta?.text || '';
} catch (error) {
console.error('Error parsing Google stream data:', error);
return '';
}
}
// Helper to process streams generically
export async function processStream(
stream: ReadableStream<Uint8Array>,
onChunk: (text: string) => void,
decodeFunction: (chunk: Uint8Array) => string
): Promise<void> {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode the chunk according to the provider format
const text = decodeFunction(value);
if (text) {
onChunk(text);
}
}
} catch (error) {
console.error('Error reading from stream:', error);
throw error;
} finally {
reader.releaseLock();
}
}

267
src/utils/webLLMUtils.ts Normal file
View File

@ -0,0 +1,267 @@
/**
* WebLLM Utilities
* This module provides functions for running LLMs in the browser using WebLLM
*/
// Note: We're importing dynamically to avoid SSR issues
// We'll mock these types for the build process
type ChatModuleType = any;
type InitProgressCallbackType = (report: {
progress: number;
timeElapsed: number;
bytesDownloaded: number;
stage: string;
}) => void;
// Available WebLLM models
export const WEBLLM_MODELS = [
{
id: 'Llama-2-7b-chat-hf-q4f16_1',
name: 'Llama 2 7B Chat (4-bit)',
contextLength: 4096,
description: 'Llama 2 7B Chat model quantized to 4-bit, runs efficiently in browsers'
},
{
id: 'Mistral-7B-Instruct-v0.2-q4f16_1',
name: 'Mistral 7B Instruct v0.2 (4-bit)',
contextLength: 8192,
description: 'Mistral 7B Instruct v0.2 model quantized to 4-bit, good instruction following'
},
{
id: 'RedPajama-INCITE-Chat-3B-v1-q4f16_1',
name: 'RedPajama 3B Chat (4-bit)',
contextLength: 2048,
description: 'RedPajama 3B Chat model quantized to 4-bit, lightweight option'
},
{
id: 'TinyLlama-1.1B-Chat-v1.0-q4f16_1',
name: 'TinyLlama 1.1B Chat (4-bit)',
contextLength: 2048,
description: 'TinyLlama 1.1B Chat model quantized to 4-bit, very lightweight'
}
];
// WebLLM instance
let chatModule: ChatModuleType | null = null;
// Model loading status
export interface ModelLoadingStatus {
isLoading: boolean;
progress: number;
stage: string;
isReady: boolean;
error: string | null;
}
const initialStatus: ModelLoadingStatus = {
isLoading: false,
progress: 0,
stage: '',
isReady: false,
error: null
};
let modelStatus: ModelLoadingStatus = { ...initialStatus };
/**
* Initialize the WebLLM chat module with the specified model
*/
export async function initWebLLM(
modelId: string,
onProgress?: InitProgressCallbackType
): Promise<ModelLoadingStatus> {
try {
modelStatus = { ...initialStatus, isLoading: true };
// Dynamically import WebLLM to avoid SSR issues
const webLLM = await import('@mlc-ai/web-llm');
// Create a new chat module if it doesn't exist
if (!chatModule) {
chatModule = new webLLM.ChatModule();
}
// Custom progress callback to update status
const progressCallback: InitProgressCallbackType = (report) => {
const { progress, timeElapsed, bytesDownloaded, stage } = report;
modelStatus = {
...modelStatus,
progress,
stage
};
// Forward to external callback if provided
if (onProgress) {
onProgress(report);
}
};
// Initialize the model
await chatModule.reload(modelId, progressCallback);
modelStatus = {
isLoading: false,
progress: 100,
stage: 'ready',
isReady: true,
error: null
};
return modelStatus;
} catch (error) {
console.error('Error initializing WebLLM:', error);
modelStatus = {
isLoading: false,
progress: 0,
stage: 'error',
isReady: false,
error: error instanceof Error ? error.message : 'Unknown error initializing WebLLM'
};
return modelStatus;
}
}
/**
* Get the current model loading status
*/
export function getModelStatus(): ModelLoadingStatus {
return modelStatus;
}
/**
* Reset the WebLLM module and status
*/
export async function resetWebLLM(): Promise<void> {
try {
if (chatModule) {
await chatModule.unload();
chatModule = null;
}
modelStatus = { ...initialStatus };
} catch (error) {
console.error('Error resetting WebLLM:', error);
}
}
/**
* Generate chat completion using WebLLM
*/
export async function generateCompletion(
messages: { role: string; content: string }[],
options: {
onTokenCallback?: (token: string) => void;
temperature?: number;
max_tokens?: number;
} = {}
): Promise<{ text: string; usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } }> {
if (!chatModule) {
// Try to dynamically load if not loaded yet
try {
await import('@mlc-ai/web-llm');
} catch (error) {
throw new Error('WebLLM module could not be loaded. Please initialize model first.');
}
}
if (!modelStatus.isReady) {
throw new Error('WebLLM model not loaded. Please initialize model first.');
}
try {
// Format messages for WebLLM
// Convert 'system' and 'developer' roles to 'user' for compatibility
const formattedMessages = messages.map(msg => {
// WebLLM only supports 'user' and 'assistant' roles
const role = msg.role === 'assistant' ? 'assistant' : 'user';
// Prepend special tokens for system or developer messages
let content = msg.content;
if (msg.role === 'system') {
content = `[SYSTEM: ${content}]`;
} else if (msg.role === 'developer') {
content = `[DEVELOPER: ${content}]`;
}
return { role, content };
});
// Generate configuration
const generateConfig = {
temperature: options.temperature ?? 0.7,
max_tokens: options.max_tokens ?? 1024,
stream: !!options.onTokenCallback
};
let fullResponse = '';
let completionTokens = 0;
// If streaming is enabled, handle token callbacks
if (generateConfig.stream && options.onTokenCallback) {
const stream = await chatModule.chatStream(formattedMessages, generateConfig);
for await (const chunk of stream) {
options.onTokenCallback(chunk.output);
fullResponse += chunk.output;
completionTokens++;
}
} else {
// Non-streaming generation
const response = await chatModule.chat(formattedMessages, generateConfig);
fullResponse = response.text;
completionTokens = fullResponse.split(/\s+/).length; // Rough approximation
}
// Estimate token counts
// This is an approximation since WebLLM doesn't provide exact token counts
const promptText = messages.map(m => m.content).join(' ');
const promptTokens = promptText.split(/\s+/).length; // Rough approximation
return {
text: fullResponse,
usage: {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens
}
};
} catch (error) {
console.error('Error generating completion with WebLLM:', error);
throw error;
}
}
/**
* Get the list of supported WebLLM models
*/
export function getWebLLMModels() {
return WEBLLM_MODELS;
}
/**
* Check if WebLLM is supported in the current browser
*/
export function isWebLLMSupported(): boolean {
// Check if running in a browser environment
if (typeof window === 'undefined') {
return false;
}
// Check for WebAssembly support
if (typeof WebAssembly !== 'object') {
return false;
}
// Check for needed browser APIs
const requiredAPIs = [
'fetch' in window,
'SharedArrayBuffer' in window,
'Atomics' in window,
'WebAssembly.compile' in window
];
return requiredAPIs.every(Boolean);
}

71
tailwind.config.js Normal file
View File

@ -0,0 +1,71 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
}

71
tailwind.config.mjs Normal file
View File

@ -0,0 +1,71 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
};

5
tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}