Spaces:
Sleeping
Sleeping
| "use client"; | |
| import styles from "./settings.module.css"; | |
| import "intl-tel-input/styles"; | |
| import { Suspense, useEffect, useState } from "react"; | |
| import { useToast } from "@/components/ui/use-toast"; | |
| import { useUserConfig, ModelOptions, UserConfig, SubscriptionStates, isUserSubscribed } from "../common/auth"; | |
| import { toTitleCase, useIsMobileWidth } from "../common/utils"; | |
| import { isValidPhoneNumber } from "libphonenumber-js"; | |
| import { Button } from "@/components/ui/button"; | |
| import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; | |
| import { Switch } from "@/components/ui/switch"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuRadioGroup, | |
| DropdownMenuRadioItem, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { | |
| AlertDialog, | |
| AlertDialogAction, | |
| AlertDialogCancel, | |
| AlertDialogContent, | |
| AlertDialogDescription, | |
| AlertDialogFooter, | |
| AlertDialogHeader, | |
| AlertDialogTitle, | |
| AlertDialogTrigger, | |
| } from "@/components/ui/alert-dialog"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogTrigger | |
| } from "@/components/ui/dialog"; | |
| import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; | |
| import { | |
| ArrowRight, | |
| ChatCircleText, | |
| Key, | |
| Palette, | |
| UserCircle, | |
| Trash, | |
| Copy, | |
| CreditCard, | |
| CheckCircle, | |
| NotionLogo, | |
| GithubLogo, | |
| Files, | |
| WhatsappLogo, | |
| ExclamationMark, | |
| Plugs, | |
| CloudSlash, | |
| Laptop, | |
| Plus, | |
| FloppyDisk, | |
| PlugsConnected, | |
| ArrowCircleUp, | |
| ArrowCircleDown, | |
| ArrowsClockwise, | |
| CaretDown, | |
| Waveform, | |
| MagnifyingGlass, | |
| Brain, | |
| EyeSlash, | |
| Eye, | |
| Download, | |
| TrashSimple, | |
| } from "@phosphor-icons/react"; | |
| import Loading from "../components/loading/loading"; | |
| import IntlTelInput from "intl-tel-input/react"; | |
| import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; | |
| import { AppSidebar } from "../components/appSidebar/appSidebar"; | |
| import { UserMemory, UserMemorySchema } from "../components/userMemory/userMemory"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { KhojLogoType } from "../components/logo/khojLogo"; | |
| import { Progress } from "@/components/ui/progress"; | |
| import JSZip from "jszip"; | |
| import { saveAs } from "file-saver"; | |
| interface DropdownComponentProps { | |
| items: ModelOptions[]; | |
| selected: number; | |
| isActive?: boolean; | |
| callbackFunc: (value: string) => Promise<void>; | |
| } | |
| const DropdownComponent: React.FC<DropdownComponentProps> = ({ | |
| items, | |
| selected, | |
| isActive, | |
| callbackFunc, | |
| }) => { | |
| const [position, setPosition] = useState(selected?.toString() ?? "0"); | |
| return ( | |
| !!selected && ( | |
| <div className="overflow-hidden shadow-md rounded-lg"> | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild className="w-full rounded-lg"> | |
| <Button variant="outline" className="justify-start py-6 rounded-lg"> | |
| {items.find((item) => item.id.toString() === position)?.name}{" "} | |
| <CaretDown className="h-4 w-4 ml-auto text-muted-foreground" /> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent | |
| style={{ | |
| maxHeight: "200px", | |
| overflowY: "auto", | |
| minWidth: "var(--radix-dropdown-menu-trigger-width)", | |
| }} | |
| > | |
| <DropdownMenuRadioGroup | |
| value={position} | |
| onValueChange={async (value) => { | |
| setPosition(value); | |
| await callbackFunc(value); | |
| }} | |
| > | |
| {items.map((item) => ( | |
| <DropdownMenuRadioItem | |
| key={item.id.toString()} | |
| value={item.id.toString()} | |
| disabled={!isActive && item.tier !== "free"} | |
| > | |
| {item.name}{" "} | |
| {item.tier === "standard" && ( | |
| <span className="text-green-500 ml-2">(Futurist)</span> | |
| )} | |
| </DropdownMenuRadioItem> | |
| ))} | |
| </DropdownMenuRadioGroup> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </div> | |
| ) | |
| ); | |
| }; | |
| interface TokenObject { | |
| token: string; | |
| name: string; | |
| } | |
| const useApiKeys = () => { | |
| const [apiKeys, setApiKeys] = useState<TokenObject[]>([]); | |
| const { toast } = useToast(); | |
| const generateAPIKey = async () => { | |
| try { | |
| const response = await fetch(`/auth/token`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| const tokenObj = await response.json(); | |
| setApiKeys((prevKeys) => [...prevKeys, tokenObj]); | |
| } catch (error) { | |
| console.error("Error generating API key:", error); | |
| } | |
| }; | |
| const copyAPIKey = async (token: string) => { | |
| try { | |
| await navigator.clipboard.writeText(token); | |
| toast({ | |
| title: "🔑 API Key", | |
| description: "Copied to clipboard", | |
| }); | |
| } catch (error) { | |
| console.error("Error copying API key:", error); | |
| } | |
| }; | |
| const deleteAPIKey = async (token: string) => { | |
| try { | |
| const response = await fetch(`/auth/token?token=${token}`, { method: "DELETE" }); | |
| if (response.ok) { | |
| setApiKeys((prevKeys) => prevKeys.filter((key) => key.token !== token)); | |
| } | |
| } catch (error) { | |
| console.error("Error deleting API key:", error); | |
| } | |
| }; | |
| const listApiKeys = async () => { | |
| try { | |
| const response = await fetch(`/auth/token`); | |
| const tokens = await response.json(); | |
| if (tokens?.length > 0) { | |
| setApiKeys(tokens); | |
| } | |
| } catch (error) { | |
| console.error("Error listing API keys:", error); | |
| } | |
| }; | |
| useEffect(() => { | |
| listApiKeys(); | |
| }, []); | |
| return { | |
| apiKeys, | |
| generateAPIKey, | |
| copyAPIKey, | |
| deleteAPIKey, | |
| }; | |
| }; | |
| function ApiKeyCard() { | |
| const { apiKeys, generateAPIKey, copyAPIKey, deleteAPIKey } = useApiKeys(); | |
| const [visibleApiKeys, setVisibleApiKeys] = useState<Set<string>>(new Set()); | |
| const { toast } = useToast(); | |
| return ( | |
| <Card className="grid grid-flow-column border border-gray-300 shadow-md rounded-lg dark:bg-muted dark:border-none border-opacity-50 lg:w-2/3"> | |
| <CardHeader className="text-xl grid grid-flow-col grid-cols-[1fr_auto] pb-0"> | |
| <span className="flex flex-wrap"> | |
| <Key className="h-7 w-7 mr-2" /> | |
| API Keys | |
| </span> | |
| <Button variant="secondary" className="!mt-0" onClick={generateAPIKey}> | |
| <Plus weight="bold" className="h-5 w-5 mr-2" /> | |
| Generate Key | |
| </Button> | |
| </CardHeader> | |
| <CardContent className="overflow-hidden grid gap-6"> | |
| <p className="text-md text-gray-400"> | |
| Access Khoj from the{" "} | |
| <a href="https://docs.khoj.dev/clients/desktop" target="_blank"> | |
| Desktop | |
| </a> | |
| , <a href="https://docs.khoj.dev/clients/obsidian">Obsidian</a>,{" "} | |
| <a href="https://docs.khoj.dev/clients/emacs">Emacs</a> apps and more. | |
| </p> | |
| <Table> | |
| <TableBody> | |
| {apiKeys.map((key) => ( | |
| <TableRow key={key.token}> | |
| <TableCell className="pl-0 py-3">{key.name}</TableCell> | |
| <TableCell className="grid grid-flow-col grid-cols-[1fr_auto] bg-secondary dark:bg-background rounded-xl p-3 m-1"> | |
| <span className="font-mono text-left w-[50px] md:w-[400px]"> | |
| {visibleApiKeys.has(key.token) | |
| ? key.token | |
| : `${key.token.slice(0, 6)}...${key.token.slice(-4)}`} | |
| </span> | |
| <div className="grid grid-flow-col"> | |
| {visibleApiKeys.has(key.token) ? ( | |
| <EyeSlash | |
| weight="bold" | |
| className="h-4 w-4 mr-2 hover:bg-primary/40" | |
| onClick={() => | |
| setVisibleApiKeys((prev) => { | |
| const next = new Set(prev); | |
| next.delete(key.token); | |
| return next; | |
| }) | |
| } | |
| /> | |
| ) : ( | |
| <Eye | |
| weight="bold" | |
| className="h-4 w-4 mr-2 hover:bg-primary/40" | |
| onClick={() => | |
| setVisibleApiKeys( | |
| new Set([...visibleApiKeys, key.token]), | |
| ) | |
| } | |
| /> | |
| )} | |
| <Copy | |
| weight="bold" | |
| className="h-4 w-4 mr-2 hover:bg-primary/40" | |
| onClick={() => { | |
| toast({ | |
| title: `🔑 Copied API Key: ${key.name}`, | |
| description: `Set this API key in the Khoj apps you want to connect to this Khoj account`, | |
| }); | |
| copyAPIKey(key.token); | |
| }} | |
| /> | |
| <Trash | |
| weight="bold" | |
| className="h-4 w-4 mr-2 md:ml-4 text-red-400 hover:bg-primary/40" | |
| onClick={() => { | |
| toast({ | |
| title: `🔑 Deleted API Key: ${key.name}`, | |
| description: `Apps using this API key will no longer connect to this Khoj account`, | |
| }); | |
| deleteAPIKey(key.token); | |
| }} | |
| /> | |
| </div> | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4" /> | |
| </Card> | |
| ); | |
| } | |
| enum PhoneNumberValidationState { | |
| Setup = "setup", | |
| SendOTP = "otp", | |
| VerifyOTP = "verify", | |
| Verified = "verified", | |
| } | |
| export default function SettingsView() { | |
| const { data: initialUserConfig } = useUserConfig(true); | |
| const [userConfig, setUserConfig] = useState<UserConfig | null>(null); | |
| const [name, setName] = useState<string | undefined>(undefined); | |
| const [notionToken, setNotionToken] = useState<string | null>(null); | |
| const [phoneNumber, setPhoneNumber] = useState<string | undefined>(undefined); | |
| const [otp, setOTP] = useState(""); | |
| const [numberValidationState, setNumberValidationState] = useState<PhoneNumberValidationState>( | |
| PhoneNumberValidationState.Verified, | |
| ); | |
| const [memories, setMemories] = useState<UserMemorySchema[]>([]); | |
| const [enableMemory, setEnableMemory] = useState<boolean>(true); | |
| const [serverMemoryMode, setServerMemoryMode] = useState<string>("enabled_default_on"); | |
| const [isExporting, setIsExporting] = useState(false); | |
| const [exportProgress, setExportProgress] = useState(0); | |
| const [exportedConversations, setExportedConversations] = useState(0); | |
| const [totalConversations, setTotalConversations] = useState(0); | |
| const { toast } = useToast(); | |
| const isMobileWidth = useIsMobileWidth(); | |
| const title = "Settings"; | |
| const cardClassName = | |
| "w-full lg:w-5/12 grid grid-flow-column border border-gray-300 shadow-md rounded-lg border dark:border-none border-opacity-50 dark:bg-muted"; | |
| useEffect(() => { | |
| setUserConfig(initialUserConfig); | |
| setPhoneNumber(initialUserConfig?.phone_number); | |
| setNumberValidationState( | |
| initialUserConfig?.is_phone_number_verified | |
| ? PhoneNumberValidationState.Verified | |
| : initialUserConfig?.phone_number | |
| ? PhoneNumberValidationState.SendOTP | |
| : PhoneNumberValidationState.Setup, | |
| ); | |
| setName(initialUserConfig?.given_name); | |
| setNotionToken(initialUserConfig?.notion_token ?? null); | |
| setEnableMemory(initialUserConfig?.enable_memory ?? true); | |
| setServerMemoryMode(initialUserConfig?.server_memory_mode ?? "enabled_default_on"); | |
| }, [initialUserConfig]); | |
| const sendOTP = async () => { | |
| try { | |
| const response = await fetch(`/api/phone?phone_number=${phoneNumber}`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| if (!response.ok) throw new Error("Failed to send OTP"); | |
| setNumberValidationState(PhoneNumberValidationState.VerifyOTP); | |
| } catch (error) { | |
| console.error("Error sending OTP:", error); | |
| toast({ | |
| title: "📱 Phone", | |
| description: "Failed to send OTP. Try again or contact us at team@khoj.dev", | |
| }); | |
| } | |
| }; | |
| const verifyOTP = async () => { | |
| try { | |
| const response = await fetch(`/api/phone/verify?code=${otp}`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| if (!response.ok) throw new Error("Failed to verify OTP"); | |
| setNumberValidationState(PhoneNumberValidationState.Verified); | |
| toast({ | |
| title: "📱 Phone", | |
| description: "Phone number verified", | |
| }); | |
| } catch (error) { | |
| console.error("Error verifying OTP:", error); | |
| toast({ | |
| title: "📱 Phone", | |
| description: "Failed to verify OTP. Try again or contact us at team@khoj.dev", | |
| }); | |
| } | |
| }; | |
| const disconnectNumber = async () => { | |
| try { | |
| const response = await fetch(`/api/phone`, { | |
| method: "DELETE", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| if (!response.ok) throw new Error("Failed to disconnect phone number"); | |
| setPhoneNumber(undefined); | |
| setNumberValidationState(PhoneNumberValidationState.Setup); | |
| toast({ | |
| title: "📱 Phone", | |
| description: "Phone number disconnected", | |
| }); | |
| } catch (error) { | |
| console.error("Error disconnecting phone number:", error); | |
| toast({ | |
| title: "📱 Phone", | |
| description: | |
| "Failed to disconnect phone number. Try again or contact us at team@khoj.dev", | |
| }); | |
| } | |
| }; | |
| const setSubscription = async (state: string) => { | |
| try { | |
| const url = `/api/subscription?operation=${state}`; | |
| const response = await fetch(url, { | |
| method: "PATCH", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| if (!response.ok) throw new Error("Failed to change subscription"); | |
| // Set updated user settings | |
| if (userConfig) { | |
| let newUserConfig = userConfig; | |
| newUserConfig.subscription_state = | |
| state === "cancel" | |
| ? SubscriptionStates.UNSUBSCRIBED | |
| : SubscriptionStates.SUBSCRIBED; | |
| setUserConfig(newUserConfig); | |
| } | |
| // Notify user of subscription change | |
| toast({ | |
| title: "💳 Subscription", | |
| description: | |
| userConfig?.subscription_state === "unsubscribed" | |
| ? "Your subscription was cancelled" | |
| : "Your Futurist subscription has been renewed", | |
| }); | |
| } catch (error) { | |
| console.error("Error changing subscription:", error); | |
| toast({ | |
| title: "💳 Subscription", | |
| description: | |
| state === "cancel" | |
| ? "Failed to cancel subscription. Try again or contact us at team@khoj.dev" | |
| : "Failed to renew subscription. Try again or contact us at team@khoj.dev", | |
| }); | |
| } | |
| }; | |
| const saveName = async () => { | |
| if (!name) return; | |
| try { | |
| const response = await fetch(`/api/user/name?name=${name}`, { | |
| method: "PATCH", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| if (!response.ok) throw new Error("Failed to update name"); | |
| // Set updated user settings | |
| if (userConfig) { | |
| let newUserConfig = userConfig; | |
| newUserConfig.given_name = name; | |
| setUserConfig(newUserConfig); | |
| } | |
| // Notify user of name change | |
| toast({ | |
| title: `✅ Updated Profile`, | |
| description: `You name has been updated to ${name}`, | |
| }); | |
| } catch (error) { | |
| console.error("Error updating name:", error); | |
| toast({ | |
| title: "⚠️ Failed to Update Profile", | |
| description: "Failed to update name. Try again or contact team@khoj.dev", | |
| }); | |
| } | |
| }; | |
| const updateModel = (modelType: string) => async (id: string) => { | |
| // Get the selected model from the options | |
| const modelOptions = | |
| modelType === "chat" | |
| ? userConfig?.chat_model_options | |
| : modelType === "paint" | |
| ? userConfig?.paint_model_options | |
| : userConfig?.voice_model_options; | |
| const selectedModel = modelOptions?.find((model) => model.id.toString() === id); | |
| const modelName = selectedModel?.name; | |
| // Check if the model is free tier or if the user is active | |
| if (!userConfig?.is_active && selectedModel?.tier !== "free") { | |
| toast({ | |
| title: `Model Update`, | |
| description: `Subscribe to switch ${modelType} model to ${modelName}.`, | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`/api/model/${modelType}?id=` + id, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| if (!response.ok) | |
| throw new Error(`Failed to switch ${modelType} model to ${modelName}`); | |
| toast({ | |
| title: `✅ Switched ${modelType} model to ${modelName}`, | |
| }); | |
| } catch (error) { | |
| console.error(`Failed to update ${modelType} model to ${modelName}:`, error); | |
| toast({ | |
| description: `❌ Failed to switch ${modelType} model to ${modelName}. Try again.`, | |
| variant: "destructive", | |
| }); | |
| } | |
| }; | |
| const exportChats = async () => { | |
| try { | |
| setIsExporting(true); | |
| // Get total conversation count | |
| const statsResponse = await fetch("/api/chat/stats"); | |
| const stats = await statsResponse.json(); | |
| const total = stats.num_conversations; | |
| setTotalConversations(total); | |
| // Create zip file | |
| const zip = new JSZip(); | |
| const conversations = []; | |
| // Fetch all conversations in batches of 10 | |
| for (let page = 0; page * 10 < total; page++) { | |
| const response = await fetch(`/api/chat/export?page=${page}`); | |
| const data = await response.json(); | |
| conversations.push(...data); | |
| setExportedConversations((page + 1) * 10); | |
| setExportProgress((((page + 1) * 10) / total) * 100); | |
| } | |
| // Add conversations to zip | |
| zip.file("conversations.json", JSON.stringify(conversations, null, 2)); | |
| // Generate and download zip | |
| const content = await zip.generateAsync({ type: "blob" }); | |
| saveAs(content, "khoj-conversations.zip"); | |
| toast({ | |
| title: "Export Complete", | |
| description: `Successfully exported ${conversations.length} conversations`, | |
| }); | |
| } catch (error) { | |
| console.error("Error exporting chats:", error); | |
| toast({ | |
| title: "Export Failed", | |
| description: "Failed to export chats. Please try again.", | |
| variant: "destructive", | |
| }); | |
| } finally { | |
| setIsExporting(false); | |
| setExportProgress(0); | |
| setExportedConversations(0); | |
| setTotalConversations(0); | |
| } | |
| }; | |
| const saveNotionToken = async () => { | |
| if (!notionToken) return; | |
| // Save Notion API key to server | |
| try { | |
| const response = await fetch(`/api/content/notion`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ token: notionToken }), | |
| }); | |
| if (!response.ok) throw new Error("Failed to save Notion API key"); | |
| // Set updated user settings | |
| if (userConfig) { | |
| let newUserConfig = userConfig; | |
| newUserConfig.notion_token = notionToken; | |
| setUserConfig(newUserConfig); | |
| } | |
| // Notify user of Notion API key save | |
| toast({ | |
| title: `✅ Saved Notion Settings`, | |
| description: `You Notion API key has been saved.`, | |
| }); | |
| } catch (error) { | |
| console.error("Error updating name:", error); | |
| toast({ | |
| title: "⚠️ Failed to Save Notion Settings", | |
| description: "Failed to save Notion API key. Try again or contact team@khoj.dev", | |
| }); | |
| } | |
| }; | |
| const fetchMemories = async () => { | |
| try { | |
| console.log("Fetching memories..."); | |
| const response = await fetch('/api/memories'); | |
| if (!response.ok) throw new Error('Failed to fetch memories'); | |
| const data = await response.json(); | |
| setMemories(data); | |
| } catch (error) { | |
| console.error('Error fetching memories:', error); | |
| toast({ | |
| title: "Error", | |
| description: "Failed to fetch memories. Please try again.", | |
| variant: "destructive" | |
| }); | |
| } | |
| }; | |
| const handleDeleteMemory = async (id: number) => { | |
| try { | |
| const response = await fetch(`/api/memories/${id}`, { | |
| method: 'DELETE' | |
| }); | |
| if (!response.ok) throw new Error('Failed to delete memory'); | |
| setMemories(memories.filter(memory => memory.id !== id)); | |
| } catch (error) { | |
| console.error('Error deleting memory:', error); | |
| toast({ | |
| title: "Error", | |
| description: "Failed to delete memory. Please try again.", | |
| variant: "destructive" | |
| }); | |
| } | |
| }; | |
| const handleUpdateMemory = async (id: number, raw: string) => { | |
| try { | |
| const response = await fetch(`/api/memories/${id}`, { | |
| method: 'PUT', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ raw, memory_id: id }), | |
| }); | |
| if (!response.ok) throw new Error('Failed to update memory'); | |
| const updatedMemory: UserMemorySchema = await response.json(); | |
| setMemories(memories.map(memory => | |
| memory.id === id ? updatedMemory : memory | |
| )); | |
| } catch (error) { | |
| console.error('Error updating memory:', error); | |
| toast({ | |
| title: "Error", | |
| description: "Failed to update memory. Please try again.", | |
| variant: "destructive" | |
| }); | |
| } | |
| }; | |
| const handleToggleMemory = async (enabled: boolean) => { | |
| try { | |
| const response = await fetch(`/api/user/memory?enable_memory=${enabled}`, { | |
| method: 'PATCH', | |
| }); | |
| if (!response.ok) throw new Error('Failed to update memory setting'); | |
| setEnableMemory(enabled); | |
| toast({ | |
| title: enabled ? "Memory enabled" : "Memory disabled", | |
| description: enabled | |
| ? "Khoj will learn and remember from your conversations." | |
| : "Khoj will no longer learn or remember from your conversations.", | |
| }); | |
| } catch (error) { | |
| console.error('Error toggling memory:', error); | |
| toast({ | |
| title: "Error", | |
| description: "Failed to update memory setting. Please try again.", | |
| variant: "destructive" | |
| }); | |
| } | |
| }; | |
| const syncContent = async (type: string) => { | |
| try { | |
| const response = await fetch(`/api/content?t=${type}`, { | |
| method: "PATCH", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| if (!response.ok) throw new Error(`Failed to sync content from ${type}`); | |
| toast({ | |
| title: `🔄 Syncing ${type}`, | |
| description: `Your ${type} content is being synced.`, | |
| }); | |
| } catch (error) { | |
| console.error("Error syncing content:", error); | |
| toast({ | |
| title: `⚠️ Failed to Sync ${type}`, | |
| description: `Failed to sync ${type} content. Try again or contact team@khoj.dev`, | |
| }); | |
| } | |
| }; | |
| const disconnectContent = async (source: string) => { | |
| try { | |
| const response = await fetch(`/api/content/source/${source}`, { | |
| method: "DELETE", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| if (!response.ok) throw new Error(`Failed to disconnect ${source}`); | |
| // Set updated user settings | |
| if (userConfig) { | |
| let newUserConfig = userConfig; | |
| if (source === "computer") { | |
| newUserConfig.enabled_content_source.computer = false; | |
| } else if (source === "notion") { | |
| newUserConfig.enabled_content_source.notion = false; | |
| newUserConfig.notion_token = null; | |
| setNotionToken(newUserConfig.notion_token); | |
| } else if (source === "github") { | |
| newUserConfig.enabled_content_source.github = false; | |
| } | |
| setUserConfig(newUserConfig); | |
| } | |
| // Notify user about disconnecting content source | |
| if (source === "computer") { | |
| toast({ | |
| title: `✅ Deleted Synced Files`, | |
| description: "Your synced documents have been deleted.", | |
| }); | |
| } else { | |
| toast({ | |
| title: `✅ Disconnected ${source}`, | |
| description: `Your ${source} integration to Khoj has been disconnected.`, | |
| }); | |
| } | |
| } catch (error) { | |
| console.error(`Error disconnecting ${source}:`, error); | |
| toast({ | |
| title: `⚠️ Failed to Disconnect ${source}`, | |
| description: `Failed to disconnect from ${source}. Try again or contact team@khoj.dev`, | |
| }); | |
| } | |
| }; | |
| if (!userConfig) return <Loading />; | |
| return ( | |
| <SidebarProvider> | |
| <AppSidebar conversationId={""} /> | |
| <SidebarInset> | |
| <header className="flex h-16 shrink-0 items-center gap-2 border-b px-4"> | |
| <SidebarTrigger className="-ml-1" /> | |
| <Separator orientation="vertical" className="mr-2 h-4" /> | |
| {isMobileWidth ? ( | |
| <a className="p-0 no-underline" href="/"> | |
| <KhojLogoType className="h-auto w-16" /> | |
| </a> | |
| ) : ( | |
| <h2 className="text-lg">Settings</h2> | |
| )} | |
| </header> | |
| <div className={styles.page}> | |
| <title>{title}</title> | |
| <div className={styles.content}> | |
| <div className={`${styles.contentBody} mx-10 my-2`}> | |
| <Suspense fallback={<Loading />}> | |
| <div | |
| id="content" | |
| className="grid grid-flow-column sm:grid-flow-row gap-16 m-8" | |
| > | |
| <div className="section grid gap-8"> | |
| <div className="text-2xl">Profile</div> | |
| <div className="cards flex flex-wrap gap-16"> | |
| <Card className={cardClassName}> | |
| <CardHeader className="text-xl flex flex-row"> | |
| <UserCircle className="h-7 w-7 mr-2" /> | |
| Name | |
| </CardHeader> | |
| <CardContent className="overflow-hidden"> | |
| <p className="pb-4 text-gray-400"> | |
| What should Khoj refer to you as? | |
| </p> | |
| <Input | |
| type="text" | |
| onChange={(e) => setName(e.target.value)} | |
| value={name || ""} | |
| className="w-full border border-gray-300 rounded-lg p-4 py-6" | |
| /> | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={saveName} | |
| disabled={name === userConfig.given_name} | |
| > | |
| <FloppyDisk className="h-5 w-5 inline mr-2" /> | |
| Save | |
| </Button> | |
| </CardFooter> | |
| </Card> | |
| {isUserSubscribed(userConfig) && ( | |
| <Card id="subscription" className={cardClassName}> | |
| <CardHeader className="text-xl flex flex-row"> | |
| <CreditCard className="h-7 w-7 mr-2" /> | |
| Subscription | |
| </CardHeader> | |
| <CardContent className="grid gap-2 overflow-hidden"> | |
| <p className="text-gray-400">Current Plan</p> | |
| {(userConfig.subscription_state === "trial" && ( | |
| <> | |
| <p className="text-xl text-primary/80"> | |
| Futurist (Trial) | |
| </p> | |
| <p className="text-gray-400"> | |
| You are on a{" "} | |
| {userConfig.length_of_free_trial}{" "} | |
| day trial of the Khoj Futurist plan. | |
| Your trial ends on{" "} | |
| { | |
| userConfig.subscription_renewal_date | |
| } | |
| . Check{" "} | |
| <a | |
| href="https://khoj.dev/#pricing" | |
| target="_blank" | |
| > | |
| pricing page | |
| </a>{" "} | |
| to compare plans. | |
| </p> | |
| </> | |
| )) || | |
| (userConfig.subscription_state === | |
| "subscribed" && ( | |
| <> | |
| <p className="text-xl text-primary/80"> | |
| Futurist | |
| </p> | |
| <p className="text-gray-400"> | |
| Subscription <b>renews</b> on{" "} | |
| <b> | |
| { | |
| userConfig.subscription_renewal_date | |
| } | |
| </b> | |
| </p> | |
| </> | |
| )) || | |
| (userConfig.subscription_state === | |
| "unsubscribed" && ( | |
| <> | |
| <p className="text-xl">Futurist</p> | |
| <p className="text-gray-400"> | |
| Subscription <b>ends</b> on{" "} | |
| <b> | |
| { | |
| userConfig.subscription_renewal_date | |
| } | |
| </b> | |
| </p> | |
| </> | |
| ))} | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| {(userConfig.subscription_state == | |
| "subscribed" && ( | |
| <Button | |
| variant="outline" | |
| className="hover:text-red-400" | |
| onClick={() => | |
| setSubscription("cancel") | |
| } | |
| > | |
| <ArrowCircleDown className="h-5 w-5 mr-2" /> | |
| Unsubscribe | |
| </Button> | |
| )) || | |
| (userConfig.subscription_state == | |
| "unsubscribed" && ( | |
| <Button | |
| variant="outline" | |
| className="text-primary/80 hover:text-primary" | |
| onClick={() => | |
| setSubscription("resubscribe") | |
| } | |
| > | |
| <ArrowCircleUp | |
| weight="bold" | |
| className="h-5 w-5 mr-2" | |
| /> | |
| Resubscribe | |
| </Button> | |
| )) || | |
| ( | |
| <Button | |
| variant="outline" | |
| className="text-primary/80 hover:text-primary" | |
| onClick={() => | |
| window.open( | |
| `${userConfig.khoj_cloud_subscription_url}?prefilled_email=${userConfig.username}`, | |
| "_blank", | |
| "noopener,noreferrer", | |
| ) | |
| } | |
| > | |
| <ArrowCircleUp | |
| weight="bold" | |
| className="h-5 w-5 mr-2" | |
| /> | |
| Subscribe | |
| </Button> | |
| )} | |
| </CardFooter> | |
| </Card> | |
| )} | |
| </div> | |
| </div> | |
| <div className="section grid gap-8"> | |
| <div className="text-2xl">Content</div> | |
| <div className="cards flex flex-wrap gap-16"> | |
| <Card id="computer" className={cardClassName}> | |
| <CardHeader className="flex flex-row text-xl"> | |
| <Brain className="h-8 w-8 mr-2" /> | |
| Knowledge Base | |
| {userConfig.enabled_content_source.computer && ( | |
| <CheckCircle | |
| className="h-6 w-6 ml-auto text-green-500" | |
| weight="fill" | |
| /> | |
| )} | |
| </CardHeader> | |
| <CardContent className="overflow-hidden pb-12 text-gray-400"> | |
| Manage and search through your digital brain. | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| title="Search thorugh files" | |
| onClick={() => | |
| (window.location.href = "/search") | |
| } | |
| > | |
| <MagnifyingGlass className="h-5 w-5 inline mr-1" /> | |
| Search | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className={`${userConfig.enabled_content_source.computer || "hidden"}`} | |
| onClick={() => | |
| disconnectContent("computer") | |
| } | |
| > | |
| <CloudSlash className="h-5 w-5 inline mr-1" /> | |
| Clear All | |
| </Button> | |
| </CardFooter> | |
| </Card> | |
| <Card id="github" className={`${cardClassName} hidden`}> | |
| <CardHeader className="flex flex-row text-2xl"> | |
| <GithubLogo className="h-8 w-8 mr-2" /> | |
| Github | |
| </CardHeader> | |
| <CardContent className="overflow-hidden pb-12 text-gray-400"> | |
| Set Github repositories to index | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| <Button variant="outline" size="sm"> | |
| {(userConfig.enabled_content_source | |
| .github && ( | |
| <> | |
| <Files className="h-5 w-5 inline mr-1" /> | |
| Manage | |
| </> | |
| )) || ( | |
| <> | |
| <Plugs className="h-5 w-5 inline mr-1" /> | |
| Connect | |
| </> | |
| )} | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className={`${userConfig.enabled_content_source.github || "hidden"}`} | |
| > | |
| <CloudSlash className="h-5 w-5 inline mr-1" /> | |
| Disable | |
| </Button> | |
| </CardFooter> | |
| </Card> | |
| <Card id="notion" className={cardClassName}> | |
| <CardHeader className="text-xl flex flex-row"> | |
| <NotionLogo className="h-7 w-7 mr-2" /> | |
| Notion | |
| {userConfig.enabled_content_source.notion && ( | |
| <CheckCircle | |
| className="h-6 w-6 ml-auto text-green-500" | |
| weight="fill" | |
| /> | |
| )} | |
| </CardHeader> | |
| <CardContent className="grid gap-4"> | |
| <p className="text-gray-400"> | |
| Sync your Notion workspace. | |
| </p> | |
| {!userConfig.notion_oauth_url && ( | |
| <Input | |
| onChange={(e) => | |
| setNotionToken(e.target.value) | |
| } | |
| value={notionToken || ""} | |
| placeholder="Enter API Key of your Khoj integration on Notion" | |
| className="w-full border border-gray-300 rounded-lg px-4 py-6" | |
| /> | |
| )} | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| { | |
| /* Show connect to notion button if notion oauth url setup and user disconnected*/ | |
| userConfig.notion_oauth_url && | |
| !userConfig.enabled_content_source | |
| .notion ? ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => { | |
| window.open( | |
| userConfig.notion_oauth_url, | |
| ); | |
| }} | |
| > | |
| <Plugs className="h-5 w-5 inline mr-1" /> | |
| Connect | |
| </Button> | |
| ) : /* Show sync button if user connected to notion and API key unchanged */ | |
| userConfig.enabled_content_source.notion && | |
| notionToken === | |
| userConfig.notion_token ? ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => | |
| syncContent("notion") | |
| } | |
| > | |
| <ArrowsClockwise className="h-5 w-5 inline mr-1" /> | |
| Sync | |
| </Button> | |
| ) : /* Show set API key button notion oauth url not set setup */ | |
| !userConfig.notion_oauth_url ? ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={saveNotionToken} | |
| disabled={ | |
| notionToken === | |
| userConfig.notion_token | |
| } | |
| > | |
| <FloppyDisk className="h-5 w-5 inline mr-1" /> | |
| {(userConfig.enabled_content_source | |
| .notion && | |
| "Update API Key") || | |
| "Set API Key"} | |
| </Button> | |
| ) : ( | |
| <></> | |
| ) | |
| } | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className={`${userConfig.notion_token || "hidden"}`} | |
| onClick={() => disconnectContent("notion")} | |
| > | |
| <CloudSlash className="h-5 w-5 inline mr-1" /> | |
| Disconnect | |
| </Button> | |
| </CardFooter> | |
| </Card> | |
| </div> | |
| </div> | |
| <div className="section grid gap-8"> | |
| <div className="text-2xl">Models</div> | |
| <div className="cards flex flex-wrap gap-16"> | |
| {userConfig.chat_model_options.length > 0 && ( | |
| <Card className={cardClassName}> | |
| <CardHeader className="text-xl flex flex-row"> | |
| <ChatCircleText className="h-7 w-7 mr-2" /> | |
| Chat | |
| </CardHeader> | |
| <CardContent className="overflow-hidden pb-12 grid gap-8 h-fit"> | |
| <p className="text-gray-400"> | |
| Pick the chat model to generate text | |
| responses | |
| </p> | |
| <DropdownComponent | |
| items={userConfig.chat_model_options} | |
| selected={ | |
| userConfig.selected_chat_model_config | |
| } | |
| isActive={userConfig.is_active} | |
| callbackFunc={updateModel("chat")} | |
| /> | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| {!userConfig.is_active && ( | |
| <p className="text-gray-400"> | |
| {userConfig.chat_model_options.some( | |
| (model) => | |
| model.tier === "free", | |
| ) | |
| ? "Free models available" | |
| : "Subscribe to switch model"} | |
| </p> | |
| )} | |
| </CardFooter> | |
| </Card> | |
| )} | |
| {userConfig.paint_model_options.length > 0 && ( | |
| <Card className={cardClassName}> | |
| <CardHeader className="text-xl flex flex-row"> | |
| <Palette className="h-7 w-7 mr-2" /> | |
| Paint | |
| </CardHeader> | |
| <CardContent className="overflow-hidden pb-12 grid gap-8 h-fit"> | |
| <p className="text-gray-400"> | |
| Pick the paint model to generate image | |
| responses | |
| </p> | |
| <DropdownComponent | |
| items={userConfig.paint_model_options} | |
| selected={ | |
| userConfig.selected_paint_model_config | |
| } | |
| isActive={userConfig.is_active} | |
| callbackFunc={updateModel("paint")} | |
| /> | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| {!userConfig.is_active && ( | |
| <p className="text-gray-400"> | |
| {userConfig.paint_model_options.some( | |
| (model) => | |
| model.tier === "free", | |
| ) | |
| ? "Free models available" | |
| : "Subscribe to switch model"} | |
| </p> | |
| )} | |
| </CardFooter> | |
| </Card> | |
| )} | |
| {userConfig.voice_model_options.length > 0 && ( | |
| <Card className={cardClassName}> | |
| <CardHeader className="text-xl flex flex-row"> | |
| <Waveform className="h-7 w-7 mr-2" /> | |
| Voice | |
| </CardHeader> | |
| <CardContent className="overflow-hidden pb-12 grid gap-8 h-fit"> | |
| <p className="text-gray-400"> | |
| Pick the voice model to generate speech | |
| responses | |
| </p> | |
| <DropdownComponent | |
| items={userConfig.voice_model_options} | |
| selected={ | |
| userConfig.selected_voice_model_config | |
| } | |
| isActive={userConfig.is_active} | |
| callbackFunc={updateModel("voice")} | |
| /> | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| {!userConfig.is_active && ( | |
| <p className="text-gray-400"> | |
| {userConfig.voice_model_options.some( | |
| (model) => | |
| model.tier === "free", | |
| ) | |
| ? "Free models available" | |
| : "Subscribe to switch model"} | |
| </p> | |
| )} | |
| </CardFooter> | |
| </Card> | |
| )} | |
| </div> | |
| </div> | |
| <div className="section grid gap-8"> | |
| <div id="clients" className="text-2xl"> | |
| Clients | |
| </div> | |
| <div className="cards flex flex-col flex-wrap gap-8"> | |
| {!userConfig.anonymous_mode && <ApiKeyCard />} | |
| </div> | |
| </div> | |
| <div className="section grid gap-8"> | |
| <div id="clients" className="text-2xl"> | |
| Account | |
| </div> | |
| <div className="cards flex flex-wrap gap-16"> | |
| <Card className={cardClassName}> | |
| <CardHeader className="text-xl flex flex-row"> | |
| <Download className="h-7 w-7 mr-2" /> | |
| Export Data | |
| </CardHeader> | |
| <CardContent className="overflow-hidden"> | |
| <p className="pb-4 text-gray-400"> | |
| Download all your chat conversations | |
| </p> | |
| {exportProgress > 0 && ( | |
| <div className="w-full mt-4"> | |
| <Progress | |
| value={exportProgress} | |
| className="w-full" | |
| /> | |
| <p className="text-sm text-gray-500 mt-2"> | |
| Exported {exportedConversations} of{" "} | |
| {totalConversations} conversations | |
| </p> | |
| </div> | |
| )} | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| <Button | |
| variant="outline" | |
| onClick={exportChats} | |
| disabled={isExporting} | |
| > | |
| <Download className="h-5 w-5 mr-2" /> | |
| {isExporting | |
| ? "Exporting..." | |
| : "Export Chats"} | |
| </Button> | |
| </CardFooter> | |
| </Card> | |
| <Card className={cardClassName}> | |
| <CardHeader className="text-xl flex flex-row"> | |
| <Brain className="h-7 w-7 mr-2" /> | |
| Memories | |
| </CardHeader> | |
| <CardContent className="overflow-hidden"> | |
| <p className="pb-4 text-gray-400"> | |
| View and manage your long-term memories | |
| </p> | |
| <div className="flex items-center justify-between"> | |
| <label | |
| htmlFor="enable-memory" | |
| className={`text-sm font-medium leading-none ${serverMemoryMode === "disabled" ? "text-gray-400" : ""}`} | |
| > | |
| Enable Memory | |
| </label> | |
| <Switch | |
| id="enable-memory" | |
| checked={enableMemory} | |
| onCheckedChange={(checked) => handleToggleMemory(checked)} | |
| disabled={serverMemoryMode === "disabled"} | |
| /> | |
| </div> | |
| {serverMemoryMode === "disabled" && ( | |
| <p className="text-xs text-gray-400 mt-2"> | |
| Memory has been disabled by the server administrator. | |
| </p> | |
| )} | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| <Dialog onOpenChange={(open) => open && fetchMemories()}> | |
| <DialogTrigger asChild> | |
| <Button variant="outline"> | |
| <Brain className="h-5 w-5 mr-2" /> | |
| Browse Memories | |
| </Button> | |
| </DialogTrigger> | |
| <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> | |
| <DialogHeader> | |
| <DialogTitle>Your Memories</DialogTitle> | |
| </DialogHeader> | |
| <div className="grid gap-4 py-4"> | |
| {memories.map((memory) => ( | |
| <UserMemory | |
| key={memory.id} | |
| memory={memory} | |
| onDelete={handleDeleteMemory} | |
| onUpdate={handleUpdateMemory} | |
| /> | |
| ))} | |
| {memories.length === 0 && ( | |
| <p className="text-center text-gray-500">No memories found</p> | |
| )} | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| </CardFooter> | |
| </Card> | |
| <Card className={cardClassName}> | |
| <CardHeader className="text-xl flex flex-row"> | |
| <TrashSimple className="h-7 w-7 mr-2 text-red-500" /> | |
| Delete Account | |
| </CardHeader> | |
| <CardContent className="overflow-hidden"> | |
| <p className="pb-4 text-gray-400"> | |
| This will delete all your account data, | |
| including conversations, agents, and any | |
| assets you{"'"}ve generated. Be sure to | |
| export before you do this if you want to | |
| keep your information. | |
| </p> | |
| </CardContent> | |
| <CardFooter className="flex flex-wrap gap-4"> | |
| <AlertDialog> | |
| <AlertDialogTrigger asChild> | |
| <Button | |
| variant="outline" | |
| className="text-red-500 hover:text-red-600 hover:bg-red-50" | |
| > | |
| <TrashSimple className="h-5 w-5 mr-2" /> | |
| Delete Account | |
| </Button> | |
| </AlertDialogTrigger> | |
| <AlertDialogContent> | |
| <AlertDialogHeader> | |
| <AlertDialogTitle> | |
| Are you absolutely sure? | |
| </AlertDialogTitle> | |
| <AlertDialogDescription> | |
| This action is irreversible. | |
| This will permanently delete | |
| your account and remove all your | |
| data from our servers. | |
| </AlertDialogDescription> | |
| </AlertDialogHeader> | |
| <AlertDialogFooter> | |
| <AlertDialogCancel> | |
| Cancel | |
| </AlertDialogCancel> | |
| <AlertDialogAction | |
| className="bg-red-500 hover:bg-red-600" | |
| onClick={async () => { | |
| try { | |
| const response = | |
| await fetch( | |
| "/api/self", | |
| { | |
| method: "DELETE", | |
| }, | |
| ); | |
| if (!response.ok) | |
| throw new Error( | |
| "Failed to delete account", | |
| ); | |
| toast({ | |
| title: "Account Deleted", | |
| description: | |
| "Your account has been successfully deleted.", | |
| }); | |
| // Redirect to home page after successful deletion | |
| window.location.href = | |
| "/"; | |
| } catch (error) { | |
| console.error( | |
| "Error deleting account:", | |
| error, | |
| ); | |
| toast({ | |
| title: "Error", | |
| description: | |
| "Failed to delete account. Please try again or contact support.", | |
| variant: | |
| "destructive", | |
| }); | |
| } | |
| }} | |
| > | |
| <TrashSimple className="h-5 w-5 mr-2" /> | |
| Delete Account | |
| </AlertDialogAction> | |
| </AlertDialogFooter> | |
| </AlertDialogContent> | |
| </AlertDialog> | |
| </CardFooter> | |
| </Card> | |
| </div> | |
| </div> | |
| </div> | |
| </Suspense> | |
| </div> | |
| </div> | |
| </div> | |
| </SidebarInset> | |
| </SidebarProvider> | |
| ); | |
| } | |