mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-03 21:29:08 +00:00
Currently, the personality of the agent is only included in the final response that it returns to the user. Historically, this was because models were quite bad at navigating the additional context of personality, and there was a bias towards having more control over certain operations (e.g., tool selection, question extraction). Going forward, it should be more approachable to have prompts included in the sub tasks that Khoj runs in order to response to a given query. Make this possible in this PR. This also sets us up for agent creation becoming available soon. Create custom agents in #928 Agents are useful insofar as you can personalize them to fulfill specific subtasks you need to accomplish. In this PR, we add support for using custom agents that can be configured with a custom system prompt (aka persona) and knowledge base (from your own indexed documents). Once created, private agents can be accessible only to the creator, and protected agents can be accessible via a direct link. Custom tool selection for agents in #930 Expose the functionality to select which tools a given agent has access to. By default, they have all. Can limit both information sources and output modes. Add new tools to the agent modification form
1477 lines
76 KiB
TypeScript
1477 lines
76 KiB
TypeScript
"use client";
|
|
|
|
import styles from "./settings.module.css";
|
|
import "intl-tel-input/styles";
|
|
|
|
import { Suspense, useEffect, useRef, useState } from "react";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
|
|
import { useUserConfig, ModelOptions, UserConfig, SubscriptionStates } 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 {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
|
import {
|
|
CommandInput,
|
|
CommandList,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandItem,
|
|
CommandDialog,
|
|
} from "@/components/ui/command";
|
|
|
|
import {
|
|
ArrowRight,
|
|
ChatCircleText,
|
|
Key,
|
|
Palette,
|
|
UserCircle,
|
|
FileMagnifyingGlass,
|
|
Trash,
|
|
Copy,
|
|
CreditCard,
|
|
CheckCircle,
|
|
NotionLogo,
|
|
GithubLogo,
|
|
Files,
|
|
WhatsappLogo,
|
|
ExclamationMark,
|
|
Plugs,
|
|
CloudSlash,
|
|
Laptop,
|
|
Plus,
|
|
FloppyDisk,
|
|
PlugsConnected,
|
|
ArrowCircleUp,
|
|
ArrowCircleDown,
|
|
ArrowsClockwise,
|
|
Check,
|
|
CaretDown,
|
|
Waveform,
|
|
} from "@phosphor-icons/react";
|
|
|
|
import SidePanel from "../components/sidePanel/chatHistorySidePanel";
|
|
import Loading from "../components/loading/loading";
|
|
|
|
import IntlTelInput from "intl-tel-input/react";
|
|
import { uploadDataForIndexing } from "../common/chatFunctions";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import Link from "next/link";
|
|
|
|
const ManageFilesModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
|
const [syncedFiles, setSyncedFiles] = useState<string[]>([]);
|
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [isDragAndDropping, setIsDragAndDropping] = useState(false);
|
|
|
|
const [warning, setWarning] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [progressValue, setProgressValue] = useState(0);
|
|
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!uploading) {
|
|
setProgressValue(0);
|
|
}
|
|
|
|
if (uploading) {
|
|
const interval = setInterval(() => {
|
|
setProgressValue((prev) => {
|
|
const increment = Math.floor(Math.random() * 5) + 1; // Generates a random number between 1 and 5
|
|
const nextValue = prev + increment;
|
|
return nextValue < 100 ? nextValue : 100; // Ensures progress does not exceed 100
|
|
});
|
|
}, 800);
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [uploading]);
|
|
|
|
useEffect(() => {
|
|
const fetchFiles = async () => {
|
|
try {
|
|
const response = await fetch("/api/content/computer");
|
|
if (!response.ok) throw new Error("Failed to fetch files");
|
|
|
|
// Extract resonse
|
|
const syncedFiles = await response.json();
|
|
// Validate response
|
|
if (Array.isArray(syncedFiles)) {
|
|
// Set synced files state
|
|
setSyncedFiles(syncedFiles.toSorted());
|
|
} else {
|
|
console.error("Unexpected data format from API");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching files:", error);
|
|
}
|
|
};
|
|
|
|
fetchFiles();
|
|
}, [uploadedFiles]);
|
|
|
|
const filteredFiles = syncedFiles.filter((file) =>
|
|
file.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
);
|
|
|
|
const deleteSelected = async () => {
|
|
let filesToDelete = selectedFiles.length > 0 ? selectedFiles : filteredFiles;
|
|
console.log("Delete selected files", filesToDelete);
|
|
|
|
if (filesToDelete.length === 0) {
|
|
console.log("No files to delete");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch("/api/content/files", {
|
|
method: "DELETE",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ files: filesToDelete }),
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to delete files");
|
|
|
|
// Update the syncedFiles state
|
|
setSyncedFiles((prevFiles) =>
|
|
prevFiles.filter((file) => !filesToDelete.includes(file)),
|
|
);
|
|
|
|
// Reset selectedFiles
|
|
setSelectedFiles([]);
|
|
|
|
console.log("Deleted files:", filesToDelete);
|
|
} catch (error) {
|
|
console.error("Error deleting files:", error);
|
|
}
|
|
};
|
|
|
|
const deleteFile = async (filename: string) => {
|
|
console.log("Delete selected file", filename);
|
|
try {
|
|
const response = await fetch(
|
|
`/api/content/file?filename=${encodeURIComponent(filename)}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) throw new Error("Failed to delete file");
|
|
|
|
// Update the syncedFiles state
|
|
setSyncedFiles((prevFiles) => prevFiles.filter((file) => file !== filename));
|
|
|
|
// Remove the file from selectedFiles if it's there
|
|
setSelectedFiles((prevSelected) => prevSelected.filter((file) => file !== filename));
|
|
|
|
console.log("Deleted file:", filename);
|
|
} catch (error) {
|
|
console.error("Error deleting file:", error);
|
|
}
|
|
};
|
|
|
|
function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
|
|
event.preventDefault();
|
|
setIsDragAndDropping(true);
|
|
}
|
|
|
|
function handleDragLeave(event: React.DragEvent<HTMLDivElement>) {
|
|
event.preventDefault();
|
|
setIsDragAndDropping(false);
|
|
}
|
|
|
|
function handleDragAndDropFiles(event: React.DragEvent<HTMLDivElement>) {
|
|
event.preventDefault();
|
|
setIsDragAndDropping(false);
|
|
|
|
if (!event.dataTransfer.files) return;
|
|
|
|
uploadFiles(event.dataTransfer.files);
|
|
}
|
|
|
|
function openFileInput() {
|
|
if (fileInputRef && fileInputRef.current) {
|
|
fileInputRef.current.click();
|
|
}
|
|
}
|
|
|
|
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
if (!event.target.files) return;
|
|
|
|
uploadFiles(event.target.files);
|
|
}
|
|
|
|
function uploadFiles(files: FileList) {
|
|
uploadDataForIndexing(files, setWarning, setUploading, setError, setUploadedFiles);
|
|
}
|
|
|
|
return (
|
|
<CommandDialog open={true} onOpenChange={onClose}>
|
|
<AlertDialog open={warning !== null || error != null}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Alert</AlertDialogTitle>
|
|
</AlertDialogHeader>
|
|
<AlertDialogDescription>{warning || error}</AlertDialogDescription>
|
|
<AlertDialogAction
|
|
className="bg-slate-400 hover:bg-slate-500"
|
|
onClick={() => {
|
|
setWarning(null);
|
|
setError(null);
|
|
setUploading(false);
|
|
}}
|
|
>
|
|
Close
|
|
</AlertDialogAction>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
<div
|
|
className={`flex flex-col h-full`}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDragAndDropFiles}
|
|
onClick={openFileInput}
|
|
>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
ref={fileInputRef}
|
|
style={{ display: "none" }}
|
|
onChange={handleFileChange}
|
|
/>
|
|
<div className="flex-none p-4">
|
|
Upload files
|
|
{uploading && (
|
|
<Progress
|
|
indicatorColor="bg-slate-500"
|
|
className="w-full h-2 rounded-full"
|
|
value={progressValue}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`flex-none p-4 bg-secondary border-b ${isDragAndDropping ? "animate-pulse" : ""} rounded-lg`}
|
|
>
|
|
<div className="flex items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg">
|
|
{isDragAndDropping ? (
|
|
<div className="flex items-center justify-center w-full h-full">
|
|
<Waveform className="h-6 w-6 mr-2" />
|
|
<span>Drop files to upload</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center w-full h-full">
|
|
<Plus className="h-6 w-6 mr-2" />
|
|
<span>Drag and drop files here</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex-none p-4 bg-background border-b">
|
|
<CommandInput
|
|
placeholder="Find synced files"
|
|
value={searchQuery}
|
|
onValueChange={setSearchQuery}
|
|
/>
|
|
</div>
|
|
<div className="flex-grow overflow-auto">
|
|
<CommandList>
|
|
<CommandEmpty>
|
|
{syncedFiles.length === 0 ? (
|
|
<div className="flex items-center justify-center">
|
|
<ExclamationMark className="h-4 w-4 mr-2" weight="bold" />
|
|
No files synced
|
|
</div>
|
|
) : (
|
|
<div>
|
|
Could not find a good match.
|
|
<Link href="/search" className="block">
|
|
Need advanced search? Click here.
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</CommandEmpty>
|
|
<CommandGroup heading="Synced files">
|
|
{filteredFiles.map((filename: string) => (
|
|
<CommandItem
|
|
key={filename}
|
|
value={filename}
|
|
onSelect={(value) => {
|
|
setSelectedFiles((prev) =>
|
|
prev.includes(value)
|
|
? prev.filter((f) => f !== value)
|
|
: [...prev, value],
|
|
);
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between w-full">
|
|
<div
|
|
className={`flex items-center ${selectedFiles.includes(filename) ? "font-semibold" : ""}`}
|
|
>
|
|
{selectedFiles.includes(filename) && (
|
|
<Check className="h-4 w-4 mr-2" />
|
|
)}
|
|
<span className="break-all">{filename}</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => deleteFile(filename)}
|
|
className="ml-auto"
|
|
>
|
|
<Trash className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</div>
|
|
|
|
<div className="flex-none p-4 bg-background border-t">
|
|
<div className="flex justify-between">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={deleteSelected}
|
|
className="mr-2"
|
|
>
|
|
<Trash className="h-4 w-4 mr-2" />
|
|
{selectedFiles.length > 0
|
|
? `Delete Selected (${selectedFiles.length})`
|
|
: "Delete All"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CommandDialog>
|
|
);
|
|
};
|
|
|
|
interface DropdownComponentProps {
|
|
items: ModelOptions[];
|
|
selected: number;
|
|
callbackFunc: (value: string) => Promise<void>;
|
|
}
|
|
|
|
const DropdownComponent: React.FC<DropdownComponentProps> = ({ items, selected, 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>
|
|
<DropdownMenuRadioGroup
|
|
value={position}
|
|
onValueChange={async (value) => {
|
|
setPosition(value);
|
|
await callbackFunc(value);
|
|
}}
|
|
>
|
|
{items.map((item) => (
|
|
<DropdownMenuRadioItem
|
|
key={item.id.toString()}
|
|
value={item.id.toString()}
|
|
>
|
|
{item.name}
|
|
</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,
|
|
};
|
|
};
|
|
|
|
enum PhoneNumberValidationState {
|
|
Setup = "setup",
|
|
SendOTP = "otp",
|
|
VerifyOTP = "verify",
|
|
Verified = "verified",
|
|
}
|
|
|
|
export default function SettingsView() {
|
|
const [title, setTitle] = useState("Settings");
|
|
const { apiKeys, generateAPIKey, copyAPIKey, deleteAPIKey } = useApiKeys();
|
|
const { userConfig: 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 [isManageFilesModalOpen, setIsManageFilesModalOpen] = useState(false);
|
|
const { toast } = useToast();
|
|
const isMobileWidth = useIsMobileWidth();
|
|
|
|
const cardClassName =
|
|
"w-full lg:w-1/3 grid grid-flow-column border border-gray-300 shadow-md rounded-lg bg-gradient-to-b from-background to-gray-50 dark:to-gray-950";
|
|
|
|
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);
|
|
}, [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?email=${userConfig?.username}&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 = (name: string) => async (id: string) => {
|
|
if (!userConfig?.is_active && name !== "search") {
|
|
toast({
|
|
title: `Model Update`,
|
|
description: `You need to be subscribed to update ${name} models`,
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/model/${name}?id=` + id, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to update model");
|
|
|
|
toast({
|
|
title: `✅ Updated ${toTitleCase(name)} Model`,
|
|
});
|
|
} catch (error) {
|
|
console.error(`Failed to update ${name} model:`, error);
|
|
toast({
|
|
description: `❌ Failed to update ${toTitleCase(name)} model. Try again.`,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
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 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 (type: string) => {
|
|
try {
|
|
const response = await fetch(`/api/content/${type}`, {
|
|
method: "DELETE",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
if (!response.ok) throw new Error(`Failed to disconnect ${type}`);
|
|
|
|
// Set updated user settings
|
|
if (userConfig) {
|
|
let newUserConfig = userConfig;
|
|
if (type === "computer") {
|
|
newUserConfig.enabled_content_source.computer = false;
|
|
} else if (type === "notion") {
|
|
newUserConfig.enabled_content_source.notion = false;
|
|
newUserConfig.notion_token = null;
|
|
setNotionToken(newUserConfig.notion_token);
|
|
} else if (type === "github") {
|
|
newUserConfig.enabled_content_source.github = false;
|
|
}
|
|
setUserConfig(newUserConfig);
|
|
}
|
|
|
|
// Notify user about disconnecting content source
|
|
if (type === "computer") {
|
|
toast({
|
|
title: `✅ Deleted Synced Files`,
|
|
description: "Your synced documents have been deleted.",
|
|
});
|
|
} else {
|
|
toast({
|
|
title: `✅ Disconnected ${type}`,
|
|
description: `Your ${type} integration to Khoj has been disconnected.`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error disconnecting ${type}:`, error);
|
|
toast({
|
|
title: `⚠️ Failed to Disconnect ${type}`,
|
|
description: `Failed to disconnect from ${type}. Try again or contact team@khoj.dev`,
|
|
});
|
|
}
|
|
};
|
|
|
|
if (!userConfig) return <Loading />;
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<title>{title}</title>
|
|
<div className={styles.sidePanel}>
|
|
<SidePanel conversationId={null} uploadedFiles={[]} isMobileWidth={isMobileWidth} />
|
|
</div>
|
|
<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>
|
|
<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 14 day trial of the Khoj
|
|
Futurist plan. 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>
|
|
</>
|
|
)) ||
|
|
(userConfig.subscription_state === "expired" && (
|
|
<>
|
|
<p className="text-xl">Free Plan</p>
|
|
{(userConfig.subscription_renewal_date && (
|
|
<p className="text-gray-400">
|
|
Subscription <b>expired</b> on{" "}
|
|
<b>
|
|
{
|
|
userConfig.subscription_renewal_date
|
|
}
|
|
</b>
|
|
</p>
|
|
)) || (
|
|
<p className="text-gray-400">
|
|
Check{" "}
|
|
<a
|
|
href="https://khoj.dev/pricing"
|
|
target="_blank"
|
|
>
|
|
pricing page
|
|
</a>{" "}
|
|
to compare plans.
|
|
</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>
|
|
{isManageFilesModalOpen && (
|
|
<ManageFilesModal
|
|
onClose={() => setIsManageFilesModalOpen(false)}
|
|
/>
|
|
)}
|
|
<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-2xl">
|
|
<Laptop className="h-8 w-8 mr-2" />
|
|
Files
|
|
{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 your synced files
|
|
</CardContent>
|
|
<CardFooter className="flex flex-wrap gap-4">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsManageFilesModalOpen(true)}
|
|
>
|
|
<>
|
|
<Files className="h-5 w-5 inline mr-1" />
|
|
Manage
|
|
</>
|
|
</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" />
|
|
Disable
|
|
</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}
|
|
callbackFunc={updateModel("chat")}
|
|
/>
|
|
</CardContent>
|
|
<CardFooter className="flex flex-wrap gap-4">
|
|
{!userConfig.is_active && (
|
|
<p className="text-gray-400">
|
|
Subscribe to switch model
|
|
</p>
|
|
)}
|
|
</CardFooter>
|
|
</Card>
|
|
)}
|
|
{userConfig.search_model_options.length > 0 && (
|
|
<Card className={cardClassName}>
|
|
<CardHeader className="text-xl flex flex-row">
|
|
<FileMagnifyingGlass className="h-7 w-7 mr-2" />
|
|
Search
|
|
</CardHeader>
|
|
<CardContent className="overflow-hidden pb-12 grid gap-8 h-fit">
|
|
<p className="text-gray-400">
|
|
Pick the search model to find your documents
|
|
</p>
|
|
<DropdownComponent
|
|
items={userConfig.search_model_options}
|
|
selected={
|
|
userConfig.selected_search_model_config
|
|
}
|
|
callbackFunc={updateModel("search")}
|
|
/>
|
|
</CardContent>
|
|
<CardFooter className="flex flex-wrap gap-4"></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
|
|
}
|
|
callbackFunc={updateModel("paint")}
|
|
/>
|
|
</CardContent>
|
|
<CardFooter className="flex flex-wrap gap-4">
|
|
{!userConfig.is_active && (
|
|
<p className="text-gray-400">
|
|
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
|
|
}
|
|
callbackFunc={updateModel("voice")}
|
|
/>
|
|
</CardContent>
|
|
<CardFooter className="flex flex-wrap gap-4">
|
|
{!userConfig.is_active && (
|
|
<p className="text-gray-400">
|
|
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-wrap gap-8">
|
|
{!userConfig.anonymous_mode && (
|
|
<Card className="grid grid-flow-column border border-gray-300 shadow-md rounded-lg bg-gradient-to-b from-background to-gray-50 dark:to-gray-950">
|
|
<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 rounded-xl p-3 m-1">
|
|
<span>
|
|
{`${key.token.slice(0, 6)}...${key.token.slice(-4)}`}
|
|
</span>
|
|
<div className="grid grid-flow-col">
|
|
<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"></CardFooter>
|
|
</Card>
|
|
)}
|
|
<Card className={cardClassName}>
|
|
<CardHeader className="text-xl flex flex-row">
|
|
<WhatsappLogo className="h-7 w-7 mr-2" />
|
|
Chat on Whatsapp
|
|
{(numberValidationState ===
|
|
PhoneNumberValidationState.Verified && (
|
|
<CheckCircle
|
|
weight="bold"
|
|
className="h-4 w-4 ml-1 text-green-400"
|
|
/>
|
|
)) ||
|
|
(numberValidationState !==
|
|
PhoneNumberValidationState.Setup && (
|
|
<ExclamationMark
|
|
weight="bold"
|
|
className="h-4 w-4 ml-1 text-yellow-400"
|
|
/>
|
|
))}
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4">
|
|
<p className="text-gray-400">
|
|
Connect your number to chat with Khoj on WhatsApp.
|
|
Learn more about the integration{" "}
|
|
<a href="https://docs.khoj.dev/clients/whatsapp">
|
|
here
|
|
</a>
|
|
.
|
|
</p>
|
|
<div>
|
|
<IntlTelInput
|
|
initialValue={phoneNumber || ""}
|
|
onChangeNumber={setPhoneNumber}
|
|
disabled={
|
|
numberValidationState ===
|
|
PhoneNumberValidationState.VerifyOTP
|
|
}
|
|
initOptions={{
|
|
separateDialCode: true,
|
|
initialCountry: "af",
|
|
utilsScript:
|
|
"https://assets.khoj.dev/intl-tel-input%4023.8.0_build_js_utils.js",
|
|
containerClass: `${styles.phoneInput}`,
|
|
}}
|
|
/>
|
|
{numberValidationState ===
|
|
PhoneNumberValidationState.VerifyOTP && (
|
|
<>
|
|
<p>{`Enter the OTP sent to your number: ${phoneNumber}`}</p>
|
|
<InputOTP
|
|
autoFocus={true}
|
|
maxLength={6}
|
|
value={otp || ""}
|
|
onChange={setOTP}
|
|
onComplete={() =>
|
|
setNumberValidationState(
|
|
PhoneNumberValidationState.VerifyOTP,
|
|
)
|
|
}
|
|
>
|
|
<InputOTPGroup>
|
|
<InputOTPSlot index={0} />
|
|
<InputOTPSlot index={1} />
|
|
<InputOTPSlot index={2} />
|
|
<InputOTPSlot index={3} />
|
|
<InputOTPSlot index={4} />
|
|
<InputOTPSlot index={5} />
|
|
</InputOTPGroup>
|
|
</InputOTP>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter className="flex flex-wrap gap-4">
|
|
{(numberValidationState ===
|
|
PhoneNumberValidationState.VerifyOTP && (
|
|
<Button variant="outline" onClick={verifyOTP}>
|
|
Verify
|
|
</Button>
|
|
)) || (
|
|
<Button
|
|
variant="outline"
|
|
disabled={
|
|
!phoneNumber ||
|
|
(phoneNumber === userConfig.phone_number &&
|
|
numberValidationState ===
|
|
PhoneNumberValidationState.Verified) ||
|
|
!isValidPhoneNumber(phoneNumber)
|
|
}
|
|
onClick={sendOTP}
|
|
>
|
|
{!userConfig.phone_number ? (
|
|
<>
|
|
<Plugs className="inline mr-2" />
|
|
Setup Whatsapp
|
|
</>
|
|
) : !phoneNumber ||
|
|
(phoneNumber === userConfig.phone_number &&
|
|
numberValidationState ===
|
|
PhoneNumberValidationState.Verified) ||
|
|
!isValidPhoneNumber(phoneNumber) ? (
|
|
<>
|
|
<PlugsConnected className="inline mr-2 text-green-400" />
|
|
Switch Number
|
|
</>
|
|
) : (
|
|
<>
|
|
Send OTP{" "}
|
|
<ArrowRight
|
|
className="inline ml-2"
|
|
weight="bold"
|
|
/>
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
{numberValidationState ===
|
|
PhoneNumberValidationState.Verified && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => disconnectNumber()}
|
|
>
|
|
<CloudSlash className="h-5 w-5 mr-2" />
|
|
Disconnect
|
|
</Button>
|
|
)}
|
|
</CardFooter>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|