gpt-engineer-app[bot] commited on
Commit ·
808fe45
1
Parent(s): fea8c8a
Changes
Browse files- .env +3 -0
- index.html +26 -7
- package-lock.json +0 -0
- package.json +4 -1
- public/pwa-192x192.png +0 -0
- public/pwa-512x512.png +0 -0
- src/components/ChatInput.tsx +195 -0
- src/components/ChatInterface.tsx +203 -0
- src/components/ChatMessage.tsx +54 -0
- src/components/ConversationSidebar.tsx +131 -0
- src/components/ScreenSharePreview.tsx +65 -0
- src/components/WelcomeScreen.tsx +79 -0
- src/hooks/useChat.ts +217 -0
- src/hooks/useScreenShare.ts +76 -0
- src/hooks/useVoice.ts +158 -0
- src/index.css +135 -61
- src/integrations/supabase/client.ts +17 -0
- src/integrations/supabase/types.ts +155 -0
- src/pages/Index.tsx +2 -9
- src/types/chat.ts +28 -0
- supabase/config.toml +4 -0
- supabase/functions/chat/index.ts +83 -0
- tailwind.config.ts +21 -12
- vite.config.ts +57 -1
.env
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
VITE_SUPABASE_PROJECT_ID="rxrhjvwnfxtosbneybwa"
|
| 2 |
+
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJ4cmhqdnduZnh0b3NibmV5YndhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkzMTQyNzcsImV4cCI6MjA4NDg5MDI3N30.__UwHyl1SV2LNw-NM2hMpNtfL5b_ae7Pw3Yv9oacCIs"
|
| 3 |
+
VITE_SUPABASE_URL="https://rxrhjvwnfxtosbneybwa.supabase.co"
|
index.html
CHANGED
|
@@ -1,22 +1,41 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<
|
| 7 |
-
<
|
| 8 |
-
<meta name="description" content="Lovable Generated Project" />
|
| 9 |
<meta name="author" content="Lovable" />
|
|
|
|
| 10 |
|
| 11 |
-
<!--
|
| 12 |
-
<
|
| 13 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
<meta property="og:type" content="website" />
|
| 15 |
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
| 16 |
|
|
|
|
| 17 |
<meta name="twitter:card" content="summary_large_image" />
|
| 18 |
<meta name="twitter:site" content="@Lovable" />
|
| 19 |
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
</head>
|
| 21 |
|
| 22 |
<body>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en" class="dark">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>AI Desktop Assistant</title>
|
| 7 |
+
<meta name="description" content="Your intelligent desktop assistant with vision and voice capabilities. Ask questions, share your screen, and get help with any task." />
|
|
|
|
| 8 |
<meta name="author" content="Lovable" />
|
| 9 |
+
<meta name="theme-color" content="#0f172a" />
|
| 10 |
|
| 11 |
+
<!-- PWA Meta Tags -->
|
| 12 |
+
<link rel="manifest" href="/manifest.webmanifest" />
|
| 13 |
+
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
|
| 14 |
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
| 15 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
| 16 |
+
<meta name="apple-mobile-web-app-title" content="AI Assistant" />
|
| 17 |
+
|
| 18 |
+
<!-- Open Graph -->
|
| 19 |
+
<meta property="og:title" content="AI Desktop Assistant" />
|
| 20 |
+
<meta property="og:description" content="Your intelligent desktop assistant with vision and voice capabilities" />
|
| 21 |
<meta property="og:type" content="website" />
|
| 22 |
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
| 23 |
|
| 24 |
+
<!-- Twitter -->
|
| 25 |
<meta name="twitter:card" content="summary_large_image" />
|
| 26 |
<meta name="twitter:site" content="@Lovable" />
|
| 27 |
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
| 28 |
+
|
| 29 |
+
<!-- Fonts -->
|
| 30 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 31 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 32 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
| 33 |
+
|
| 34 |
+
<style>
|
| 35 |
+
body {
|
| 36 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 37 |
+
}
|
| 38 |
+
</style>
|
| 39 |
</head>
|
| 40 |
|
| 41 |
<body>
|
package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -41,12 +41,14 @@
|
|
| 41 |
"@radix-ui/react-toggle": "^1.1.9",
|
| 42 |
"@radix-ui/react-toggle-group": "^1.1.10",
|
| 43 |
"@radix-ui/react-tooltip": "^1.2.7",
|
|
|
|
| 44 |
"@tanstack/react-query": "^5.83.0",
|
| 45 |
"class-variance-authority": "^0.7.1",
|
| 46 |
"clsx": "^2.1.1",
|
| 47 |
"cmdk": "^1.1.1",
|
| 48 |
"date-fns": "^3.6.0",
|
| 49 |
"embla-carousel-react": "^8.6.0",
|
|
|
|
| 50 |
"input-otp": "^1.4.2",
|
| 51 |
"lucide-react": "^0.462.0",
|
| 52 |
"next-themes": "^0.3.0",
|
|
@@ -61,13 +63,14 @@
|
|
| 61 |
"tailwind-merge": "^2.6.0",
|
| 62 |
"tailwindcss-animate": "^1.0.7",
|
| 63 |
"vaul": "^0.9.9",
|
|
|
|
| 64 |
"zod": "^3.25.76"
|
| 65 |
},
|
| 66 |
"devDependencies": {
|
| 67 |
"@eslint/js": "^9.32.0",
|
|
|
|
| 68 |
"@testing-library/jest-dom": "^6.6.0",
|
| 69 |
"@testing-library/react": "^16.0.0",
|
| 70 |
-
"@tailwindcss/typography": "^0.5.16",
|
| 71 |
"@types/node": "^22.16.5",
|
| 72 |
"@types/react": "^18.3.23",
|
| 73 |
"@types/react-dom": "^18.3.7",
|
|
|
|
| 41 |
"@radix-ui/react-toggle": "^1.1.9",
|
| 42 |
"@radix-ui/react-toggle-group": "^1.1.10",
|
| 43 |
"@radix-ui/react-tooltip": "^1.2.7",
|
| 44 |
+
"@supabase/supabase-js": "^2.91.1",
|
| 45 |
"@tanstack/react-query": "^5.83.0",
|
| 46 |
"class-variance-authority": "^0.7.1",
|
| 47 |
"clsx": "^2.1.1",
|
| 48 |
"cmdk": "^1.1.1",
|
| 49 |
"date-fns": "^3.6.0",
|
| 50 |
"embla-carousel-react": "^8.6.0",
|
| 51 |
+
"framer-motion": "^12.29.0",
|
| 52 |
"input-otp": "^1.4.2",
|
| 53 |
"lucide-react": "^0.462.0",
|
| 54 |
"next-themes": "^0.3.0",
|
|
|
|
| 63 |
"tailwind-merge": "^2.6.0",
|
| 64 |
"tailwindcss-animate": "^1.0.7",
|
| 65 |
"vaul": "^0.9.9",
|
| 66 |
+
"vite-plugin-pwa": "^1.2.0",
|
| 67 |
"zod": "^3.25.76"
|
| 68 |
},
|
| 69 |
"devDependencies": {
|
| 70 |
"@eslint/js": "^9.32.0",
|
| 71 |
+
"@tailwindcss/typography": "^0.5.16",
|
| 72 |
"@testing-library/jest-dom": "^6.6.0",
|
| 73 |
"@testing-library/react": "^16.0.0",
|
|
|
|
| 74 |
"@types/node": "^22.16.5",
|
| 75 |
"@types/react": "^18.3.23",
|
| 76 |
"@types/react-dom": "^18.3.7",
|
public/pwa-192x192.png
ADDED
|
public/pwa-512x512.png
ADDED
|
src/components/ChatInput.tsx
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from "react";
|
| 2 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 3 |
+
import { Send, Mic, MicOff, Monitor, MonitorOff, Volume2, VolumeX } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 6 |
+
import { cn } from "@/lib/utils";
|
| 7 |
+
|
| 8 |
+
interface ChatInputProps {
|
| 9 |
+
onSendMessage: (message: string) => void;
|
| 10 |
+
isLoading: boolean;
|
| 11 |
+
isListening: boolean;
|
| 12 |
+
isSpeaking: boolean;
|
| 13 |
+
isSharing: boolean;
|
| 14 |
+
voiceEnabled: boolean;
|
| 15 |
+
transcript: string;
|
| 16 |
+
onStartListening: () => void;
|
| 17 |
+
onStopListening: () => void;
|
| 18 |
+
onToggleVoice: () => void;
|
| 19 |
+
onToggleScreenShare: () => void;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function ChatInput({
|
| 23 |
+
onSendMessage,
|
| 24 |
+
isLoading,
|
| 25 |
+
isListening,
|
| 26 |
+
isSpeaking,
|
| 27 |
+
isSharing,
|
| 28 |
+
voiceEnabled,
|
| 29 |
+
transcript,
|
| 30 |
+
onStartListening,
|
| 31 |
+
onStopListening,
|
| 32 |
+
onToggleVoice,
|
| 33 |
+
onToggleScreenShare,
|
| 34 |
+
}: ChatInputProps) {
|
| 35 |
+
const [input, setInput] = useState("");
|
| 36 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 37 |
+
|
| 38 |
+
// Update input with voice transcript
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
if (transcript && !isListening) {
|
| 41 |
+
setInput(transcript);
|
| 42 |
+
}
|
| 43 |
+
}, [transcript, isListening]);
|
| 44 |
+
|
| 45 |
+
const handleSubmit = () => {
|
| 46 |
+
if (input.trim() && !isLoading) {
|
| 47 |
+
onSendMessage(input.trim());
|
| 48 |
+
setInput("");
|
| 49 |
+
if (textareaRef.current) {
|
| 50 |
+
textareaRef.current.style.height = "auto";
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 56 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 57 |
+
e.preventDefault();
|
| 58 |
+
handleSubmit();
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
| 63 |
+
setInput(e.target.value);
|
| 64 |
+
// Auto-resize
|
| 65 |
+
const textarea = e.target;
|
| 66 |
+
textarea.style.height = "auto";
|
| 67 |
+
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<div className="p-4 border-t border-border bg-background/50 backdrop-blur-sm">
|
| 72 |
+
<div className="max-w-3xl mx-auto">
|
| 73 |
+
{/* Voice transcript indicator */}
|
| 74 |
+
<AnimatePresence>
|
| 75 |
+
{isListening && (
|
| 76 |
+
<motion.div
|
| 77 |
+
initial={{ opacity: 0, y: 10 }}
|
| 78 |
+
animate={{ opacity: 1, y: 0 }}
|
| 79 |
+
exit={{ opacity: 0, y: 10 }}
|
| 80 |
+
className="mb-3 px-4 py-2 rounded-lg bg-primary/10 border border-primary/20"
|
| 81 |
+
>
|
| 82 |
+
<div className="flex items-center gap-2">
|
| 83 |
+
<div className="flex gap-1">
|
| 84 |
+
{[...Array(3)].map((_, i) => (
|
| 85 |
+
<div
|
| 86 |
+
key={i}
|
| 87 |
+
className="w-1 h-4 bg-primary rounded-full animate-voice-wave"
|
| 88 |
+
style={{ animationDelay: `${i * 0.1}s` }}
|
| 89 |
+
/>
|
| 90 |
+
))}
|
| 91 |
+
</div>
|
| 92 |
+
<span className="text-sm text-primary">
|
| 93 |
+
{transcript || "Listening..."}
|
| 94 |
+
</span>
|
| 95 |
+
</div>
|
| 96 |
+
</motion.div>
|
| 97 |
+
)}
|
| 98 |
+
</AnimatePresence>
|
| 99 |
+
|
| 100 |
+
<div className="flex items-end gap-2">
|
| 101 |
+
{/* Screen Share Button */}
|
| 102 |
+
<Button
|
| 103 |
+
variant="ghost"
|
| 104 |
+
size="icon"
|
| 105 |
+
onClick={onToggleScreenShare}
|
| 106 |
+
className={cn(
|
| 107 |
+
"shrink-0 rounded-full transition-colors",
|
| 108 |
+
isSharing
|
| 109 |
+
? "bg-primary/20 text-primary hover:bg-primary/30"
|
| 110 |
+
: "text-muted-foreground hover:text-foreground"
|
| 111 |
+
)}
|
| 112 |
+
title={isSharing ? "Stop sharing screen" : "Share screen"}
|
| 113 |
+
>
|
| 114 |
+
{isSharing ? (
|
| 115 |
+
<MonitorOff className="h-5 w-5" />
|
| 116 |
+
) : (
|
| 117 |
+
<Monitor className="h-5 w-5" />
|
| 118 |
+
)}
|
| 119 |
+
</Button>
|
| 120 |
+
|
| 121 |
+
{/* Voice Toggle Button */}
|
| 122 |
+
<Button
|
| 123 |
+
variant="ghost"
|
| 124 |
+
size="icon"
|
| 125 |
+
onClick={onToggleVoice}
|
| 126 |
+
className={cn(
|
| 127 |
+
"shrink-0 rounded-full transition-colors",
|
| 128 |
+
voiceEnabled
|
| 129 |
+
? "bg-primary/20 text-primary hover:bg-primary/30"
|
| 130 |
+
: "text-muted-foreground hover:text-foreground"
|
| 131 |
+
)}
|
| 132 |
+
title={voiceEnabled ? "Disable voice responses" : "Enable voice responses"}
|
| 133 |
+
>
|
| 134 |
+
{voiceEnabled ? (
|
| 135 |
+
<Volume2 className="h-5 w-5" />
|
| 136 |
+
) : (
|
| 137 |
+
<VolumeX className="h-5 w-5" />
|
| 138 |
+
)}
|
| 139 |
+
</Button>
|
| 140 |
+
|
| 141 |
+
{/* Input Area */}
|
| 142 |
+
<div className="flex-1 relative">
|
| 143 |
+
<Textarea
|
| 144 |
+
ref={textareaRef}
|
| 145 |
+
value={input}
|
| 146 |
+
onChange={handleTextareaChange}
|
| 147 |
+
onKeyDown={handleKeyDown}
|
| 148 |
+
placeholder="Ask me anything..."
|
| 149 |
+
className="min-h-[48px] max-h-[200px] resize-none pr-24 rounded-2xl bg-input border-border focus-visible:ring-primary"
|
| 150 |
+
disabled={isLoading}
|
| 151 |
+
rows={1}
|
| 152 |
+
/>
|
| 153 |
+
|
| 154 |
+
{/* Action buttons inside textarea */}
|
| 155 |
+
<div className="absolute right-2 bottom-2 flex items-center gap-1">
|
| 156 |
+
{/* Mic Button */}
|
| 157 |
+
<Button
|
| 158 |
+
variant="ghost"
|
| 159 |
+
size="icon"
|
| 160 |
+
onClick={isListening ? onStopListening : onStartListening}
|
| 161 |
+
className={cn(
|
| 162 |
+
"h-8 w-8 rounded-full transition-all",
|
| 163 |
+
isListening
|
| 164 |
+
? "bg-red-500/20 text-red-500 hover:bg-red-500/30 animate-pulse-glow"
|
| 165 |
+
: "text-muted-foreground hover:text-foreground"
|
| 166 |
+
)}
|
| 167 |
+
disabled={isLoading}
|
| 168 |
+
>
|
| 169 |
+
{isListening ? (
|
| 170 |
+
<MicOff className="h-4 w-4" />
|
| 171 |
+
) : (
|
| 172 |
+
<Mic className="h-4 w-4" />
|
| 173 |
+
)}
|
| 174 |
+
</Button>
|
| 175 |
+
|
| 176 |
+
{/* Send Button */}
|
| 177 |
+
<Button
|
| 178 |
+
size="icon"
|
| 179 |
+
onClick={handleSubmit}
|
| 180 |
+
disabled={!input.trim() || isLoading}
|
| 181 |
+
className="h-8 w-8 rounded-full bg-primary hover:bg-primary/90"
|
| 182 |
+
>
|
| 183 |
+
<Send className="h-4 w-4" />
|
| 184 |
+
</Button>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<p className="mt-2 text-center text-xs text-muted-foreground">
|
| 190 |
+
AI can make mistakes. Consider checking important information.
|
| 191 |
+
</p>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
}
|
src/components/ChatInterface.tsx
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useEffect, useState } from "react";
|
| 2 |
+
import { AnimatePresence } from "framer-motion";
|
| 3 |
+
import { Menu, PanelLeftClose } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 6 |
+
import { ChatMessage } from "@/components/ChatMessage";
|
| 7 |
+
import { ChatInput } from "@/components/ChatInput";
|
| 8 |
+
import { WelcomeScreen } from "@/components/WelcomeScreen";
|
| 9 |
+
import { ScreenSharePreview } from "@/components/ScreenSharePreview";
|
| 10 |
+
import { ConversationSidebar } from "@/components/ConversationSidebar";
|
| 11 |
+
import { useChat } from "@/hooks/useChat";
|
| 12 |
+
import { useVoice } from "@/hooks/useVoice";
|
| 13 |
+
import { useScreenShare } from "@/hooks/useScreenShare";
|
| 14 |
+
import { cn } from "@/lib/utils";
|
| 15 |
+
|
| 16 |
+
export function ChatInterface() {
|
| 17 |
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 18 |
+
const [voiceEnabled, setVoiceEnabled] = useState(false);
|
| 19 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 20 |
+
|
| 21 |
+
const {
|
| 22 |
+
conversations,
|
| 23 |
+
activeConversation,
|
| 24 |
+
activeConversationId,
|
| 25 |
+
messages,
|
| 26 |
+
isLoading,
|
| 27 |
+
createConversation,
|
| 28 |
+
setActiveConversationId,
|
| 29 |
+
deleteConversation,
|
| 30 |
+
sendMessage,
|
| 31 |
+
} = useChat();
|
| 32 |
+
|
| 33 |
+
const {
|
| 34 |
+
isListening,
|
| 35 |
+
isSpeaking,
|
| 36 |
+
transcript,
|
| 37 |
+
startListening,
|
| 38 |
+
stopListening,
|
| 39 |
+
speak,
|
| 40 |
+
stopSpeaking,
|
| 41 |
+
} = useVoice();
|
| 42 |
+
|
| 43 |
+
const {
|
| 44 |
+
isSharing,
|
| 45 |
+
stream,
|
| 46 |
+
videoRef,
|
| 47 |
+
canvasRef,
|
| 48 |
+
startSharing,
|
| 49 |
+
stopSharing,
|
| 50 |
+
getScreenDescription,
|
| 51 |
+
} = useScreenShare();
|
| 52 |
+
|
| 53 |
+
// Auto-scroll to bottom when new messages arrive
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
if (scrollRef.current) {
|
| 56 |
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 57 |
+
}
|
| 58 |
+
}, [messages]);
|
| 59 |
+
|
| 60 |
+
// Speak assistant responses if voice is enabled
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
if (voiceEnabled && messages.length > 0) {
|
| 63 |
+
const lastMessage = messages[messages.length - 1];
|
| 64 |
+
if (lastMessage.role === "assistant" && !lastMessage.isStreaming) {
|
| 65 |
+
speak(lastMessage.content);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
}, [messages, voiceEnabled, speak]);
|
| 69 |
+
|
| 70 |
+
const handleSendMessage = async (content: string) => {
|
| 71 |
+
let screenContext: string | undefined;
|
| 72 |
+
|
| 73 |
+
if (isSharing) {
|
| 74 |
+
const description = await getScreenDescription();
|
| 75 |
+
if (description) {
|
| 76 |
+
screenContext = "User is sharing their screen. The current screen shows: " + description;
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
sendMessage(content, screenContext);
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const handleToggleScreenShare = async () => {
|
| 84 |
+
if (isSharing) {
|
| 85 |
+
stopSharing();
|
| 86 |
+
} else {
|
| 87 |
+
await startSharing();
|
| 88 |
+
}
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
const handleToggleVoice = () => {
|
| 92 |
+
if (voiceEnabled) {
|
| 93 |
+
stopSpeaking();
|
| 94 |
+
}
|
| 95 |
+
setVoiceEnabled(!voiceEnabled);
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const handleVoiceSubmit = () => {
|
| 99 |
+
if (transcript) {
|
| 100 |
+
handleSendMessage(transcript);
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
// Submit voice input when user stops speaking
|
| 105 |
+
useEffect(() => {
|
| 106 |
+
if (!isListening && transcript) {
|
| 107 |
+
handleVoiceSubmit();
|
| 108 |
+
}
|
| 109 |
+
}, [isListening]);
|
| 110 |
+
|
| 111 |
+
return (
|
| 112 |
+
<div className="flex h-screen bg-background">
|
| 113 |
+
{/* Hidden video and canvas for screen capture */}
|
| 114 |
+
<video ref={videoRef} className="hidden" autoPlay muted playsInline />
|
| 115 |
+
<canvas ref={canvasRef} className="hidden" />
|
| 116 |
+
|
| 117 |
+
{/* Sidebar */}
|
| 118 |
+
<AnimatePresence mode="wait">
|
| 119 |
+
{sidebarOpen && (
|
| 120 |
+
<div className="w-72 border-r border-border shrink-0">
|
| 121 |
+
<ConversationSidebar
|
| 122 |
+
conversations={conversations}
|
| 123 |
+
activeConversationId={activeConversationId}
|
| 124 |
+
onSelectConversation={setActiveConversationId}
|
| 125 |
+
onNewConversation={createConversation}
|
| 126 |
+
onDeleteConversation={deleteConversation}
|
| 127 |
+
/>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
</AnimatePresence>
|
| 131 |
+
|
| 132 |
+
{/* Main Chat Area */}
|
| 133 |
+
<div className="flex-1 flex flex-col min-w-0">
|
| 134 |
+
{/* Header */}
|
| 135 |
+
<header className="h-14 px-4 flex items-center justify-between border-b border-border bg-background/80 backdrop-blur-sm">
|
| 136 |
+
<div className="flex items-center gap-3">
|
| 137 |
+
<Button
|
| 138 |
+
variant="ghost"
|
| 139 |
+
size="icon"
|
| 140 |
+
onClick={() => setSidebarOpen(!sidebarOpen)}
|
| 141 |
+
className="text-muted-foreground hover:text-foreground"
|
| 142 |
+
>
|
| 143 |
+
{sidebarOpen ? (
|
| 144 |
+
<PanelLeftClose className="h-5 w-5" />
|
| 145 |
+
) : (
|
| 146 |
+
<Menu className="h-5 w-5" />
|
| 147 |
+
)}
|
| 148 |
+
</Button>
|
| 149 |
+
<h1 className="font-semibold text-foreground">
|
| 150 |
+
{activeConversation?.title || "AI Assistant"}
|
| 151 |
+
</h1>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
{isSharing && (
|
| 155 |
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
|
| 156 |
+
<div className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
| 157 |
+
<span className="text-xs font-medium text-primary">Screen Sharing Active</span>
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
</header>
|
| 161 |
+
|
| 162 |
+
{/* Messages Area */}
|
| 163 |
+
<ScrollArea className="flex-1" ref={scrollRef}>
|
| 164 |
+
<div className="max-w-3xl mx-auto px-4">
|
| 165 |
+
{messages.length === 0 ? (
|
| 166 |
+
<WelcomeScreen onSuggestionClick={handleSendMessage} />
|
| 167 |
+
) : (
|
| 168 |
+
<div className="py-4">
|
| 169 |
+
{messages.map((message) => (
|
| 170 |
+
<ChatMessage key={message.id} message={message} />
|
| 171 |
+
))}
|
| 172 |
+
</div>
|
| 173 |
+
)}
|
| 174 |
+
</div>
|
| 175 |
+
</ScrollArea>
|
| 176 |
+
|
| 177 |
+
{/* Input Area */}
|
| 178 |
+
<ChatInput
|
| 179 |
+
onSendMessage={handleSendMessage}
|
| 180 |
+
isLoading={isLoading}
|
| 181 |
+
isListening={isListening}
|
| 182 |
+
isSpeaking={isSpeaking}
|
| 183 |
+
isSharing={isSharing}
|
| 184 |
+
voiceEnabled={voiceEnabled}
|
| 185 |
+
transcript={transcript}
|
| 186 |
+
onStartListening={startListening}
|
| 187 |
+
onStopListening={stopListening}
|
| 188 |
+
onToggleVoice={handleToggleVoice}
|
| 189 |
+
onToggleScreenShare={handleToggleScreenShare}
|
| 190 |
+
/>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
{/* Screen Share Preview */}
|
| 194 |
+
<AnimatePresence>
|
| 195 |
+
<ScreenSharePreview
|
| 196 |
+
stream={stream}
|
| 197 |
+
isSharing={isSharing}
|
| 198 |
+
onStop={stopSharing}
|
| 199 |
+
/>
|
| 200 |
+
</AnimatePresence>
|
| 201 |
+
</div>
|
| 202 |
+
);
|
| 203 |
+
}
|
src/components/ChatMessage.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from "framer-motion";
|
| 2 |
+
import { User, Bot } from "lucide-react";
|
| 3 |
+
import { Message } from "@/types/chat";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
interface ChatMessageProps {
|
| 7 |
+
message: Message;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function ChatMessage({ message }: ChatMessageProps) {
|
| 11 |
+
const isUser = message.role === "user";
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<motion.div
|
| 15 |
+
initial={{ opacity: 0, y: 10 }}
|
| 16 |
+
animate={{ opacity: 1, y: 0 }}
|
| 17 |
+
transition={{ duration: 0.2 }}
|
| 18 |
+
className={cn("flex gap-3 py-4", isUser ? "flex-row-reverse" : "")}
|
| 19 |
+
>
|
| 20 |
+
{/* Avatar */}
|
| 21 |
+
<div
|
| 22 |
+
className={cn(
|
| 23 |
+
"flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center",
|
| 24 |
+
isUser
|
| 25 |
+
? "bg-primary text-primary-foreground"
|
| 26 |
+
: "bg-accent text-accent-foreground"
|
| 27 |
+
)}
|
| 28 |
+
>
|
| 29 |
+
{isUser ? (
|
| 30 |
+
<User className="h-4 w-4" />
|
| 31 |
+
) : (
|
| 32 |
+
<Bot className="h-4 w-4" />
|
| 33 |
+
)}
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
{/* Message Bubble */}
|
| 37 |
+
<div
|
| 38 |
+
className={cn(
|
| 39 |
+
"max-w-[80%] rounded-2xl px-4 py-3",
|
| 40 |
+
isUser
|
| 41 |
+
? "bg-primary text-primary-foreground rounded-tr-sm"
|
| 42 |
+
: "bg-card text-card-foreground rounded-tl-sm border border-border"
|
| 43 |
+
)}
|
| 44 |
+
>
|
| 45 |
+
<p className="text-sm whitespace-pre-wrap leading-relaxed">
|
| 46 |
+
{message.content}
|
| 47 |
+
{message.isStreaming && (
|
| 48 |
+
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse rounded-sm" />
|
| 49 |
+
)}
|
| 50 |
+
</p>
|
| 51 |
+
</div>
|
| 52 |
+
</motion.div>
|
| 53 |
+
);
|
| 54 |
+
}
|
src/components/ConversationSidebar.tsx
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from "framer-motion";
|
| 2 |
+
import { Plus, MessageSquare, Trash2 } from "lucide-react";
|
| 3 |
+
import { Button } from "@/components/ui/button";
|
| 4 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 5 |
+
import { Conversation } from "@/types/chat";
|
| 6 |
+
import { cn } from "@/lib/utils";
|
| 7 |
+
|
| 8 |
+
interface ConversationSidebarProps {
|
| 9 |
+
conversations: Conversation[];
|
| 10 |
+
activeConversationId: string | null;
|
| 11 |
+
onSelectConversation: (id: string) => void;
|
| 12 |
+
onNewConversation: () => void;
|
| 13 |
+
onDeleteConversation: (id: string) => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function ConversationSidebar({
|
| 17 |
+
conversations,
|
| 18 |
+
activeConversationId,
|
| 19 |
+
onSelectConversation,
|
| 20 |
+
onNewConversation,
|
| 21 |
+
onDeleteConversation,
|
| 22 |
+
}: ConversationSidebarProps) {
|
| 23 |
+
const groupedConversations = groupByDate(conversations);
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="flex flex-col h-full bg-sidebar">
|
| 27 |
+
{/* Header */}
|
| 28 |
+
<div className="p-4 border-b border-sidebar-border">
|
| 29 |
+
<Button
|
| 30 |
+
onClick={onNewConversation}
|
| 31 |
+
className="w-full gap-2 bg-primary/10 hover:bg-primary/20 text-primary border-0"
|
| 32 |
+
variant="outline"
|
| 33 |
+
>
|
| 34 |
+
<Plus className="h-4 w-4" />
|
| 35 |
+
New chat
|
| 36 |
+
</Button>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
{/* Conversation List */}
|
| 40 |
+
<ScrollArea className="flex-1 px-2">
|
| 41 |
+
<div className="py-2 space-y-4">
|
| 42 |
+
{Object.entries(groupedConversations).map(([group, items]) => (
|
| 43 |
+
<div key={group}>
|
| 44 |
+
<h3 className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
| 45 |
+
{group}
|
| 46 |
+
</h3>
|
| 47 |
+
<div className="space-y-1">
|
| 48 |
+
{items.map((conversation) => (
|
| 49 |
+
<motion.div
|
| 50 |
+
key={conversation.id}
|
| 51 |
+
initial={{ opacity: 0, x: -10 }}
|
| 52 |
+
animate={{ opacity: 1, x: 0 }}
|
| 53 |
+
className="group relative"
|
| 54 |
+
>
|
| 55 |
+
<button
|
| 56 |
+
onClick={() => onSelectConversation(conversation.id)}
|
| 57 |
+
className={cn(
|
| 58 |
+
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors",
|
| 59 |
+
activeConversationId === conversation.id
|
| 60 |
+
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
| 61 |
+
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
|
| 62 |
+
)}
|
| 63 |
+
>
|
| 64 |
+
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
|
| 65 |
+
<span className="truncate text-sm">
|
| 66 |
+
{conversation.title || "New conversation"}
|
| 67 |
+
</span>
|
| 68 |
+
</button>
|
| 69 |
+
<Button
|
| 70 |
+
size="icon"
|
| 71 |
+
variant="ghost"
|
| 72 |
+
className="absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
| 73 |
+
onClick={(e) => {
|
| 74 |
+
e.stopPropagation();
|
| 75 |
+
onDeleteConversation(conversation.id);
|
| 76 |
+
}}
|
| 77 |
+
>
|
| 78 |
+
<Trash2 className="h-3.5 w-3.5" />
|
| 79 |
+
</Button>
|
| 80 |
+
</motion.div>
|
| 81 |
+
))}
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
))}
|
| 85 |
+
|
| 86 |
+
{conversations.length === 0 && (
|
| 87 |
+
<div className="px-3 py-8 text-center text-muted-foreground text-sm">
|
| 88 |
+
No conversations yet
|
| 89 |
+
</div>
|
| 90 |
+
)}
|
| 91 |
+
</div>
|
| 92 |
+
</ScrollArea>
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function groupByDate(conversations: Conversation[]): Record<string, Conversation[]> {
|
| 98 |
+
const today = new Date();
|
| 99 |
+
const yesterday = new Date(today);
|
| 100 |
+
yesterday.setDate(yesterday.getDate() - 1);
|
| 101 |
+
|
| 102 |
+
const groups: Record<string, Conversation[]> = {};
|
| 103 |
+
|
| 104 |
+
conversations.forEach((conversation) => {
|
| 105 |
+
const date = new Date(conversation.updatedAt);
|
| 106 |
+
let group: string;
|
| 107 |
+
|
| 108 |
+
if (isSameDay(date, today)) {
|
| 109 |
+
group = "Today";
|
| 110 |
+
} else if (isSameDay(date, yesterday)) {
|
| 111 |
+
group = "Yesterday";
|
| 112 |
+
} else {
|
| 113 |
+
group = "Older";
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
if (!groups[group]) {
|
| 117 |
+
groups[group] = [];
|
| 118 |
+
}
|
| 119 |
+
groups[group].push(conversation);
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
return groups;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function isSameDay(date1: Date, date2: Date): boolean {
|
| 126 |
+
return (
|
| 127 |
+
date1.getFullYear() === date2.getFullYear() &&
|
| 128 |
+
date1.getMonth() === date2.getMonth() &&
|
| 129 |
+
date1.getDate() === date2.getDate()
|
| 130 |
+
);
|
| 131 |
+
}
|
src/components/ScreenSharePreview.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useEffect } from "react";
|
| 2 |
+
import { motion } from "framer-motion";
|
| 3 |
+
import { X, Eye } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
|
| 6 |
+
interface ScreenSharePreviewProps {
|
| 7 |
+
stream: MediaStream | null;
|
| 8 |
+
isSharing: boolean;
|
| 9 |
+
onStop: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function ScreenSharePreview({ stream, isSharing, onStop }: ScreenSharePreviewProps) {
|
| 13 |
+
const videoRef = useRef<HTMLVideoElement>(null);
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
if (videoRef.current && stream) {
|
| 17 |
+
videoRef.current.srcObject = stream;
|
| 18 |
+
}
|
| 19 |
+
}, [stream]);
|
| 20 |
+
|
| 21 |
+
if (!isSharing || !stream) return null;
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<motion.div
|
| 25 |
+
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
| 26 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 27 |
+
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
| 28 |
+
className="fixed bottom-24 right-4 w-64 rounded-xl overflow-hidden shadow-2xl border border-border bg-card"
|
| 29 |
+
>
|
| 30 |
+
{/* Header */}
|
| 31 |
+
<div className="flex items-center justify-between px-3 py-2 bg-primary/10 border-b border-border">
|
| 32 |
+
<div className="flex items-center gap-2">
|
| 33 |
+
<Eye className="h-4 w-4 text-primary" />
|
| 34 |
+
<span className="text-xs font-medium text-foreground">Screen Sharing</span>
|
| 35 |
+
</div>
|
| 36 |
+
<Button
|
| 37 |
+
variant="ghost"
|
| 38 |
+
size="icon"
|
| 39 |
+
onClick={onStop}
|
| 40 |
+
className="h-6 w-6 rounded-full hover:bg-destructive/20 hover:text-destructive"
|
| 41 |
+
>
|
| 42 |
+
<X className="h-3.5 w-3.5" />
|
| 43 |
+
</Button>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
{/* Video Preview */}
|
| 47 |
+
<div className="relative aspect-video bg-black">
|
| 48 |
+
<video
|
| 49 |
+
ref={videoRef}
|
| 50 |
+
autoPlay
|
| 51 |
+
muted
|
| 52 |
+
playsInline
|
| 53 |
+
className="w-full h-full object-contain"
|
| 54 |
+
/>
|
| 55 |
+
<div className="absolute inset-0 ring-2 ring-primary/50 ring-inset rounded-b-xl pointer-events-none" />
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
{/* Status */}
|
| 59 |
+
<div className="px-3 py-2 flex items-center gap-2">
|
| 60 |
+
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
| 61 |
+
<span className="text-xs text-muted-foreground">AI can see your screen</span>
|
| 62 |
+
</div>
|
| 63 |
+
</motion.div>
|
| 64 |
+
);
|
| 65 |
+
}
|
src/components/WelcomeScreen.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from "react";
|
| 2 |
+
import { motion } from "framer-motion";
|
| 3 |
+
import { Bot } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
interface WelcomeScreenProps {
|
| 6 |
+
onSuggestionClick: (suggestion: string) => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const suggestions = [
|
| 10 |
+
{ text: "Write a first draft", description: "of an email, essay, or document" },
|
| 11 |
+
{ text: "Get advice", description: "on a project, decision, or idea" },
|
| 12 |
+
{ text: "Learn something new", description: "ask about any topic" },
|
| 13 |
+
{ text: "Analyze my screen", description: "share your screen and ask questions" },
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
+
export function WelcomeScreen({ onSuggestionClick }: WelcomeScreenProps) {
|
| 17 |
+
const getGreeting = () => {
|
| 18 |
+
const hour = new Date().getHours();
|
| 19 |
+
if (hour < 12) return "Good morning";
|
| 20 |
+
if (hour < 18) return "Good afternoon";
|
| 21 |
+
return "Good evening";
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className="flex flex-col items-center justify-center min-h-[60vh] px-4">
|
| 26 |
+
<motion.div
|
| 27 |
+
initial={{ scale: 0.8, opacity: 0 }}
|
| 28 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 29 |
+
transition={{ duration: 0.5, type: "spring" }}
|
| 30 |
+
className="mb-8"
|
| 31 |
+
>
|
| 32 |
+
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary to-blue-400 flex items-center justify-center glow">
|
| 33 |
+
<Bot className="h-10 w-10 text-primary-foreground" />
|
| 34 |
+
</div>
|
| 35 |
+
</motion.div>
|
| 36 |
+
|
| 37 |
+
<motion.h1
|
| 38 |
+
initial={{ y: 20, opacity: 0 }}
|
| 39 |
+
animate={{ y: 0, opacity: 1 }}
|
| 40 |
+
transition={{ delay: 0.1 }}
|
| 41 |
+
className="text-3xl font-semibold text-foreground mb-2"
|
| 42 |
+
>
|
| 43 |
+
{getGreeting()}
|
| 44 |
+
</motion.h1>
|
| 45 |
+
|
| 46 |
+
<motion.p
|
| 47 |
+
initial={{ y: 20, opacity: 0 }}
|
| 48 |
+
animate={{ y: 0, opacity: 1 }}
|
| 49 |
+
transition={{ delay: 0.2 }}
|
| 50 |
+
className="text-lg text-muted-foreground mb-10"
|
| 51 |
+
>
|
| 52 |
+
What can I help you with today?
|
| 53 |
+
</motion.p>
|
| 54 |
+
|
| 55 |
+
<motion.div
|
| 56 |
+
initial={{ y: 20, opacity: 0 }}
|
| 57 |
+
animate={{ y: 0, opacity: 1 }}
|
| 58 |
+
transition={{ delay: 0.3 }}
|
| 59 |
+
className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-2xl"
|
| 60 |
+
>
|
| 61 |
+
{suggestions.map((suggestion, index) => (
|
| 62 |
+
<motion.button
|
| 63 |
+
key={index}
|
| 64 |
+
initial={{ opacity: 0, y: 10 }}
|
| 65 |
+
animate={{ opacity: 1, y: 0 }}
|
| 66 |
+
transition={{ delay: 0.4 + index * 0.1 }}
|
| 67 |
+
onClick={() => onSuggestionClick(suggestion.text)}
|
| 68 |
+
className="group p-4 rounded-xl bg-card hover:bg-accent border border-border hover:border-primary/50 text-left transition-all duration-200"
|
| 69 |
+
>
|
| 70 |
+
<p className="font-medium text-foreground group-hover:text-primary transition-colors">
|
| 71 |
+
{suggestion.text}
|
| 72 |
+
</p>
|
| 73 |
+
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
|
| 74 |
+
</motion.button>
|
| 75 |
+
))}
|
| 76 |
+
</motion.div>
|
| 77 |
+
</div>
|
| 78 |
+
);
|
| 79 |
+
}
|
src/hooks/useChat.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "react";
|
| 2 |
+
import { Message, Conversation } from "@/types/chat";
|
| 3 |
+
|
| 4 |
+
const CHAT_URL = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/chat`;
|
| 5 |
+
|
| 6 |
+
export function useChat() {
|
| 7 |
+
const [conversations, setConversations] = useState<Conversation[]>([]);
|
| 8 |
+
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
| 9 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 10 |
+
|
| 11 |
+
const activeConversation = conversations.find((c) => c.id === activeConversationId);
|
| 12 |
+
const messages = activeConversation?.messages || [];
|
| 13 |
+
|
| 14 |
+
const createConversation = useCallback(() => {
|
| 15 |
+
const newConversation: Conversation = {
|
| 16 |
+
id: crypto.randomUUID(),
|
| 17 |
+
title: "New conversation",
|
| 18 |
+
messages: [],
|
| 19 |
+
createdAt: new Date(),
|
| 20 |
+
updatedAt: new Date(),
|
| 21 |
+
};
|
| 22 |
+
setConversations((prev) => [newConversation, ...prev]);
|
| 23 |
+
setActiveConversationId(newConversation.id);
|
| 24 |
+
return newConversation.id;
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
const updateConversationTitle = useCallback((id: string, title: string) => {
|
| 28 |
+
setConversations((prev) =>
|
| 29 |
+
prev.map((c) => (c.id === id ? { ...c, title } : c))
|
| 30 |
+
);
|
| 31 |
+
}, []);
|
| 32 |
+
|
| 33 |
+
const deleteConversation = useCallback((id: string) => {
|
| 34 |
+
setConversations((prev) => prev.filter((c) => c.id !== id));
|
| 35 |
+
if (activeConversationId === id) {
|
| 36 |
+
setActiveConversationId(null);
|
| 37 |
+
}
|
| 38 |
+
}, [activeConversationId]);
|
| 39 |
+
|
| 40 |
+
const sendMessage = useCallback(
|
| 41 |
+
async (content: string, screenContext?: string) => {
|
| 42 |
+
let conversationId = activeConversationId;
|
| 43 |
+
|
| 44 |
+
if (!conversationId) {
|
| 45 |
+
conversationId = createConversation();
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const userMessage: Message = {
|
| 49 |
+
id: crypto.randomUUID(),
|
| 50 |
+
role: "user",
|
| 51 |
+
content,
|
| 52 |
+
timestamp: new Date(),
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
// Add user message
|
| 56 |
+
setConversations((prev) =>
|
| 57 |
+
prev.map((c) =>
|
| 58 |
+
c.id === conversationId
|
| 59 |
+
? {
|
| 60 |
+
...c,
|
| 61 |
+
messages: [...c.messages, userMessage],
|
| 62 |
+
updatedAt: new Date(),
|
| 63 |
+
title: c.messages.length === 0 ? content.slice(0, 50) : c.title,
|
| 64 |
+
}
|
| 65 |
+
: c
|
| 66 |
+
)
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
setIsLoading(true);
|
| 70 |
+
|
| 71 |
+
try {
|
| 72 |
+
const currentConversation = conversations.find((c) => c.id === conversationId);
|
| 73 |
+
const messageHistory = currentConversation?.messages || [];
|
| 74 |
+
|
| 75 |
+
const response = await fetch(CHAT_URL, {
|
| 76 |
+
method: "POST",
|
| 77 |
+
headers: {
|
| 78 |
+
"Content-Type": "application/json",
|
| 79 |
+
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
|
| 80 |
+
},
|
| 81 |
+
body: JSON.stringify({
|
| 82 |
+
messages: [
|
| 83 |
+
...messageHistory.map((m) => ({ role: m.role, content: m.content })),
|
| 84 |
+
{ role: "user", content: screenContext ? `[Screen Context: ${screenContext}]\n\n${content}` : content },
|
| 85 |
+
],
|
| 86 |
+
}),
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
if (!response.ok) {
|
| 90 |
+
throw new Error(`Failed to get response: ${response.status}`);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const reader = response.body?.getReader();
|
| 94 |
+
if (!reader) throw new Error("No response body");
|
| 95 |
+
|
| 96 |
+
const decoder = new TextDecoder();
|
| 97 |
+
let assistantContent = "";
|
| 98 |
+
const assistantId = crypto.randomUUID();
|
| 99 |
+
|
| 100 |
+
// Add empty assistant message
|
| 101 |
+
setConversations((prev) =>
|
| 102 |
+
prev.map((c) =>
|
| 103 |
+
c.id === conversationId
|
| 104 |
+
? {
|
| 105 |
+
...c,
|
| 106 |
+
messages: [
|
| 107 |
+
...c.messages,
|
| 108 |
+
{
|
| 109 |
+
id: assistantId,
|
| 110 |
+
role: "assistant" as const,
|
| 111 |
+
content: "",
|
| 112 |
+
timestamp: new Date(),
|
| 113 |
+
isStreaming: true,
|
| 114 |
+
},
|
| 115 |
+
],
|
| 116 |
+
}
|
| 117 |
+
: c
|
| 118 |
+
)
|
| 119 |
+
);
|
| 120 |
+
|
| 121 |
+
let textBuffer = "";
|
| 122 |
+
|
| 123 |
+
while (true) {
|
| 124 |
+
const { done, value } = await reader.read();
|
| 125 |
+
if (done) break;
|
| 126 |
+
|
| 127 |
+
textBuffer += decoder.decode(value, { stream: true });
|
| 128 |
+
|
| 129 |
+
let newlineIndex: number;
|
| 130 |
+
while ((newlineIndex = textBuffer.indexOf("\n")) !== -1) {
|
| 131 |
+
let line = textBuffer.slice(0, newlineIndex);
|
| 132 |
+
textBuffer = textBuffer.slice(newlineIndex + 1);
|
| 133 |
+
|
| 134 |
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
| 135 |
+
if (line.startsWith(":") || line.trim() === "") continue;
|
| 136 |
+
if (!line.startsWith("data: ")) continue;
|
| 137 |
+
|
| 138 |
+
const jsonStr = line.slice(6).trim();
|
| 139 |
+
if (jsonStr === "[DONE]") break;
|
| 140 |
+
|
| 141 |
+
try {
|
| 142 |
+
const parsed = JSON.parse(jsonStr);
|
| 143 |
+
const delta = parsed.choices?.[0]?.delta?.content;
|
| 144 |
+
if (delta) {
|
| 145 |
+
assistantContent += delta;
|
| 146 |
+
setConversations((prev) =>
|
| 147 |
+
prev.map((c) =>
|
| 148 |
+
c.id === conversationId
|
| 149 |
+
? {
|
| 150 |
+
...c,
|
| 151 |
+
messages: c.messages.map((m) =>
|
| 152 |
+
m.id === assistantId
|
| 153 |
+
? { ...m, content: assistantContent }
|
| 154 |
+
: m
|
| 155 |
+
),
|
| 156 |
+
}
|
| 157 |
+
: c
|
| 158 |
+
)
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
} catch {
|
| 162 |
+
textBuffer = line + "\n" + textBuffer;
|
| 163 |
+
break;
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// Mark streaming complete
|
| 169 |
+
setConversations((prev) =>
|
| 170 |
+
prev.map((c) =>
|
| 171 |
+
c.id === conversationId
|
| 172 |
+
? {
|
| 173 |
+
...c,
|
| 174 |
+
messages: c.messages.map((m) =>
|
| 175 |
+
m.id === assistantId ? { ...m, isStreaming: false } : m
|
| 176 |
+
),
|
| 177 |
+
updatedAt: new Date(),
|
| 178 |
+
}
|
| 179 |
+
: c
|
| 180 |
+
)
|
| 181 |
+
);
|
| 182 |
+
} catch (error) {
|
| 183 |
+
console.error("Chat error:", error);
|
| 184 |
+
const errorMessage: Message = {
|
| 185 |
+
id: crypto.randomUUID(),
|
| 186 |
+
role: "assistant",
|
| 187 |
+
content: "Sorry, I encountered an error. Please try again.",
|
| 188 |
+
timestamp: new Date(),
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
setConversations((prev) =>
|
| 192 |
+
prev.map((c) =>
|
| 193 |
+
c.id === conversationId
|
| 194 |
+
? { ...c, messages: [...c.messages, errorMessage] }
|
| 195 |
+
: c
|
| 196 |
+
)
|
| 197 |
+
);
|
| 198 |
+
} finally {
|
| 199 |
+
setIsLoading(false);
|
| 200 |
+
}
|
| 201 |
+
},
|
| 202 |
+
[activeConversationId, conversations, createConversation]
|
| 203 |
+
);
|
| 204 |
+
|
| 205 |
+
return {
|
| 206 |
+
conversations,
|
| 207 |
+
activeConversation,
|
| 208 |
+
activeConversationId,
|
| 209 |
+
messages,
|
| 210 |
+
isLoading,
|
| 211 |
+
createConversation,
|
| 212 |
+
setActiveConversationId,
|
| 213 |
+
updateConversationTitle,
|
| 214 |
+
deleteConversation,
|
| 215 |
+
sendMessage,
|
| 216 |
+
};
|
| 217 |
+
}
|
src/hooks/useScreenShare.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback, useRef } from "react";
|
| 2 |
+
import { ScreenShareState } from "@/types/chat";
|
| 3 |
+
|
| 4 |
+
export function useScreenShare() {
|
| 5 |
+
const [state, setState] = useState<ScreenShareState>({
|
| 6 |
+
isSharing: false,
|
| 7 |
+
stream: null,
|
| 8 |
+
error: null,
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
| 12 |
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
| 13 |
+
|
| 14 |
+
const startSharing = useCallback(async () => {
|
| 15 |
+
try {
|
| 16 |
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
| 17 |
+
video: {
|
| 18 |
+
displaySurface: "browser",
|
| 19 |
+
},
|
| 20 |
+
audio: false,
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
stream.getVideoTracks()[0].addEventListener("ended", () => {
|
| 24 |
+
setState({ isSharing: false, stream: null, error: null });
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
setState({ isSharing: true, stream, error: null });
|
| 28 |
+
return stream;
|
| 29 |
+
} catch (error) {
|
| 30 |
+
const message = error instanceof Error ? error.message : "Failed to start screen sharing";
|
| 31 |
+
setState({ isSharing: false, stream: null, error: message });
|
| 32 |
+
return null;
|
| 33 |
+
}
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
const stopSharing = useCallback(() => {
|
| 37 |
+
if (state.stream) {
|
| 38 |
+
state.stream.getTracks().forEach((track) => track.stop());
|
| 39 |
+
}
|
| 40 |
+
setState({ isSharing: false, stream: null, error: null });
|
| 41 |
+
}, [state.stream]);
|
| 42 |
+
|
| 43 |
+
const captureFrame = useCallback((): string | null => {
|
| 44 |
+
if (!state.stream || !videoRef.current || !canvasRef.current) return null;
|
| 45 |
+
|
| 46 |
+
const video = videoRef.current;
|
| 47 |
+
const canvas = canvasRef.current;
|
| 48 |
+
const ctx = canvas.getContext("2d");
|
| 49 |
+
|
| 50 |
+
if (!ctx) return null;
|
| 51 |
+
|
| 52 |
+
canvas.width = video.videoWidth;
|
| 53 |
+
canvas.height = video.videoHeight;
|
| 54 |
+
ctx.drawImage(video, 0, 0);
|
| 55 |
+
|
| 56 |
+
return canvas.toDataURL("image/jpeg", 0.8);
|
| 57 |
+
}, [state.stream]);
|
| 58 |
+
|
| 59 |
+
const getScreenDescription = useCallback(async (): Promise<string | null> => {
|
| 60 |
+
const frame = captureFrame();
|
| 61 |
+
if (!frame) return null;
|
| 62 |
+
|
| 63 |
+
// Return base64 image for AI to analyze
|
| 64 |
+
return frame;
|
| 65 |
+
}, [captureFrame]);
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
...state,
|
| 69 |
+
videoRef,
|
| 70 |
+
canvasRef,
|
| 71 |
+
startSharing,
|
| 72 |
+
stopSharing,
|
| 73 |
+
captureFrame,
|
| 74 |
+
getScreenDescription,
|
| 75 |
+
};
|
| 76 |
+
}
|
src/hooks/useVoice.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback, useRef, useEffect } from "react";
|
| 2 |
+
import { VoiceState } from "@/types/chat";
|
| 3 |
+
|
| 4 |
+
// Web Speech API types
|
| 5 |
+
interface SpeechRecognitionEvent extends Event {
|
| 6 |
+
results: SpeechRecognitionResultList;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface SpeechRecognitionErrorEvent extends Event {
|
| 10 |
+
error: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface SpeechRecognitionInstance extends EventTarget {
|
| 14 |
+
continuous: boolean;
|
| 15 |
+
interimResults: boolean;
|
| 16 |
+
lang: string;
|
| 17 |
+
start: () => void;
|
| 18 |
+
stop: () => void;
|
| 19 |
+
abort: () => void;
|
| 20 |
+
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
| 21 |
+
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
|
| 22 |
+
onend: (() => void) | null;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
declare global {
|
| 26 |
+
interface Window {
|
| 27 |
+
SpeechRecognition: new () => SpeechRecognitionInstance;
|
| 28 |
+
webkitSpeechRecognition: new () => SpeechRecognitionInstance;
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function useVoice() {
|
| 33 |
+
const [state, setState] = useState<VoiceState>({
|
| 34 |
+
isListening: false,
|
| 35 |
+
isSpeaking: false,
|
| 36 |
+
transcript: "",
|
| 37 |
+
error: null,
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null);
|
| 41 |
+
const synthesisRef = useRef<SpeechSynthesisUtterance | null>(null);
|
| 42 |
+
|
| 43 |
+
// Initialize speech recognition
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
if (typeof window !== "undefined") {
|
| 46 |
+
const SpeechRecognition =
|
| 47 |
+
window.SpeechRecognition || (window as any).webkitSpeechRecognition;
|
| 48 |
+
|
| 49 |
+
if (SpeechRecognition) {
|
| 50 |
+
const recognition = new SpeechRecognition();
|
| 51 |
+
recognition.continuous = false;
|
| 52 |
+
recognition.interimResults = true;
|
| 53 |
+
recognition.lang = "en-US";
|
| 54 |
+
|
| 55 |
+
recognition.onresult = (event) => {
|
| 56 |
+
const transcript = Array.from(event.results)
|
| 57 |
+
.map((result) => result[0].transcript)
|
| 58 |
+
.join("");
|
| 59 |
+
|
| 60 |
+
setState((prev) => ({ ...prev, transcript }));
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
recognition.onerror = (event) => {
|
| 64 |
+
setState((prev) => ({
|
| 65 |
+
...prev,
|
| 66 |
+
isListening: false,
|
| 67 |
+
error: event.error,
|
| 68 |
+
}));
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
recognition.onend = () => {
|
| 72 |
+
setState((prev) => ({ ...prev, isListening: false }));
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
recognitionRef.current = recognition;
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return () => {
|
| 80 |
+
if (recognitionRef.current) {
|
| 81 |
+
recognitionRef.current.abort();
|
| 82 |
+
}
|
| 83 |
+
};
|
| 84 |
+
}, []);
|
| 85 |
+
|
| 86 |
+
const startListening = useCallback(() => {
|
| 87 |
+
if (!recognitionRef.current) {
|
| 88 |
+
setState((prev) => ({
|
| 89 |
+
...prev,
|
| 90 |
+
error: "Speech recognition not supported",
|
| 91 |
+
}));
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
setState((prev) => ({
|
| 96 |
+
...prev,
|
| 97 |
+
isListening: true,
|
| 98 |
+
transcript: "",
|
| 99 |
+
error: null,
|
| 100 |
+
}));
|
| 101 |
+
|
| 102 |
+
try {
|
| 103 |
+
recognitionRef.current.start();
|
| 104 |
+
} catch (error) {
|
| 105 |
+
console.error("Failed to start recognition:", error);
|
| 106 |
+
}
|
| 107 |
+
}, []);
|
| 108 |
+
|
| 109 |
+
const stopListening = useCallback(() => {
|
| 110 |
+
if (recognitionRef.current) {
|
| 111 |
+
recognitionRef.current.stop();
|
| 112 |
+
}
|
| 113 |
+
setState((prev) => ({ ...prev, isListening: false }));
|
| 114 |
+
}, []);
|
| 115 |
+
|
| 116 |
+
const speak = useCallback((text: string) => {
|
| 117 |
+
if (!window.speechSynthesis) {
|
| 118 |
+
setState((prev) => ({ ...prev, error: "Speech synthesis not supported" }));
|
| 119 |
+
return;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// Cancel any ongoing speech
|
| 123 |
+
window.speechSynthesis.cancel();
|
| 124 |
+
|
| 125 |
+
const utterance = new SpeechSynthesisUtterance(text);
|
| 126 |
+
utterance.rate = 1;
|
| 127 |
+
utterance.pitch = 1;
|
| 128 |
+
utterance.volume = 1;
|
| 129 |
+
|
| 130 |
+
utterance.onstart = () => {
|
| 131 |
+
setState((prev) => ({ ...prev, isSpeaking: true }));
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
utterance.onend = () => {
|
| 135 |
+
setState((prev) => ({ ...prev, isSpeaking: false }));
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
utterance.onerror = () => {
|
| 139 |
+
setState((prev) => ({ ...prev, isSpeaking: false }));
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
synthesisRef.current = utterance;
|
| 143 |
+
window.speechSynthesis.speak(utterance);
|
| 144 |
+
}, []);
|
| 145 |
+
|
| 146 |
+
const stopSpeaking = useCallback(() => {
|
| 147 |
+
window.speechSynthesis.cancel();
|
| 148 |
+
setState((prev) => ({ ...prev, isSpeaking: false }));
|
| 149 |
+
}, []);
|
| 150 |
+
|
| 151 |
+
return {
|
| 152 |
+
...state,
|
| 153 |
+
startListening,
|
| 154 |
+
stopListening,
|
| 155 |
+
speak,
|
| 156 |
+
stopSpeaking,
|
| 157 |
+
};
|
| 158 |
+
}
|
src/index.css
CHANGED
|
@@ -2,95 +2,96 @@
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
-
/*
|
| 6 |
-
All colors MUST be HSL.
|
| 7 |
-
*/
|
| 8 |
|
| 9 |
@layer base {
|
| 10 |
:root {
|
| 11 |
-
--background:
|
| 12 |
-
--foreground:
|
| 13 |
|
| 14 |
-
--card:
|
| 15 |
-
--card-foreground:
|
| 16 |
|
| 17 |
-
--popover:
|
| 18 |
-
--popover-foreground:
|
| 19 |
|
| 20 |
-
--primary:
|
| 21 |
-
--primary-foreground:
|
| 22 |
|
| 23 |
-
--secondary:
|
| 24 |
-
--secondary-foreground:
|
| 25 |
|
| 26 |
-
--muted:
|
| 27 |
-
--muted-foreground: 215
|
| 28 |
|
| 29 |
-
--accent:
|
| 30 |
-
--accent-foreground:
|
| 31 |
|
| 32 |
-
--destructive: 0 84
|
| 33 |
--destructive-foreground: 210 40% 98%;
|
| 34 |
|
| 35 |
-
--border:
|
| 36 |
-
--input:
|
| 37 |
-
--ring:
|
| 38 |
-
|
| 39 |
-
--radius: 0.
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
--
|
| 44 |
-
|
| 45 |
-
--
|
| 46 |
-
|
| 47 |
-
--sidebar-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
--sidebar-
|
| 52 |
-
|
| 53 |
-
--sidebar-
|
| 54 |
-
|
| 55 |
-
--sidebar-
|
|
|
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
.dark {
|
| 59 |
-
--background: 222
|
| 60 |
--foreground: 210 40% 98%;
|
| 61 |
|
| 62 |
-
--card: 222
|
| 63 |
--card-foreground: 210 40% 98%;
|
| 64 |
|
| 65 |
-
--popover: 222
|
| 66 |
--popover-foreground: 210 40% 98%;
|
| 67 |
|
| 68 |
-
--primary:
|
| 69 |
-
--primary-foreground: 222
|
| 70 |
|
| 71 |
-
--secondary: 217
|
| 72 |
--secondary-foreground: 210 40% 98%;
|
| 73 |
|
| 74 |
-
--muted: 217
|
| 75 |
-
--muted-foreground: 215 20
|
| 76 |
|
| 77 |
-
--accent: 217
|
| 78 |
--accent-foreground: 210 40% 98%;
|
| 79 |
|
| 80 |
-
--destructive: 0 62
|
| 81 |
--destructive-foreground: 210 40% 98%;
|
| 82 |
|
| 83 |
-
--border: 217
|
| 84 |
-
--input: 217
|
| 85 |
-
--ring:
|
| 86 |
-
|
| 87 |
-
--sidebar-
|
| 88 |
-
--sidebar-
|
| 89 |
-
--sidebar-primary
|
| 90 |
-
--sidebar-
|
| 91 |
-
--sidebar-accent
|
| 92 |
-
--sidebar-
|
| 93 |
-
--sidebar-
|
|
|
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
|
@@ -100,6 +101,79 @@ All colors MUST be HSL.
|
|
| 100 |
}
|
| 101 |
|
| 102 |
body {
|
| 103 |
-
@apply bg-background text-foreground;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
|
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
+
/* AI Desktop Assistant - Dark Modern Theme (Copilot-inspired) */
|
|
|
|
|
|
|
| 6 |
|
| 7 |
@layer base {
|
| 8 |
:root {
|
| 9 |
+
--background: 222 47% 11%;
|
| 10 |
+
--foreground: 210 40% 98%;
|
| 11 |
|
| 12 |
+
--card: 222 47% 14%;
|
| 13 |
+
--card-foreground: 210 40% 98%;
|
| 14 |
|
| 15 |
+
--popover: 222 47% 12%;
|
| 16 |
+
--popover-foreground: 210 40% 98%;
|
| 17 |
|
| 18 |
+
--primary: 217 91% 60%;
|
| 19 |
+
--primary-foreground: 222 47% 11%;
|
| 20 |
|
| 21 |
+
--secondary: 217 33% 17%;
|
| 22 |
+
--secondary-foreground: 210 40% 98%;
|
| 23 |
|
| 24 |
+
--muted: 217 33% 17%;
|
| 25 |
+
--muted-foreground: 215 20% 65%;
|
| 26 |
|
| 27 |
+
--accent: 217 33% 22%;
|
| 28 |
+
--accent-foreground: 210 40% 98%;
|
| 29 |
|
| 30 |
+
--destructive: 0 84% 60%;
|
| 31 |
--destructive-foreground: 210 40% 98%;
|
| 32 |
|
| 33 |
+
--border: 217 33% 20%;
|
| 34 |
+
--input: 217 33% 17%;
|
| 35 |
+
--ring: 217 91% 60%;
|
| 36 |
+
|
| 37 |
+
--radius: 0.75rem;
|
| 38 |
+
|
| 39 |
+
/* Custom tokens for AI assistant */
|
| 40 |
+
--glass: 222 47% 16%;
|
| 41 |
+
--glass-border: 217 33% 25%;
|
| 42 |
+
--glow: 217 91% 60%;
|
| 43 |
+
--user-bubble: 217 91% 50%;
|
| 44 |
+
--assistant-bubble: 222 47% 18%;
|
| 45 |
+
--sidebar-bg: 222 47% 8%;
|
| 46 |
+
--hover-accent: 217 33% 25%;
|
| 47 |
+
|
| 48 |
+
--sidebar-background: 222 47% 8%;
|
| 49 |
+
--sidebar-foreground: 215 20% 65%;
|
| 50 |
+
--sidebar-primary: 217 91% 60%;
|
| 51 |
+
--sidebar-primary-foreground: 222 47% 11%;
|
| 52 |
+
--sidebar-accent: 217 33% 17%;
|
| 53 |
+
--sidebar-accent-foreground: 210 40% 98%;
|
| 54 |
+
--sidebar-border: 217 33% 15%;
|
| 55 |
+
--sidebar-ring: 217 91% 60%;
|
| 56 |
}
|
| 57 |
|
| 58 |
.dark {
|
| 59 |
+
--background: 222 47% 11%;
|
| 60 |
--foreground: 210 40% 98%;
|
| 61 |
|
| 62 |
+
--card: 222 47% 14%;
|
| 63 |
--card-foreground: 210 40% 98%;
|
| 64 |
|
| 65 |
+
--popover: 222 47% 12%;
|
| 66 |
--popover-foreground: 210 40% 98%;
|
| 67 |
|
| 68 |
+
--primary: 217 91% 60%;
|
| 69 |
+
--primary-foreground: 222 47% 11%;
|
| 70 |
|
| 71 |
+
--secondary: 217 33% 17%;
|
| 72 |
--secondary-foreground: 210 40% 98%;
|
| 73 |
|
| 74 |
+
--muted: 217 33% 17%;
|
| 75 |
+
--muted-foreground: 215 20% 65%;
|
| 76 |
|
| 77 |
+
--accent: 217 33% 22%;
|
| 78 |
--accent-foreground: 210 40% 98%;
|
| 79 |
|
| 80 |
+
--destructive: 0 62% 30%;
|
| 81 |
--destructive-foreground: 210 40% 98%;
|
| 82 |
|
| 83 |
+
--border: 217 33% 20%;
|
| 84 |
+
--input: 217 33% 17%;
|
| 85 |
+
--ring: 217 91% 60%;
|
| 86 |
+
|
| 87 |
+
--sidebar-background: 222 47% 8%;
|
| 88 |
+
--sidebar-foreground: 215 20% 65%;
|
| 89 |
+
--sidebar-primary: 217 91% 60%;
|
| 90 |
+
--sidebar-primary-foreground: 222 47% 11%;
|
| 91 |
+
--sidebar-accent: 217 33% 17%;
|
| 92 |
+
--sidebar-accent-foreground: 210 40% 98%;
|
| 93 |
+
--sidebar-border: 217 33% 15%;
|
| 94 |
+
--sidebar-ring: 217 91% 60%;
|
| 95 |
}
|
| 96 |
}
|
| 97 |
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
body {
|
| 104 |
+
@apply bg-background text-foreground antialiased;
|
| 105 |
+
font-feature-settings: "rlig" 1, "calt" 1;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
@layer utilities {
|
| 110 |
+
.glass {
|
| 111 |
+
@apply bg-card/80 backdrop-blur-xl border border-border/50;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.glass-hover {
|
| 115 |
+
@apply hover:bg-accent/50 transition-colors duration-200;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.glow {
|
| 119 |
+
box-shadow: 0 0 20px hsl(var(--primary) / 0.3);
|
| 120 |
}
|
| 121 |
+
|
| 122 |
+
.glow-subtle {
|
| 123 |
+
box-shadow: 0 0 10px hsl(var(--primary) / 0.15);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.text-gradient {
|
| 127 |
+
@apply bg-gradient-to-r from-primary to-blue-400 bg-clip-text text-transparent;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.scrollbar-thin {
|
| 131 |
+
scrollbar-width: thin;
|
| 132 |
+
scrollbar-color: hsl(var(--muted)) transparent;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.scrollbar-thin::-webkit-scrollbar {
|
| 136 |
+
width: 6px;
|
| 137 |
+
height: 6px;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.scrollbar-thin::-webkit-scrollbar-track {
|
| 141 |
+
background: transparent;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.scrollbar-thin::-webkit-scrollbar-thumb {
|
| 145 |
+
background: hsl(var(--muted));
|
| 146 |
+
border-radius: 3px;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
| 150 |
+
background: hsl(var(--muted-foreground));
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/* Animations */
|
| 155 |
+
@keyframes pulse-glow {
|
| 156 |
+
0%, 100% {
|
| 157 |
+
box-shadow: 0 0 20px hsl(var(--primary) / 0.4);
|
| 158 |
+
}
|
| 159 |
+
50% {
|
| 160 |
+
box-shadow: 0 0 40px hsl(var(--primary) / 0.6);
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
@keyframes voice-wave {
|
| 165 |
+
0%, 100% {
|
| 166 |
+
transform: scaleY(0.5);
|
| 167 |
+
}
|
| 168 |
+
50% {
|
| 169 |
+
transform: scaleY(1);
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.animate-pulse-glow {
|
| 174 |
+
animation: pulse-glow 2s ease-in-out infinite;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.animate-voice-wave {
|
| 178 |
+
animation: voice-wave 0.5s ease-in-out infinite;
|
| 179 |
}
|
src/integrations/supabase/client.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is automatically generated. Do not edit it directly.
|
| 2 |
+
import { createClient } from '@supabase/supabase-js';
|
| 3 |
+
import type { Database } from './types';
|
| 4 |
+
|
| 5 |
+
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
|
| 6 |
+
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
|
| 7 |
+
|
| 8 |
+
// Import the supabase client like this:
|
| 9 |
+
// import { supabase } from "@/integrations/supabase/client";
|
| 10 |
+
|
| 11 |
+
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
|
| 12 |
+
auth: {
|
| 13 |
+
storage: localStorage,
|
| 14 |
+
persistSession: true,
|
| 15 |
+
autoRefreshToken: true,
|
| 16 |
+
}
|
| 17 |
+
});
|
src/integrations/supabase/types.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Json =
|
| 2 |
+
| string
|
| 3 |
+
| number
|
| 4 |
+
| boolean
|
| 5 |
+
| null
|
| 6 |
+
| { [key: string]: Json | undefined }
|
| 7 |
+
| Json[]
|
| 8 |
+
|
| 9 |
+
export type Database = {
|
| 10 |
+
// Allows to automatically instantiate createClient with right options
|
| 11 |
+
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
| 12 |
+
__InternalSupabase: {
|
| 13 |
+
PostgrestVersion: "14.1"
|
| 14 |
+
}
|
| 15 |
+
public: {
|
| 16 |
+
Tables: {
|
| 17 |
+
[_ in never]: never
|
| 18 |
+
}
|
| 19 |
+
Views: {
|
| 20 |
+
[_ in never]: never
|
| 21 |
+
}
|
| 22 |
+
Functions: {
|
| 23 |
+
[_ in never]: never
|
| 24 |
+
}
|
| 25 |
+
Enums: {
|
| 26 |
+
[_ in never]: never
|
| 27 |
+
}
|
| 28 |
+
CompositeTypes: {
|
| 29 |
+
[_ in never]: never
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
|
| 35 |
+
|
| 36 |
+
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
|
| 37 |
+
|
| 38 |
+
export type Tables<
|
| 39 |
+
DefaultSchemaTableNameOrOptions extends
|
| 40 |
+
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
| 41 |
+
| { schema: keyof DatabaseWithoutInternals },
|
| 42 |
+
TableName extends DefaultSchemaTableNameOrOptions extends {
|
| 43 |
+
schema: keyof DatabaseWithoutInternals
|
| 44 |
+
}
|
| 45 |
+
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
| 46 |
+
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
| 47 |
+
: never = never,
|
| 48 |
+
> = DefaultSchemaTableNameOrOptions extends {
|
| 49 |
+
schema: keyof DatabaseWithoutInternals
|
| 50 |
+
}
|
| 51 |
+
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
| 52 |
+
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
| 53 |
+
Row: infer R
|
| 54 |
+
}
|
| 55 |
+
? R
|
| 56 |
+
: never
|
| 57 |
+
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
| 58 |
+
DefaultSchema["Views"])
|
| 59 |
+
? (DefaultSchema["Tables"] &
|
| 60 |
+
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
| 61 |
+
Row: infer R
|
| 62 |
+
}
|
| 63 |
+
? R
|
| 64 |
+
: never
|
| 65 |
+
: never
|
| 66 |
+
|
| 67 |
+
export type TablesInsert<
|
| 68 |
+
DefaultSchemaTableNameOrOptions extends
|
| 69 |
+
| keyof DefaultSchema["Tables"]
|
| 70 |
+
| { schema: keyof DatabaseWithoutInternals },
|
| 71 |
+
TableName extends DefaultSchemaTableNameOrOptions extends {
|
| 72 |
+
schema: keyof DatabaseWithoutInternals
|
| 73 |
+
}
|
| 74 |
+
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
| 75 |
+
: never = never,
|
| 76 |
+
> = DefaultSchemaTableNameOrOptions extends {
|
| 77 |
+
schema: keyof DatabaseWithoutInternals
|
| 78 |
+
}
|
| 79 |
+
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
| 80 |
+
Insert: infer I
|
| 81 |
+
}
|
| 82 |
+
? I
|
| 83 |
+
: never
|
| 84 |
+
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
| 85 |
+
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
| 86 |
+
Insert: infer I
|
| 87 |
+
}
|
| 88 |
+
? I
|
| 89 |
+
: never
|
| 90 |
+
: never
|
| 91 |
+
|
| 92 |
+
export type TablesUpdate<
|
| 93 |
+
DefaultSchemaTableNameOrOptions extends
|
| 94 |
+
| keyof DefaultSchema["Tables"]
|
| 95 |
+
| { schema: keyof DatabaseWithoutInternals },
|
| 96 |
+
TableName extends DefaultSchemaTableNameOrOptions extends {
|
| 97 |
+
schema: keyof DatabaseWithoutInternals
|
| 98 |
+
}
|
| 99 |
+
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
| 100 |
+
: never = never,
|
| 101 |
+
> = DefaultSchemaTableNameOrOptions extends {
|
| 102 |
+
schema: keyof DatabaseWithoutInternals
|
| 103 |
+
}
|
| 104 |
+
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
| 105 |
+
Update: infer U
|
| 106 |
+
}
|
| 107 |
+
? U
|
| 108 |
+
: never
|
| 109 |
+
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
| 110 |
+
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
| 111 |
+
Update: infer U
|
| 112 |
+
}
|
| 113 |
+
? U
|
| 114 |
+
: never
|
| 115 |
+
: never
|
| 116 |
+
|
| 117 |
+
export type Enums<
|
| 118 |
+
DefaultSchemaEnumNameOrOptions extends
|
| 119 |
+
| keyof DefaultSchema["Enums"]
|
| 120 |
+
| { schema: keyof DatabaseWithoutInternals },
|
| 121 |
+
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
| 122 |
+
schema: keyof DatabaseWithoutInternals
|
| 123 |
+
}
|
| 124 |
+
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
| 125 |
+
: never = never,
|
| 126 |
+
> = DefaultSchemaEnumNameOrOptions extends {
|
| 127 |
+
schema: keyof DatabaseWithoutInternals
|
| 128 |
+
}
|
| 129 |
+
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
| 130 |
+
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
| 131 |
+
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
| 132 |
+
: never
|
| 133 |
+
|
| 134 |
+
export type CompositeTypes<
|
| 135 |
+
PublicCompositeTypeNameOrOptions extends
|
| 136 |
+
| keyof DefaultSchema["CompositeTypes"]
|
| 137 |
+
| { schema: keyof DatabaseWithoutInternals },
|
| 138 |
+
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
| 139 |
+
schema: keyof DatabaseWithoutInternals
|
| 140 |
+
}
|
| 141 |
+
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
| 142 |
+
: never = never,
|
| 143 |
+
> = PublicCompositeTypeNameOrOptions extends {
|
| 144 |
+
schema: keyof DatabaseWithoutInternals
|
| 145 |
+
}
|
| 146 |
+
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
| 147 |
+
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
| 148 |
+
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
| 149 |
+
: never
|
| 150 |
+
|
| 151 |
+
export const Constants = {
|
| 152 |
+
public: {
|
| 153 |
+
Enums: {},
|
| 154 |
+
},
|
| 155 |
+
} as const
|
src/pages/Index.tsx
CHANGED
|
@@ -1,14 +1,7 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
const Index = () => {
|
| 4 |
-
return
|
| 5 |
-
<div className="flex min-h-screen items-center justify-center bg-background">
|
| 6 |
-
<div className="text-center">
|
| 7 |
-
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
|
| 8 |
-
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
|
| 9 |
-
</div>
|
| 10 |
-
</div>
|
| 11 |
-
);
|
| 12 |
};
|
| 13 |
|
| 14 |
export default Index;
|
|
|
|
| 1 |
+
import { ChatInterface } from "@/components/ChatInterface";
|
| 2 |
|
| 3 |
const Index = () => {
|
| 4 |
+
return <ChatInterface />;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
};
|
| 6 |
|
| 7 |
export default Index;
|
src/types/chat.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface Message {
|
| 2 |
+
id: string;
|
| 3 |
+
role: "user" | "assistant";
|
| 4 |
+
content: string;
|
| 5 |
+
timestamp: Date;
|
| 6 |
+
isStreaming?: boolean;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export interface Conversation {
|
| 10 |
+
id: string;
|
| 11 |
+
title: string;
|
| 12 |
+
messages: Message[];
|
| 13 |
+
createdAt: Date;
|
| 14 |
+
updatedAt: Date;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface ScreenShareState {
|
| 18 |
+
isSharing: boolean;
|
| 19 |
+
stream: MediaStream | null;
|
| 20 |
+
error: string | null;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export interface VoiceState {
|
| 24 |
+
isListening: boolean;
|
| 25 |
+
isSpeaking: boolean;
|
| 26 |
+
transcript: string;
|
| 27 |
+
error: string | null;
|
| 28 |
+
}
|
supabase/config.toml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
project_id = "rxrhjvwnfxtosbneybwa"
|
| 2 |
+
|
| 3 |
+
[functions.chat]
|
| 4 |
+
verify_jwt = false
|
supabase/functions/chat/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
| 2 |
+
|
| 3 |
+
const corsHeaders = {
|
| 4 |
+
"Access-Control-Allow-Origin": "*",
|
| 5 |
+
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
serve(async (req) => {
|
| 9 |
+
if (req.method === "OPTIONS") {
|
| 10 |
+
return new Response(null, { headers: corsHeaders });
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
try {
|
| 14 |
+
const { messages, screenContext } = await req.json();
|
| 15 |
+
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
|
| 16 |
+
|
| 17 |
+
if (!LOVABLE_API_KEY) {
|
| 18 |
+
throw new Error("LOVABLE_API_KEY is not configured");
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const systemPrompt = `You are an intelligent AI desktop assistant with vision and voice capabilities. You are helpful, friendly, and professional.
|
| 22 |
+
|
| 23 |
+
Key capabilities:
|
| 24 |
+
- You can help with a wide range of tasks including writing, research, coding, and creative work
|
| 25 |
+
- When the user shares their screen, you can see what they're viewing and provide contextual assistance
|
| 26 |
+
- You can engage in natural voice conversations
|
| 27 |
+
- You're knowledgeable about many topics and can explain complex concepts clearly
|
| 28 |
+
|
| 29 |
+
Guidelines:
|
| 30 |
+
- Be concise but thorough in your responses
|
| 31 |
+
- When analyzing screen content, be specific about what you see and how you can help
|
| 32 |
+
- Maintain a warm, professional tone
|
| 33 |
+
- If you're unsure about something, acknowledge it honestly
|
| 34 |
+
- Format responses with markdown when appropriate for readability`;
|
| 35 |
+
|
| 36 |
+
const response = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
|
| 37 |
+
method: "POST",
|
| 38 |
+
headers: {
|
| 39 |
+
Authorization: `Bearer ${LOVABLE_API_KEY}`,
|
| 40 |
+
"Content-Type": "application/json",
|
| 41 |
+
},
|
| 42 |
+
body: JSON.stringify({
|
| 43 |
+
model: "google/gemini-3-flash-preview",
|
| 44 |
+
messages: [
|
| 45 |
+
{ role: "system", content: systemPrompt },
|
| 46 |
+
...messages,
|
| 47 |
+
],
|
| 48 |
+
stream: true,
|
| 49 |
+
}),
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
if (!response.ok) {
|
| 53 |
+
if (response.status === 429) {
|
| 54 |
+
return new Response(
|
| 55 |
+
JSON.stringify({ error: "Rate limit exceeded. Please try again later." }),
|
| 56 |
+
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
if (response.status === 402) {
|
| 60 |
+
return new Response(
|
| 61 |
+
JSON.stringify({ error: "Usage limit reached. Please add credits to continue." }),
|
| 62 |
+
{ status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
const errorText = await response.text();
|
| 66 |
+
console.error("AI gateway error:", response.status, errorText);
|
| 67 |
+
return new Response(
|
| 68 |
+
JSON.stringify({ error: "AI service error" }),
|
| 69 |
+
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return new Response(response.body, {
|
| 74 |
+
headers: { ...corsHeaders, "Content-Type": "text/event-stream" },
|
| 75 |
+
});
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error("Chat function error:", error);
|
| 78 |
+
return new Response(
|
| 79 |
+
JSON.stringify({ error: error instanceof Error ? error.message : "Unknown error" }),
|
| 80 |
+
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
});
|
tailwind.config.ts
CHANGED
|
@@ -57,6 +57,8 @@ export default {
|
|
| 57 |
border: "hsl(var(--sidebar-border))",
|
| 58 |
ring: "hsl(var(--sidebar-ring))",
|
| 59 |
},
|
|
|
|
|
|
|
| 60 |
},
|
| 61 |
borderRadius: {
|
| 62 |
lg: "var(--radius)",
|
|
@@ -65,25 +67,32 @@ export default {
|
|
| 65 |
},
|
| 66 |
keyframes: {
|
| 67 |
"accordion-down": {
|
| 68 |
-
from: {
|
| 69 |
-
|
| 70 |
-
},
|
| 71 |
-
to: {
|
| 72 |
-
height: "var(--radix-accordion-content-height)",
|
| 73 |
-
},
|
| 74 |
},
|
| 75 |
"accordion-up": {
|
| 76 |
-
from: {
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
},
|
| 83 |
},
|
| 84 |
animation: {
|
| 85 |
"accordion-down": "accordion-down 0.2s ease-out",
|
| 86 |
"accordion-up": "accordion-up 0.2s ease-out",
|
|
|
|
|
|
|
|
|
|
| 87 |
},
|
| 88 |
},
|
| 89 |
},
|
|
|
|
| 57 |
border: "hsl(var(--sidebar-border))",
|
| 58 |
ring: "hsl(var(--sidebar-ring))",
|
| 59 |
},
|
| 60 |
+
glass: "hsl(var(--glass))",
|
| 61 |
+
glow: "hsl(var(--glow))",
|
| 62 |
},
|
| 63 |
borderRadius: {
|
| 64 |
lg: "var(--radius)",
|
|
|
|
| 67 |
},
|
| 68 |
keyframes: {
|
| 69 |
"accordion-down": {
|
| 70 |
+
from: { height: "0" },
|
| 71 |
+
to: { height: "var(--radix-accordion-content-height)" },
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
},
|
| 73 |
"accordion-up": {
|
| 74 |
+
from: { height: "var(--radix-accordion-content-height)" },
|
| 75 |
+
to: { height: "0" },
|
| 76 |
+
},
|
| 77 |
+
"fade-in": {
|
| 78 |
+
"0%": { opacity: "0", transform: "translateY(10px)" },
|
| 79 |
+
"100%": { opacity: "1", transform: "translateY(0)" },
|
| 80 |
+
},
|
| 81 |
+
"slide-up": {
|
| 82 |
+
"0%": { opacity: "0", transform: "translateY(20px)" },
|
| 83 |
+
"100%": { opacity: "1", transform: "translateY(0)" },
|
| 84 |
+
},
|
| 85 |
+
"scale-in": {
|
| 86 |
+
"0%": { opacity: "0", transform: "scale(0.95)" },
|
| 87 |
+
"100%": { opacity: "1", transform: "scale(1)" },
|
| 88 |
},
|
| 89 |
},
|
| 90 |
animation: {
|
| 91 |
"accordion-down": "accordion-down 0.2s ease-out",
|
| 92 |
"accordion-up": "accordion-up 0.2s ease-out",
|
| 93 |
+
"fade-in": "fade-in 0.3s ease-out",
|
| 94 |
+
"slide-up": "slide-up 0.4s ease-out",
|
| 95 |
+
"scale-in": "scale-in 0.2s ease-out",
|
| 96 |
},
|
| 97 |
},
|
| 98 |
},
|
vite.config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
|
|
| 2 |
import react from "@vitejs/plugin-react-swc";
|
| 3 |
import path from "path";
|
| 4 |
import { componentTagger } from "lovable-tagger";
|
|
|
|
| 5 |
|
| 6 |
// https://vitejs.dev/config/
|
| 7 |
export default defineConfig(({ mode }) => ({
|
|
@@ -12,7 +13,62 @@ export default defineConfig(({ mode }) => ({
|
|
| 12 |
overlay: false,
|
| 13 |
},
|
| 14 |
},
|
| 15 |
-
plugins: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
resolve: {
|
| 17 |
alias: {
|
| 18 |
"@": path.resolve(__dirname, "./src"),
|
|
|
|
| 2 |
import react from "@vitejs/plugin-react-swc";
|
| 3 |
import path from "path";
|
| 4 |
import { componentTagger } from "lovable-tagger";
|
| 5 |
+
import { VitePWA } from "vite-plugin-pwa";
|
| 6 |
|
| 7 |
// https://vitejs.dev/config/
|
| 8 |
export default defineConfig(({ mode }) => ({
|
|
|
|
| 13 |
overlay: false,
|
| 14 |
},
|
| 15 |
},
|
| 16 |
+
plugins: [
|
| 17 |
+
react(),
|
| 18 |
+
mode === "development" && componentTagger(),
|
| 19 |
+
VitePWA({
|
| 20 |
+
registerType: "autoUpdate",
|
| 21 |
+
includeAssets: ["favicon.ico", "robots.txt"],
|
| 22 |
+
manifest: {
|
| 23 |
+
name: "AI Desktop Assistant",
|
| 24 |
+
short_name: "AI Assistant",
|
| 25 |
+
description: "Your intelligent desktop assistant with vision and voice capabilities",
|
| 26 |
+
theme_color: "#0f172a",
|
| 27 |
+
background_color: "#0f172a",
|
| 28 |
+
display: "standalone",
|
| 29 |
+
orientation: "portrait",
|
| 30 |
+
scope: "/",
|
| 31 |
+
start_url: "/",
|
| 32 |
+
icons: [
|
| 33 |
+
{
|
| 34 |
+
src: "/pwa-192x192.png",
|
| 35 |
+
sizes: "192x192",
|
| 36 |
+
type: "image/png",
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
src: "/pwa-512x512.png",
|
| 40 |
+
sizes: "512x512",
|
| 41 |
+
type: "image/png",
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
src: "/pwa-512x512.png",
|
| 45 |
+
sizes: "512x512",
|
| 46 |
+
type: "image/png",
|
| 47 |
+
purpose: "any maskable",
|
| 48 |
+
},
|
| 49 |
+
],
|
| 50 |
+
},
|
| 51 |
+
workbox: {
|
| 52 |
+
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
| 53 |
+
runtimeCaching: [
|
| 54 |
+
{
|
| 55 |
+
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
| 56 |
+
handler: "CacheFirst",
|
| 57 |
+
options: {
|
| 58 |
+
cacheName: "google-fonts-cache",
|
| 59 |
+
expiration: {
|
| 60 |
+
maxEntries: 10,
|
| 61 |
+
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
|
| 62 |
+
},
|
| 63 |
+
cacheableResponse: {
|
| 64 |
+
statuses: [0, 200],
|
| 65 |
+
},
|
| 66 |
+
},
|
| 67 |
+
},
|
| 68 |
+
],
|
| 69 |
+
},
|
| 70 |
+
}),
|
| 71 |
+
].filter(Boolean),
|
| 72 |
resolve: {
|
| 73 |
alias: {
|
| 74 |
"@": path.resolve(__dirname, "./src"),
|