init
commit
c1be9d2bd2
|
@ -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/
|
|
@ -0,0 +1,3 @@
|
||||||
|
so there chatGPT, DeepSeek
|
||||||
|
Lets build with more freedom
|
||||||
|
freedom to run on my system, connect with any other and
|
|
@ -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']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
}
|
|
@ -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 |
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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';
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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 }
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 };
|
|
@ -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,
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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,
|
||||||
|
}
|
|
@ -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 }
|
|
@ -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 };
|
|
@ -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 }
|
|
@ -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,
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 }
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 ? '...' : '');
|
||||||
|
}
|
|
@ -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)}`;
|
||||||
|
};
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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: [],
|
||||||
|
}
|
|
@ -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: [],
|
||||||
|
};
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
Loading…
Reference in New Issue