From f306159a5aee3e31f46518b87bafcc2a3c552b0c Mon Sep 17 00:00:00 2001 From: Yash-1511 Date: Sun, 5 Jan 2025 17:30:00 +0530 Subject: [PATCH 01/25] feat: add autocomplete suggestions feature in search page --- src/interface/web/app/search/page.tsx | 104 ++++++++++++++++++++------ src/khoj/routers/api.py | 16 ++++ 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/interface/web/app/search/page.tsx b/src/interface/web/app/search/page.tsx index b700e5c1..1c099a7a 100644 --- a/src/interface/web/app/search/page.tsx +++ b/src/interface/web/app/search/page.tsx @@ -173,7 +173,10 @@ export default function Search() { const [searchResultsLoading, setSearchResultsLoading] = useState(false); const [focusSearchResult, setFocusSearchResult] = useState(null); const [exampleQuery, setExampleQuery] = useState(""); + const [fileSuggestions, setFileSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); const searchTimeoutRef = useRef(null); + const suggestionsTimeoutRef = useRef(null); const isMobileWidth = useIsMobileWidth(); @@ -205,29 +208,62 @@ export default function Search() { }); } - useEffect(() => { - if (!searchQuery.trim()) { + function getFileSuggestions(query: string) { + // Get suggestions only if query starts with "file:" + if (!query.toLowerCase().startsWith("file:")) { + setFileSuggestions([]); + setShowSuggestions(false); return; } - setFocusSearchResult(null); + const filePrefix = query.substring(5).trim(); // Remove "file:" prefix + const apiUrl = `/api/file-suggestions?q=${encodeURIComponent(filePrefix)}`; + + fetch(apiUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }) + .then((response) => response.json()) + .then((data) => { + setFileSuggestions(data); + setShowSuggestions(true); + }) + .catch((error) => { + console.error("Error:", error); + }); + } + function handleSearchInputChange(value: string) { + setSearchQuery(value); + + // Clear previous timeouts if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } - - if (searchQuery.trim()) { - searchTimeoutRef.current = setTimeout(() => { - search(); - }, 750); // 1000 milliseconds = 1 second + if (suggestionsTimeoutRef.current) { + clearTimeout(suggestionsTimeoutRef.current); } - return () => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - }; - }, [searchQuery]); + // Get file suggestions immediately + suggestionsTimeoutRef.current = setTimeout(() => { + getFileSuggestions(value); + }, 100); + + // Debounce search + if (value.trim()) { + searchTimeoutRef.current = setTimeout(() => { + search(); + }, 750); + } + } + + function applySuggestion(suggestion: string) { + setSearchQuery(`file:${suggestion}`); + setShowSuggestions(false); + search(); + } return ( @@ -247,14 +283,38 @@ export default function Search() {
- setSearchQuery(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && search()} - type="search" - placeholder="Search Documents" - /> +
+ handleSearchInputChange(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (showSuggestions && fileSuggestions.length > 0) { + applySuggestion(fileSuggestions[0]); + } else { + search(); + } + } + }} + type="search" + placeholder="Search Documents (type 'file:' for file suggestions)" + value={searchQuery} + /> + {showSuggestions && fileSuggestions.length > 0 && ( +
+ {fileSuggestions.map((suggestion, index) => ( +
applySuggestion(suggestion)} + > + {suggestion} +
+ ))} +
+ )} +
+
+ {}} + setUploadedFiles={setUploadedFiles} + /> + {searchResultsLoading && ( +
+ +
+ )} {focusSearchResult && (
+ + + + + + + + + + + Delete File + + + + Are you sure you + want to delete + this file? + + + + Cancel + + + handleDelete( + file.file_name, + ) + } + > + {isDeleting + ? "Deleting..." + : "Delete"} + + + + + + + + + + + + + + {file.file_name + .split( + "/", + ) + .pop()} + + + +

+ { + selectedFileFullText + } +

+
+
+
+
+
+ + + + + +

+ {file.raw_text.slice(0, 100)}... +

+
+
+ +
+ {formatDateTime(file.updated_at)} +
+
+ + ))} +
+
)} {searchResults && searchResults.length === 0 && ( diff --git a/src/khoj/database/management/commands/delete_orphaned_fileobjects.py b/src/khoj/database/management/commands/delete_orphaned_fileobjects.py new file mode 100644 index 00000000..95cec137 --- /dev/null +++ b/src/khoj/database/management/commands/delete_orphaned_fileobjects.py @@ -0,0 +1,49 @@ +from django.core.management.base import BaseCommand +from django.db.models import Exists, OuterRef + +from khoj.database.models import Entry, FileObject + + +class Command(BaseCommand): + help = "Deletes FileObjects that have no associated Entries" + + def add_arguments(self, parser): + parser.add_argument( + "--apply", + action="store_true", + help="Actually perform the deletion. Without this flag, only shows what would be deleted.", + ) + + def handle(self, *args, **options): + # Find FileObjects with no related entries using subquery + orphaned_files = FileObject.objects.annotate( + has_entries=Exists(Entry.objects.filter(file_object=OuterRef("pk"))) + ).filter(has_entries=False) + + total_orphaned = orphaned_files.count() + mode = "DELETE" if options["apply"] else "DRY RUN" + self.stdout.write(f"[{mode}] Found {total_orphaned} orphaned FileObjects") + + if total_orphaned == 0: + self.stdout.write("No orphaned FileObjects to process") + return + + # Process in batches of 1000 + batch_size = 1000 + processed = 0 + + while True: + batch = orphaned_files[:batch_size] + if not batch: + break + + if options["apply"]: + count = batch.delete()[0] + processed += count + self.stdout.write(f"Deleted {processed}/{total_orphaned} orphaned FileObjects") + else: + processed += len(batch) + self.stdout.write(f"Would delete {processed}/{total_orphaned} orphaned FileObjects") + + action = "Deleted" if options["apply"] else "Would delete" + self.stdout.write(self.style.SUCCESS(f"{action} {processed} orphaned FileObjects")) diff --git a/src/khoj/database/migrations/0079_entry_file_object.py b/src/khoj/database/migrations/0079_entry_file_object.py new file mode 100644 index 00000000..3846dd9d --- /dev/null +++ b/src/khoj/database/migrations/0079_entry_file_object.py @@ -0,0 +1,75 @@ +# Generated by Django 5.0.10 on 2025-01-10 18:28 + +import django.db.models.deletion +from django.db import migrations, models + + +def migrate_entry_objects(apps, schema_editor): + Entry = apps.get_model("database", "Entry") + FileObject = apps.get_model("database", "FileObject") + db_alias = schema_editor.connection.alias + + # Create lookup dictionary of all file objects + file_objects_map = {(fo.user_id, fo.file_name): fo for fo in FileObject.objects.using(db_alias).all()} + + # Process entries in chunks of 1000 + chunk_size = 1000 + processed = 0 + + processed_entry_ids = set() + + while True: + entries = list( + Entry.objects.using(db_alias) + .select_related("user") + .filter(file_object__isnull=True) + .exclude(id__in=processed_entry_ids) + .only("id", "user", "file_path")[:chunk_size] + ) + + if not entries: + break + + processed_entry_ids.update([entry.id for entry in entries]) + + entries_to_update = [] + for entry in entries: + try: + file_object = file_objects_map.get((entry.user_id, entry.file_path)) + if file_object: + entry.file_object = file_object + entries_to_update.append(entry) + except Exception as e: + print(f"Error processing entry {entry.id}: {str(e)}") + continue + + if entries_to_update: + Entry.objects.using(db_alias).bulk_update(entries_to_update, ["file_object"], batch_size=chunk_size) + + processed += len(entries) + print(f"Processed {processed} entries") + + +def reverse_migration(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0078_khojuser_email_verification_code_expiry"), + ] + + operations = [ + migrations.AddField( + model_name="entry", + name="file_object", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="database.fileobject", + ), + ), + migrations.RunPython(migrate_entry_objects, reverse_migration), + ] diff --git a/src/khoj/database/models/__init__.py b/src/khoj/database/models/__init__.py index 68fae434..c169e55c 100644 --- a/src/khoj/database/models/__init__.py +++ b/src/khoj/database/models/__init__.py @@ -326,6 +326,7 @@ class ProcessLock(DbBaseModel): INDEX_CONTENT = "index_content" SCHEDULED_JOB = "scheduled_job" SCHEDULE_LEADER = "schedule_leader" + APPLY_MIGRATIONS = "apply_migrations" # We need to make sure that some operations are thread-safe. To do so, add locks for potentially shared operations. # For example, we need to make sure that only one process is updating the embeddings at a time. @@ -658,6 +659,14 @@ class ReflectiveQuestion(DbBaseModel): user = models.ForeignKey(KhojUser, on_delete=models.CASCADE, default=None, null=True, blank=True) +class FileObject(DbBaseModel): + # Contains the full text of a file that has associated Entry objects + file_name = models.CharField(max_length=400, default=None, null=True, blank=True) + raw_text = models.TextField() + user = models.ForeignKey(KhojUser, on_delete=models.CASCADE, default=None, null=True, blank=True) + agent = models.ForeignKey(Agent, on_delete=models.CASCADE, default=None, null=True, blank=True) + + class Entry(DbBaseModel): class EntryType(models.TextChoices): IMAGE = "image" @@ -689,20 +698,13 @@ class Entry(DbBaseModel): hashed_value = models.CharField(max_length=100) corpus_id = models.UUIDField(default=uuid.uuid4, editable=False) search_model = models.ForeignKey(SearchModelConfig, on_delete=models.SET_NULL, default=None, null=True, blank=True) + file_object = models.ForeignKey(FileObject, on_delete=models.CASCADE, default=None, null=True, blank=True) def save(self, *args, **kwargs): if self.user and self.agent: raise ValidationError("An Entry cannot be associated with both a user and an agent.") -class FileObject(DbBaseModel): - # Same as Entry but raw will be a much larger string - file_name = models.CharField(max_length=400, default=None, null=True, blank=True) - raw_text = models.TextField() - user = models.ForeignKey(KhojUser, on_delete=models.CASCADE, default=None, null=True, blank=True) - agent = models.ForeignKey(Agent, on_delete=models.CASCADE, default=None, null=True, blank=True) - - class EntryDates(DbBaseModel): date = models.DateField() entry = models.ForeignKey(Entry, on_delete=models.CASCADE, related_name="embeddings_dates") diff --git a/src/khoj/processor/content/text_to_entries.py b/src/khoj/processor/content/text_to_entries.py index f013b28c..2c27c5a3 100644 --- a/src/khoj/processor/content/text_to_entries.py +++ b/src/khoj/processor/content/text_to_entries.py @@ -152,8 +152,22 @@ class TextToEntries(ABC): with timer("Generated embeddings for entries to add to database in", logger): entries_to_process = [hash_to_current_entries[hashed_val] for hashed_val in hashes_to_process] data_to_embed = [getattr(entry, key) for entry in entries_to_process] + modified_files = {entry.file for entry in entries_to_process} embeddings += self.embeddings_model[model.name].embed_documents(data_to_embed) + file_to_file_object_map = {} + if file_to_text_map and modified_files: + with timer("Indexed text of modified file in", logger): + # create or update text of each updated file indexed on DB + for modified_file in modified_files: + raw_text = file_to_text_map[modified_file] + file_object = FileObjectAdapters.get_file_object_by_name(user, modified_file) + if file_object: + FileObjectAdapters.update_raw_text(file_object, raw_text) + else: + file_object = FileObjectAdapters.create_file_object(user, modified_file, raw_text) + file_to_file_object_map[modified_file] = file_object + added_entries: list[DbEntry] = [] with timer("Added entries to database in", logger): num_items = len(hashes_to_process) @@ -165,6 +179,7 @@ class TextToEntries(ABC): batch_embeddings_to_create: List[DbEntry] = [] for entry_hash, new_entry in entry_batch: entry = hash_to_current_entries[entry_hash] + file_object = file_to_file_object_map.get(entry.file, None) batch_embeddings_to_create.append( DbEntry( user=user, @@ -178,6 +193,7 @@ class TextToEntries(ABC): hashed_value=entry_hash, corpus_id=entry.corpus_id, search_model=model, + file_object=file_object, ) ) try: @@ -190,19 +206,6 @@ class TextToEntries(ABC): logger.error(f"Error adding entries to database:\n{batch_indexing_error}\n---\n{e}", exc_info=True) logger.debug(f"Added {len(added_entries)} {file_type} entries to database") - if file_to_text_map: - with timer("Indexed text of modified file in", logger): - # get the set of modified files from added_entries - modified_files = {entry.file_path for entry in added_entries} - # create or update text of each updated file indexed on DB - for modified_file in modified_files: - raw_text = file_to_text_map[modified_file] - file_object = FileObjectAdapters.get_file_object_by_name(user, modified_file) - if file_object: - FileObjectAdapters.update_raw_text(file_object, raw_text) - else: - FileObjectAdapters.create_file_object(user, modified_file, raw_text) - new_dates = [] with timer("Indexed dates from added entries in", logger): for added_entry in added_entries: diff --git a/src/khoj/routers/api_content.py b/src/khoj/routers/api_content.py index 9aea0504..3211c154 100644 --- a/src/khoj/routers/api_content.py +++ b/src/khoj/routers/api_content.py @@ -22,6 +22,7 @@ from starlette.authentication import requires from khoj.database import adapters from khoj.database.adapters import ( EntryAdapters, + FileObjectAdapters, get_user_github_config, get_user_notion_config, ) @@ -270,6 +271,8 @@ async def delete_content_files( await EntryAdapters.adelete_entry_by_file(user, filename) + await FileObjectAdapters.adelete_file_object_by_name(user, filename) + return {"status": "ok"} @@ -294,6 +297,8 @@ async def delete_content_file( ) deleted_count = await EntryAdapters.adelete_entries_by_filenames(user, files.files) + for file in files.files: + await FileObjectAdapters.adelete_file_object_by_name(user, file) return {"status": "ok", "deleted_count": deleted_count} @@ -325,6 +330,65 @@ def get_content_types(request: Request, client: Optional[str] = None): return list(configured_content_types & all_content_types) +@api_content.get("/all", response_model=Dict[str, str]) +@requires(["authenticated"]) +async def get_all_content(request: Request, client: Optional[str] = None, truncated: Optional[bool] = True): + user = request.user.object + + update_telemetry_state( + request=request, + telemetry_type="api", + api="get_all_filenames", + client=client, + ) + + files_data = [] + file_objects = await FileObjectAdapters.aget_all_file_objects(user) + for file_object in file_objects: + files_data.append( + { + "file_name": file_object.file_name, + "raw_text": file_object.raw_text[:1000] if truncated else file_object.raw_text, + "updated_at": str(file_object.updated_at), + } + ) + + return Response(content=json.dumps(files_data), media_type="application/json", status_code=200) + + +@api_content.get("/file", response_model=Dict[str, str]) +@requires(["authenticated"]) +async def get_file_object( + request: Request, + file_name: str, + client: Optional[str] = None, +): + user = request.user.object + + file_object = (await FileObjectAdapters.aget_file_objects_by_name(user, file_name))[0] + if not file_object: + return Response( + content=json.dumps({"error": "File not found"}), + media_type="application/json", + status_code=404, + ) + + update_telemetry_state( + request=request, + telemetry_type="api", + api="get_file", + client=client, + ) + + return Response( + content=json.dumps( + {"id": file_object.id, "file_name": file_object.file_name, "raw_text": file_object.raw_text} + ), + media_type="application/json", + status_code=200, + ) + + @api_content.get("/{content_source}", response_model=List[str]) @requires(["authenticated"]) async def get_content_source( From f2c6ce24352ee4d0e12aa368cc79597367009803 Mon Sep 17 00:00:00 2001 From: sabaimran Date: Fri, 10 Jan 2025 18:18:15 -0800 Subject: [PATCH 03/25] Improve rendering of the file objects and sort files by updated_date --- src/interface/web/app/search/page.tsx | 224 ++++++++++++++----------- src/khoj/database/adapters/__init__.py | 2 +- 2 files changed, 131 insertions(+), 95 deletions(-) diff --git a/src/interface/web/app/search/page.tsx b/src/interface/web/app/search/page.tsx index bb70d8f7..2f9e99f1 100644 --- a/src/interface/web/app/search/page.tsx +++ b/src/interface/web/app/search/page.tsx @@ -54,6 +54,7 @@ import { import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogTrigger, @@ -213,9 +214,6 @@ const UploadFiles: React.FC<{ onClose: () => void; setUploadedFiles: (files: string[]) => void; }> = ({ onClose, setUploadedFiles }) => { - const [syncedFiles, setSyncedFiles] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); const [isDragAndDropping, setIsDragAndDropping] = useState(false); const [warning, setWarning] = useState(null); @@ -241,10 +239,6 @@ const UploadFiles: React.FC<{ } }, [uploading]); - const filteredFiles = syncedFiles.filter((file) => - file.toLowerCase().includes(searchQuery.toLowerCase()), - ); - function handleDragOver(event: React.DragEvent) { event.preventDefault(); setIsDragAndDropping(true); @@ -281,47 +275,94 @@ const UploadFiles: React.FC<{ } return ( -
- -
- {uploading && ( - + + + + + + Build Knowledge Base + + Adding files to your Khoj knowledge base allows your AI to search through + your own documents. This helps you get personalized answers, grounded in + your own data. + + +
+ - )} -
-
-
- {isDragAndDropping ? ( -
- - Drop files to upload -
- ) : ( -
- - Drag and drop files here +
+ {uploading && ( + + )} +
+ {warning && ( +
+
+ + {warning} +
+
)} + {error && ( +
+
+ + {error} +
+ +
+ )} +
+
+ {isDragAndDropping ? ( +
+ + Drop files to upload +
+ ) : ( +
+ + Drag and drop files here +
+ )} +
+
-
-
+
+ ); }; @@ -598,12 +639,51 @@ export default function Search() { > -
- {file.file_name.split("/").pop()} -
+ + +
{ + setSelectedFileFullText( + null, + ); + setSelectedFile( + file.file_name, + ); + }} + > + {file.file_name + .split("/") + .pop()} +
+
+ + + + {file.file_name + .split("/") + .pop()} + + + +

+ {!selectedFileFullText && ( + + )} + {selectedFileFullText} +

+
+
+
- - - - - {file.file_name - .split( - "/", - ) - .pop()} - - - -

- { - selectedFileFullText - } -

-
-
- -
- -

+ +

{file.raw_text.slice(0, 100)}...

diff --git a/src/khoj/database/adapters/__init__.py b/src/khoj/database/adapters/__init__.py index a5be3086..4f101566 100644 --- a/src/khoj/database/adapters/__init__.py +++ b/src/khoj/database/adapters/__init__.py @@ -1474,7 +1474,7 @@ class FileObjectAdapters: @staticmethod @arequire_valid_user async def aget_all_file_objects(user: KhojUser): - return await sync_to_async(list)(FileObject.objects.filter(user=user)) + return await sync_to_async(list)(FileObject.objects.filter(user=user).order_by("-updated_at")) @staticmethod @arequire_valid_user From d77984f9d1741d166851ca9305c2d760640c5616 Mon Sep 17 00:00:00 2001 From: sabaimran Date: Fri, 10 Jan 2025 18:57:38 -0800 Subject: [PATCH 04/25] Remove separate knowledge base file - consolidated in the search page --- .../app/components/appSidebar/appSidebar.tsx | 7 +- src/interface/web/app/knowledge/page.tsx | 93 ------------------- src/interface/web/app/search/page.tsx | 2 +- 3 files changed, 2 insertions(+), 100 deletions(-) delete mode 100644 src/interface/web/app/knowledge/page.tsx diff --git a/src/interface/web/app/components/appSidebar/appSidebar.tsx b/src/interface/web/app/components/appSidebar/appSidebar.tsx index 2f5f9bf8..b660ee52 100644 --- a/src/interface/web/app/components/appSidebar/appSidebar.tsx +++ b/src/interface/web/app/components/appSidebar/appSidebar.tsx @@ -17,7 +17,7 @@ import { KhojSearchLogo, } from "../logo/khojLogo"; import { Gear } from "@phosphor-icons/react/dist/ssr"; -import { Book, Plus } from "@phosphor-icons/react"; +import { Plus } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; import AllConversations from "../allConversations/allConversations"; import FooterMenu from "../navMenu/navMenu"; @@ -55,11 +55,6 @@ const items = [ url: "/settings", icon: Gear, }, - { - title: "Knowledge Base", - url: "/knowledge", - icon: Book, - }, ]; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/src/interface/web/app/knowledge/page.tsx b/src/interface/web/app/knowledge/page.tsx deleted file mode 100644 index cf040156..00000000 --- a/src/interface/web/app/knowledge/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; -import { AppSidebar } from "../components/appSidebar/appSidebar"; -import { Separator } from "@/components/ui/separator"; -import { KhojLogoType } from "../components/logo/khojLogo"; -import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { useIsMobileWidth } from "../common/utils"; -import { InlineLoading } from "../components/loading/loading"; - -interface FileObject { - file_name: string; - raw_text: string; -} - -export default function KnowledgeBase() { - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const isMobileWidth = useIsMobileWidth(); - - useEffect(() => { - const fetchFiles = async () => { - try { - const response = await fetch("/api/content/all"); - if (!response.ok) throw new Error("Failed to fetch files"); - - const filesList = await response.json(); - if (Array.isArray(filesList)) { - setFiles(filesList.toSorted()); - } - } catch (error) { - setError("Failed to load files"); - console.error("Error fetching files:", error); - } finally { - setLoading(false); - } - }; - - fetchFiles(); - }, []); - - return ( - - - -
- - - {isMobileWidth ? ( - - - - ) : ( -

Knowledge Base

- )} -
-
-
- {loading && ( -
- -
- )} - {error &&
{error}
} - -
- {files.map((file, index) => ( - - - - {file.file_name.split("/").pop()} - - - -

- {file.raw_text.slice(0, 100)}... -

-
-
- ))} -
-
-
-
-
- ); -} diff --git a/src/interface/web/app/search/page.tsx b/src/interface/web/app/search/page.tsx index 2f9e99f1..2d1dd6c5 100644 --- a/src/interface/web/app/search/page.tsx +++ b/src/interface/web/app/search/page.tsx @@ -276,7 +276,7 @@ const UploadFiles: React.FC<{ return ( - + From 57545c1485846aa4c5153c65278051625ee46946 Mon Sep 17 00:00:00 2001 From: sabaimran Date: Fri, 10 Jan 2025 21:06:48 -0800 Subject: [PATCH 05/25] Fix the migration script to delete orphaned fileobjects - Remove knowledge page from the sidebar - Improve speed and rendering of the documents in the search page --- .../app/components/appSidebar/appSidebar.tsx | 1 - src/interface/web/app/search/page.tsx | 404 ++++++++---------- src/interface/web/app/settings/page.tsx | 337 +-------------- .../commands/delete_orphaned_fileobjects.py | 17 +- 4 files changed, 195 insertions(+), 564 deletions(-) diff --git a/src/interface/web/app/components/appSidebar/appSidebar.tsx b/src/interface/web/app/components/appSidebar/appSidebar.tsx index b660ee52..d80314b9 100644 --- a/src/interface/web/app/components/appSidebar/appSidebar.tsx +++ b/src/interface/web/app/components/appSidebar/appSidebar.tsx @@ -26,7 +26,6 @@ import { useIsMobileWidth } from "@/app/common/utils"; import { UserPlusIcon } from "lucide-react"; import { useAuthenticatedData } from "@/app/common/auth"; import LoginPrompt from "../loginPrompt/loginPrompt"; -import { url } from "inspector"; // Menu items. const items = [ diff --git a/src/interface/web/app/search/page.tsx b/src/interface/web/app/search/page.tsx index 2d1dd6c5..4eb94179 100644 --- a/src/interface/web/app/search/page.tsx +++ b/src/interface/web/app/search/page.tsx @@ -17,7 +17,6 @@ import { ArrowLeft, ArrowRight, FileDashed, - FileMagnifyingGlass, GithubLogo, Lightbulb, LinkSimple, @@ -26,31 +25,21 @@ import { NotionLogo, Eye, Trash, - ArrowsOutSimple, DotsThreeVertical, Waveform, Plus, + Download, + Brain, + Check, } from "@phosphor-icons/react"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { getIconFromFilename } from "../common/iconUtils"; import { formatDateTime, useIsMobileWidth } from "../common/utils"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { AppSidebar } from "../components/appSidebar/appSidebar"; import { Separator } from "@/components/ui/separator"; import { KhojLogoType } from "../components/logo/khojLogo"; import { InlineLoading } from "../components/loading/loading"; -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogCancel, - AlertDialogAction, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, @@ -60,16 +49,13 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { useToast } from "@/components/ui/use-toast"; -import { Scroll } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { uploadDataForIndexing } from "../common/chatFunctions"; -import { CommandDialog } from "@/components/ui/command"; import { Progress } from "@/components/ui/progress"; interface AdditionalData { file: string; @@ -102,25 +88,6 @@ function getNoteTypeIcon(source: string) { return ; } -const naturalLanguageSearchQueryExamples = [ - "What does the paper say about climate change?", - "Making a cappuccino at home", - "Benefits of eating mangoes", - "How to plan a wedding on a budget", - "Appointment with Dr. Makinde on 12th August", - "Class notes lecture 3 on quantum mechanics", - "Painting concepts for acrylics", - "Abstract from the paper attention is all you need", - "Climbing Everest without oxygen", - "Solving a rubik's cube in 30 seconds", - "Facts about the planet Mars", - "How to make a website using React", - "Fish at the bottom of the ocean", - "Fish farming Kenya 2021", - "How to make a cake without an oven", - "Installing a solar panel at home", -]; - interface NoteResultProps { note: SearchResult; setFocusSearchResult: (note: SearchResult) => void; @@ -132,7 +99,6 @@ function Note(props: NoteResultProps) { const fileName = isFileNameURL ? note.additional.heading : note.additional.file.split("/").pop(); - const fileIcon = getIconFromFilename(fileName || ".txt", "h-4 w-4 inline mr-2"); return ( @@ -153,8 +119,8 @@ function Note(props: NoteResultProps) {
- - {isFileNameURL ? ( + {isFileNameURL && ( + {note.additional.file} - ) : ( -
- {fileIcon} - {note.additional.file} -
- )} -
+
+ )} ); } @@ -179,15 +140,14 @@ function focusNote(note: SearchResult) { const fileName = isFileNameURL ? note.additional.heading : note.additional.file.split("/").pop(); - const fileIcon = getIconFromFilename(fileName || ".txt", "h-4 w-4 inline mr-2"); return ( - + {fileName} - - {isFileNameURL ? ( + {isFileNameURL && ( + {note.additional.file} - ) : ( -
- {fileIcon} - {note.additional.file} -
- )} -
+
+ )}
{note.entry}
@@ -211,9 +166,8 @@ function focusNote(note: SearchResult) { } const UploadFiles: React.FC<{ - onClose: () => void; setUploadedFiles: (files: string[]) => void; -}> = ({ onClose, setUploadedFiles }) => { +}> = ({ setUploadedFiles }) => { const [isDragAndDropping, setIsDragAndDropping] = useState(false); const [warning, setWarning] = useState(null); @@ -225,6 +179,11 @@ const UploadFiles: React.FC<{ useEffect(() => { if (!uploading) { setProgressValue(0); + if (!warning && !error) { + // Force close the dialog by simulating a click on the escape key + const event = new KeyboardEvent("keydown", { key: "Escape" }); + document.dispatchEvent(event); + } } if (uploading) { @@ -278,16 +237,16 @@ const UploadFiles: React.FC<{ - Build Knowledge Base + Build Your Knowledge Base - Adding files to your Khoj knowledge base allows your AI to search through - your own documents. This helps you get personalized answers, grounded in - your own data. + Add your files to supercharge Khoj's AI with your knowledge. Get instant, + personalized answers powered by your own documents and data.
)}
{isDragAndDropping ? ( @@ -379,8 +338,6 @@ export default function Search() { const [selectedFileFullText, setSelectedFileFullText] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [uploadedFiles, setUploadedFiles] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); - const [filteredFiles, setFilteredFiles] = useState([]); const { toast } = useToast(); @@ -408,36 +365,6 @@ export default function Search() { }); } - const deleteSelected = async () => { - let filesToDelete = selectedFiles.length > 0 ? selectedFiles : filteredFiles; - - if (filesToDelete.length === 0) { - 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 - setUploadedFiles((prevFiles) => - prevFiles.filter((file) => !filesToDelete.includes(file)), - ); - - // Reset selectedFiles - setSelectedFiles([]); - } catch (error) { - console.error("Error deleting files:", error); - } - }; - const fetchFiles = async () => { try { const response = await fetch("/api/content/all"); @@ -468,6 +395,18 @@ export default function Search() { } }; + const handleDownload = (fileName: string, content: string) => { + const blob = new Blob([content], { type: "text/plain" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${fileName.split("/").pop()}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }; + useEffect(() => { if (!searchQuery.trim()) { setSearchResults(null); @@ -547,19 +486,20 @@ export default function Search() { ) : ( -

Search

+

Search Your Knowledge Base

)}
-
-
+
+
setSearchQuery(e.currentTarget.value)} onKeyDown={(e) => e.key === "Enter" && search()} + value={searchQuery} type="search" placeholder="Search Documents" /> @@ -572,10 +512,9 @@ export default function Search() { Find
- {}} - setUploadedFiles={setUploadedFiles} - /> + {searchResults === null && ( + + )} {searchResultsLoading && (
0 && (
+ {searchResults.map((result, index) => { return ( @@ -618,145 +565,138 @@ export default function Search() {
)} - {searchResults === null && ( -
- {fileObjectsLoading && ( -
- -
- )} - {error &&
{error}
} + {!searchResultsLoading && + searchResults === null && + !searchQuery.trim() && ( +
+ {fileObjectsLoading && ( +
+ +
+ )} + {error &&
{error}
} -
- {files.map((file, index) => ( - - - - - -
{ - setSelectedFileFullText( - null, - ); - setSelectedFile( - file.file_name, - ); - }} - > - {file.file_name - .split("/") - .pop()} -
-
- - - +
+ {files.map((file, index) => ( + + + + + +
{ + setSelectedFileFullText( + null, + ); + setSelectedFile( + file.file_name, + ); + }} + > {file.file_name .split("/") .pop()} - - - -

- {!selectedFileFullText && ( - - )} - {selectedFileFullText} -

-
- -
- - - - - - - - - - - - - - Delete File - - - - Are you sure you - want to delete - this file? - - - - Cancel - - + + + + +
+ {file.file_name + .split("/") + .pop()} + +
+
+
+ +

+ {!selectedFileFullText && ( + + )} + { + selectedFileFullText + } +

+
+
+
+ + + + + + + + + + +
+
+ + +

+ {file.raw_text.slice(0, 100)}... +

+
+
+ +
+ {formatDateTime(file.updated_at)} +
+
+
+ ))} +
-
- )} + )} {searchResults && searchResults.length === 0 && ( diff --git a/src/interface/web/app/settings/page.tsx b/src/interface/web/app/settings/page.tsx index cd419e40..7452e4f9 100644 --- a/src/interface/web/app/settings/page.tsx +++ b/src/interface/web/app/settings/page.tsx @@ -3,7 +3,7 @@ import styles from "./settings.module.css"; import "intl-tel-input/styles"; -import { Suspense, useEffect, useRef, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useToast } from "@/components/ui/use-toast"; import { useUserConfig, ModelOptions, UserConfig, SubscriptionStates } from "../common/auth"; @@ -23,14 +23,6 @@ import { 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, @@ -56,320 +48,20 @@ import { ArrowCircleUp, ArrowCircleDown, ArrowsClockwise, - Check, CaretDown, Waveform, + MagnifyingGlass, + Brain, } from "@phosphor-icons/react"; 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"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { AppSidebar } from "../components/appSidebar/appSidebar"; import { Separator } from "@/components/ui/separator"; import { KhojLogoType } from "../components/logo/khojLogo"; -const ManageFilesModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { - const [syncedFiles, setSyncedFiles] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [isDragAndDropping, setIsDragAndDropping] = useState(false); - - const [warning, setWarning] = useState(null); - const [error, setError] = useState(null); - const [uploading, setUploading] = useState(false); - const [progressValue, setProgressValue] = useState(0); - const [uploadedFiles, setUploadedFiles] = useState([]); - const fileInputRef = useRef(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; - - if (filesToDelete.length === 0) { - 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([]); - } catch (error) { - console.error("Error deleting files:", error); - } - }; - - const deleteFile = async (filename: string) => { - 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)); - } catch (error) { - console.error("Error deleting file:", error); - } - }; - - function handleDragOver(event: React.DragEvent) { - event.preventDefault(); - setIsDragAndDropping(true); - } - - function handleDragLeave(event: React.DragEvent) { - event.preventDefault(); - setIsDragAndDropping(false); - } - - function handleDragAndDropFiles(event: React.DragEvent) { - 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) { - if (!event.target.files) return; - - uploadFiles(event.target.files); - } - - function uploadFiles(files: FileList) { - uploadDataForIndexing(files, setWarning, setUploading, setError, setUploadedFiles); - } - - return ( - - - - - Alert - - {warning || error} - { - setWarning(null); - setError(null); - setUploading(false); - }} - > - Close - - - -
- -
- Upload files - {uploading && ( - - )} -
-
-
- {isDragAndDropping ? ( -
- - Drop files to upload -
- ) : ( -
- - Drag and drop files here -
- )} -
-
-
-
-
- -
-
- - - {syncedFiles.length === 0 ? ( -
- - No files synced -
- ) : ( -
- Could not find a good match. - - Need advanced search? Click here. - -
- )} -
- - {filteredFiles.map((filename: string) => ( - { - setSelectedFiles((prev) => - prev.includes(value) - ? prev.filter((f) => f !== value) - : [...prev, value], - ); - }} - > -
-
- {selectedFiles.includes(filename) && ( - - )} - {filename} -
- -
-
- ))} -
-
-
- -
-
- -
-
-
-
- ); -}; - interface DropdownComponentProps { items: ModelOptions[]; selected: number; @@ -508,7 +200,6 @@ export default function SettingsView() { const [numberValidationState, setNumberValidationState] = useState( PhoneNumberValidationState.Verified, ); - const [isManageFilesModalOpen, setIsManageFilesModalOpen] = useState(false); const { toast } = useToast(); const isMobileWidth = useIsMobileWidth(); @@ -1066,18 +757,13 @@ export default function SettingsView() {
- {isManageFilesModalOpen && ( - setIsManageFilesModalOpen(false)} - /> - )}
Content
- - Files + + Knowledge Base {userConfig.enabled_content_source.computer && ( - Manage your synced files + Manage and search through your digital brain. diff --git a/src/khoj/database/management/commands/delete_orphaned_fileobjects.py b/src/khoj/database/management/commands/delete_orphaned_fileobjects.py index 95cec137..99d45c6f 100644 --- a/src/khoj/database/management/commands/delete_orphaned_fileobjects.py +++ b/src/khoj/database/management/commands/delete_orphaned_fileobjects.py @@ -32,18 +32,25 @@ class Command(BaseCommand): batch_size = 1000 processed = 0 - while True: - batch = orphaned_files[:batch_size] - if not batch: + while processed < total_orphaned: + # Get batch of IDs to process + batch_ids = list(orphaned_files.values_list("id", flat=True)[:batch_size]) + if not batch_ids: break if options["apply"]: - count = batch.delete()[0] + # Delete by ID to avoid slice/limit issues + count = FileObject.objects.filter(id__in=batch_ids).delete()[0] processed += count self.stdout.write(f"Deleted {processed}/{total_orphaned} orphaned FileObjects") else: - processed += len(batch) + processed += len(batch_ids) self.stdout.write(f"Would delete {processed}/{total_orphaned} orphaned FileObjects") + # Re-query to get fresh state + orphaned_files = FileObject.objects.annotate( + has_entries=Exists(Entry.objects.filter(file_object=OuterRef("pk"))) + ).filter(has_entries=False) + action = "Deleted" if options["apply"] else "Would delete" self.stdout.write(self.style.SUCCESS(f"{action} {processed} orphaned FileObjects")) From f170487338630f1c82fe2dcfc9b8571967411d5e Mon Sep 17 00:00:00 2001 From: sabaimran Date: Fri, 10 Jan 2025 21:58:17 -0800 Subject: [PATCH 06/25] Fix apostrophe in the add documents modal --- src/interface/web/app/search/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interface/web/app/search/page.tsx b/src/interface/web/app/search/page.tsx index 4eb94179..03a59b5f 100644 --- a/src/interface/web/app/search/page.tsx +++ b/src/interface/web/app/search/page.tsx @@ -245,8 +245,8 @@ const UploadFiles: React.FC<{ Build Your Knowledge Base - Add your files to supercharge Khoj's AI with your knowledge. Get instant, - personalized answers powered by your own documents and data. + Add your files to supercharge Khoj`'`s AI with your knowledge. Get + instant, personalized answers powered by your own documents and data.
Date: Fri, 10 Jan 2025 22:18:44 -0800 Subject: [PATCH 07/25] Fix Obsidian style.css --- src/interface/obsidian/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 23113c90..f7c067ed 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -858,4 +858,4 @@ img.copy-icon { 100% { transform: rotate(360deg); } -} \ No newline at end of file +} From a9c180d85f1bab0dc13d09a0c24d66d744f22d69 Mon Sep 17 00:00:00 2001 From: Sam Ho Date: Sat, 11 Jan 2025 17:19:35 +0000 Subject: [PATCH 08/25] feat: add delete chat message action to the Obsidian plugin --- src/interface/obsidian/src/chat_view.ts | 64 ++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 86d5e1f9..61b884b6 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -635,6 +635,22 @@ export class KhojChatView extends KhojPaneView { setIcon(pasteToFile, "clipboard-paste"); pasteToFile.addEventListener('click', (event) => { pasteTextAtCursor(createCopyParentText(message, 'clipboard-paste')(event)); }); + + // Add delete button + let deleteButton = this.contentEl.createEl('button'); + deleteButton.classList.add("chat-action-button"); + deleteButton.title = "Delete Message"; + setIcon(deleteButton, "trash-2"); + deleteButton.addEventListener('click', () => { + const messageEl = chatMessageBodyTextEl.closest('.khoj-chat-message'); + if (messageEl) { + // Ask for confirmation before deleting + if (confirm('Are you sure you want to delete this message?')) { + this.deleteMessage(messageEl as HTMLElement); + } + } + }); + // Only enable the speech feature if the user is subscribed let speechButton = null; @@ -648,8 +664,7 @@ export class KhojChatView extends KhojPaneView { } // Append buttons to parent element - chatMessageBodyTextEl.append(copyButton, pasteToFile); - + chatMessageBodyTextEl.append(copyButton, pasteToFile, deleteButton); if (speechButton) { chatMessageBodyTextEl.append(speechButton); } @@ -1487,4 +1502,49 @@ export class KhojChatView extends KhojPaneView { } } } + + // Add this new method to handle message deletion + async deleteMessage(messageEl: HTMLElement) { + const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; + const conversationId = chatBodyEl.dataset.conversationId; + + // Get the turn_id from the message's data-meta attribute + const turnId = messageEl.getAttribute("data-meta"); + if (!turnId || !conversationId) return; + + try { + const response = await fetch(`${this.setting.khojUrl}/api/chat/conversation/message`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.setting.khojApiKey}` + }, + body: JSON.stringify({ + conversation_id: conversationId, + turn_id: turnId + }) + }); + + if (response.ok) { + // Remove both the user message and Khoj response (the conversation turn) + const isKhojMessage = messageEl.classList.contains("khoj"); + const messages = Array.from(chatBodyEl.getElementsByClassName("khoj-chat-message")); + const messageIndex = messages.indexOf(messageEl); + + if (isKhojMessage && messageIndex > 0) { + // If it is a Khoj message, remove the previous user message too + messages[messageIndex - 1].remove(); + } else if (!isKhojMessage && messageIndex < messages.length - 1) { + // If it is a user message, remove the next Khoj message too + messages[messageIndex + 1].remove(); + } + messageEl.remove(); + } else { + this.flashStatusInChatInput("Failed to delete message"); + } + } catch (error) { + console.error("Error deleting message:", error); + this.flashStatusInChatInput("Error deleting message"); + } + } } From 93687f141ad1ab72c8d561a6b952bdb3eae779cc Mon Sep 17 00:00:00 2001 From: Sam Ho Date: Sat, 11 Jan 2025 17:35:57 +0000 Subject: [PATCH 09/25] feat: do not show delete button on system messages --- src/interface/obsidian/src/chat_view.ts | 80 ++++++++++++++++++------- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 61b884b6..ac2e106d 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -41,6 +41,16 @@ interface Location { timezone: string; } +interface RenderMessageOptions { + chatBodyEl: Element; + message: string; + sender: string; + dt?: Date; + raw?: boolean; + willReplace?: boolean; + isSystemMessage?: boolean; +} + export class KhojChatView extends KhojPaneView { result: string; setting: KhojSetting; @@ -498,9 +508,19 @@ export class KhojChatView extends KhojPaneView { (images && images.length > 0) || excalidrawDiagram) { let imageMarkdown = this.generateImageMarkdown(message, intentType ?? "", inferredQueries, conversationId, images, excalidrawDiagram); - chatMessageEl = this.renderMessage(chatEl, imageMarkdown, sender, dt); + chatMessageEl = this.renderMessage({ + chatBodyEl: chatEl, + message: imageMarkdown, + sender, + dt + }); } else { - chatMessageEl = this.renderMessage(chatEl, message, sender, dt); + chatMessageEl = this.renderMessage({ + chatBodyEl: chatEl, + message, + sender, + dt + }); } // If no document or online context is provided, skip rendering the reference section @@ -550,7 +570,7 @@ export class KhojChatView extends KhojPaneView { return imageMarkdown; } - renderMessage(chatBodyEl: Element, message: string, sender: string, dt?: Date, raw: boolean = false, willReplace: boolean = true): Element { + renderMessage({ chatBodyEl, message, sender, dt, raw = false, willReplace = true, isSystemMessage = false }: RenderMessageOptions): Element { let message_time = this.formatDate(dt ?? new Date()); // Append message to conversation history HTML element. @@ -577,7 +597,7 @@ export class KhojChatView extends KhojPaneView { // Add action buttons to each chat message element if (willReplace === true) { - this.renderActionButtons(message, chatMessageBodyTextEl); + this.renderActionButtons(message, chatMessageBodyTextEl, isSystemMessage); } // Remove user-select: none property to make text selectable @@ -621,7 +641,7 @@ export class KhojChatView extends KhojPaneView { this.scrollChatToBottom(); } - renderActionButtons(message: string, chatMessageBodyTextEl: HTMLElement) { + renderActionButtons(message: string, chatMessageBodyTextEl: HTMLElement, isSystemMessage: boolean = false) { let copyButton = this.contentEl.createEl('button'); copyButton.classList.add("chat-action-button"); copyButton.title = "Copy Message to Clipboard"; @@ -637,19 +657,22 @@ export class KhojChatView extends KhojPaneView { // Add delete button - let deleteButton = this.contentEl.createEl('button'); - deleteButton.classList.add("chat-action-button"); - deleteButton.title = "Delete Message"; - setIcon(deleteButton, "trash-2"); - deleteButton.addEventListener('click', () => { - const messageEl = chatMessageBodyTextEl.closest('.khoj-chat-message'); - if (messageEl) { - // Ask for confirmation before deleting - if (confirm('Are you sure you want to delete this message?')) { - this.deleteMessage(messageEl as HTMLElement); + let deleteButton = null; + if (!isSystemMessage) { + deleteButton = this.contentEl.createEl('button'); + deleteButton.classList.add("chat-action-button"); + deleteButton.title = "Delete Message"; + setIcon(deleteButton, "trash-2"); + deleteButton.addEventListener('click', () => { + const messageEl = chatMessageBodyTextEl.closest('.khoj-chat-message'); + if (messageEl) { + // Ask for confirmation before deleting + if (confirm('Are you sure you want to delete this message?')) { + this.deleteMessage(messageEl as HTMLElement); + } } - } - }); + }); + } // Only enable the speech feature if the user is subscribed let speechButton = null; @@ -664,7 +687,10 @@ export class KhojChatView extends KhojPaneView { } // Append buttons to parent element - chatMessageBodyTextEl.append(copyButton, pasteToFile, deleteButton); + chatMessageBodyTextEl.append(copyButton, pasteToFile); + if (deleteButton) { + chatMessageBodyTextEl.append(deleteButton); + } if (speechButton) { chatMessageBodyTextEl.append(speechButton); } @@ -690,7 +716,7 @@ export class KhojChatView extends KhojPaneView { if (chatInput) { chatInput.placeholder = this.startingMessage; } - this.renderMessage(chatBodyEl, "Hey 👋🏾, what's up?", "khoj"); + this.renderMessage({chatBodyEl, message: "Hey 👋🏾, what's up?", sender: "khoj", isSystemMessage: true}); } async toggleChatSessions(forceShow: boolean = false): Promise { @@ -901,7 +927,12 @@ export class KhojChatView extends KhojPaneView { if (responseJson.detail) { // If the server returns error details in response, render a setup hint. let setupMsg = "Hi 👋🏾, to start chatting add available chat models options via [the Django Admin panel](/server/admin) on the Server"; - this.renderMessage(chatBodyEl, setupMsg, "khoj", undefined); + this.renderMessage({ + chatBodyEl, + message: setupMsg, + sender: "khoj", + isSystemMessage: true + }); return false; } else if (responseJson.response) { @@ -944,7 +975,12 @@ export class KhojChatView extends KhojPaneView { } } catch (err) { let errorMsg = "Unable to get response from Khoj server ❤️‍🩹. Ensure server is running or contact developers for help at [team@khoj.dev](mailto:team@khoj.dev) or in [Discord](https://discord.gg/BDgyabRM6e)"; - this.renderMessage(chatBodyEl, errorMsg, "khoj", undefined); + this.renderMessage({ + chatBodyEl, + message: errorMsg, + sender: "khoj", + isSystemMessage: true + }); return false; } return true; @@ -1082,7 +1118,7 @@ export class KhojChatView extends KhojPaneView { // Render user query as chat message let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; - this.renderMessage(chatBodyEl, query, "you"); + this.renderMessage({chatBodyEl, message: query, sender: "you"}); let conversationId = chatBodyEl.dataset.conversationId; if (!conversationId) { From 27165b3f4a956184cf8a99ec169e4bb14b6a2f27 Mon Sep 17 00:00:00 2001 From: Yash-1511 Date: Sun, 12 Jan 2025 15:12:14 +0530 Subject: [PATCH 10/25] fix: review suggestions --- src/interface/web/app/search/page.tsx | 120 +++++++++++++------------- src/khoj/routers/api.py | 16 ---- 2 files changed, 61 insertions(+), 75 deletions(-) diff --git a/src/interface/web/app/search/page.tsx b/src/interface/web/app/search/page.tsx index 1c099a7a..902b9631 100644 --- a/src/interface/web/app/search/page.tsx +++ b/src/interface/web/app/search/page.tsx @@ -174,10 +174,9 @@ export default function Search() { const [focusSearchResult, setFocusSearchResult] = useState(null); const [exampleQuery, setExampleQuery] = useState(""); const [fileSuggestions, setFileSuggestions] = useState([]); + const [allFiles, setAllFiles] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const searchTimeoutRef = useRef(null); - const suggestionsTimeoutRef = useRef(null); - const isMobileWidth = useIsMobileWidth(); useEffect(() => { @@ -186,8 +185,68 @@ export default function Search() { Math.floor(Math.random() * naturalLanguageSearchQueryExamples.length) ], ); + + // Load all files once on page load + fetch('/api/content/computer', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(response => response.json()) + .then(data => { + setAllFiles(data); + }) + .catch(error => { + console.error('Error loading files:', error); + }); }, []); + function getFileSuggestions(query: string) { + const fileFilterMatch = query.match(/file:([^"\s]*|"[^"]*")?/); + if (!fileFilterMatch) { + setFileSuggestions([]); + setShowSuggestions(false); + return; + } + + const filePrefix = fileFilterMatch[1]?.replace(/^"|"$/g, '').trim() || ''; + const filteredSuggestions = allFiles + .filter(file => file.toLowerCase().includes(filePrefix.toLowerCase())) + .sort() + .slice(0, 10); + + setFileSuggestions(filteredSuggestions); + setShowSuggestions(true); + } + + function handleSearchInputChange(value: string) { + setSearchQuery(value); + + // Clear previous search timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // Get file suggestions immediately + getFileSuggestions(value); + + // Debounce search + if (value.trim()) { + searchTimeoutRef.current = setTimeout(() => { + search(); + }, 750); + } + } + + function applySuggestion(suggestion: string) { + // Replace the file: filter with the selected suggestion + const newQuery = searchQuery.replace(/file:([^"\s]*|"[^"]*")?/, `file:"${suggestion}"`); + setSearchQuery(newQuery); + setShowSuggestions(false); + search(); + } + function search() { if (searchResultsLoading || !searchQuery.trim()) return; @@ -208,63 +267,6 @@ export default function Search() { }); } - function getFileSuggestions(query: string) { - // Get suggestions only if query starts with "file:" - if (!query.toLowerCase().startsWith("file:")) { - setFileSuggestions([]); - setShowSuggestions(false); - return; - } - - const filePrefix = query.substring(5).trim(); // Remove "file:" prefix - const apiUrl = `/api/file-suggestions?q=${encodeURIComponent(filePrefix)}`; - - fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => response.json()) - .then((data) => { - setFileSuggestions(data); - setShowSuggestions(true); - }) - .catch((error) => { - console.error("Error:", error); - }); - } - - function handleSearchInputChange(value: string) { - setSearchQuery(value); - - // Clear previous timeouts - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - if (suggestionsTimeoutRef.current) { - clearTimeout(suggestionsTimeoutRef.current); - } - - // Get file suggestions immediately - suggestionsTimeoutRef.current = setTimeout(() => { - getFileSuggestions(value); - }, 100); - - // Debounce search - if (value.trim()) { - searchTimeoutRef.current = setTimeout(() => { - search(); - }, 750); - } - } - - function applySuggestion(suggestion: string) { - setSearchQuery(`file:${suggestion}`); - setShowSuggestions(false); - search(); - } - return ( diff --git a/src/khoj/routers/api.py b/src/khoj/routers/api.py index d5cb060d..a29e993a 100644 --- a/src/khoj/routers/api.py +++ b/src/khoj/routers/api.py @@ -757,19 +757,3 @@ def edit_job( # Return modified automation information as a JSON response return Response(content=json.dumps(automation_info), media_type="application/json", status_code=200) - - -@api.get("/file-suggestions", response_class=Response) -@requires(["authenticated"]) -def get_file_suggestions(request: Request, q: str = ""): - """Get file suggestions for autocompletion based on the query prefix.""" - user = request.user.object - file_list = EntryAdapters.get_all_filenames_by_source(user, "computer") - - # Filter files based on query prefix - suggestions = [f for f in file_list if f.lower().startswith(q.lower())] - - # Sort suggestions alphabetically and limit to top 10 - suggestions = sorted(suggestions)[:10] - - return Response(content=json.dumps(suggestions), media_type="application/json") From fc6fab4cce8e544fce1c85e42302c621092954c9 Mon Sep 17 00:00:00 2001 From: Sam Ho Date: Mon, 13 Jan 2025 20:07:48 +0000 Subject: [PATCH 11/25] chore: fix format issue from pre-commit hook - trailing-whitespace and end-of-file-fixer --- src/interface/obsidian/src/chat_view.ts | 6 +++--- src/interface/obsidian/styles.css | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index ac2e106d..8d281755 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -655,7 +655,7 @@ export class KhojChatView extends KhojPaneView { setIcon(pasteToFile, "clipboard-paste"); pasteToFile.addEventListener('click', (event) => { pasteTextAtCursor(createCopyParentText(message, 'clipboard-paste')(event)); }); - + // Add delete button let deleteButton = null; if (!isSystemMessage) { @@ -1543,7 +1543,7 @@ export class KhojChatView extends KhojPaneView { async deleteMessage(messageEl: HTMLElement) { const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; const conversationId = chatBodyEl.dataset.conversationId; - + // Get the turn_id from the message's data-meta attribute const turnId = messageEl.getAttribute("data-meta"); if (!turnId || !conversationId) return; @@ -1566,7 +1566,7 @@ export class KhojChatView extends KhojPaneView { const isKhojMessage = messageEl.classList.contains("khoj"); const messages = Array.from(chatBodyEl.getElementsByClassName("khoj-chat-message")); const messageIndex = messages.indexOf(messageEl); - + if (isKhojMessage && messageIndex > 0) { // If it is a Khoj message, remove the previous user message too messages[messageIndex - 1].remove(); diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 23113c90..f7c067ed 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -858,4 +858,4 @@ img.copy-icon { 100% { transform: rotate(360deg); } -} \ No newline at end of file +} From f8f159efac58341627505c13de7afba396047a38 Mon Sep 17 00:00:00 2001 From: Sam Ho Date: Thu, 16 Jan 2025 00:44:16 +0000 Subject: [PATCH 12/25] feat: add turnId handling to chat messages and history --- src/interface/obsidian/src/chat_view.ts | 38 +++++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 8d281755..ebf2ad8d 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -31,6 +31,7 @@ interface ChatMessageState { rawResponse: string; rawQuery: string; isVoice: boolean; + turnId: string; } interface Location { @@ -45,6 +46,7 @@ interface RenderMessageOptions { chatBodyEl: Element; message: string; sender: string; + turnId?: string; dt?: Date; raw?: boolean; willReplace?: boolean; @@ -490,6 +492,7 @@ export class KhojChatView extends KhojPaneView { chatEl: Element, message: string, sender: string, + turnId: string, context?: string[], onlineContext?: object, dt?: Date, @@ -497,7 +500,7 @@ export class KhojChatView extends KhojPaneView { inferredQueries?: string[], conversationId?: string, images?: string[], - excalidrawDiagram?: string + excalidrawDiagram?: string, ) { if (!message) return; @@ -512,14 +515,16 @@ export class KhojChatView extends KhojPaneView { chatBodyEl: chatEl, message: imageMarkdown, sender, - dt + dt, + turnId }); } else { chatMessageEl = this.renderMessage({ chatBodyEl: chatEl, message, sender, - dt + dt, + turnId }); } @@ -570,7 +575,7 @@ export class KhojChatView extends KhojPaneView { return imageMarkdown; } - renderMessage({ chatBodyEl, message, sender, dt, raw = false, willReplace = true, isSystemMessage = false }: RenderMessageOptions): Element { + renderMessage({ chatBodyEl, message, sender, dt, turnId, raw = false, willReplace = true, isSystemMessage = false }: RenderMessageOptions): Element { let message_time = this.formatDate(dt ?? new Date()); // Append message to conversation history HTML element. @@ -578,7 +583,8 @@ export class KhojChatView extends KhojPaneView { let chatMessageEl = chatBodyEl.createDiv({ attr: { "data-meta": message_time, - class: `khoj-chat-message ${sender}` + class: `khoj-chat-message ${sender}`, + ...(turnId && { "data-turnId": turnId }) }, }) let chatMessageBodyEl = chatMessageEl.createDiv(); @@ -946,6 +952,7 @@ export class KhojChatView extends KhojPaneView { chatBodyEl, chatLog.message, chatLog.by, + chatLog.turnId, chatLog.context, chatLog.onlineContext, new Date(chatLog.created), @@ -1025,7 +1032,7 @@ export class KhojChatView extends KhojPaneView { this.textToSpeech(this.chatMessageState.rawResponse); // Append any references after all the data has been streamed - this.finalizeChatBodyResponse(this.chatMessageState.references, this.chatMessageState.newResponseTextEl); + this.finalizeChatBodyResponse(this.chatMessageState.references, this.chatMessageState.newResponseTextEl, this.chatMessageState.turnId); const liveQuery = this.chatMessageState.rawQuery; // Reset variables @@ -1038,6 +1045,7 @@ export class KhojChatView extends KhojPaneView { rawQuery: liveQuery, isVoice: false, generatedAssets: "", + turnId: "", }; } else if (chunk.type === "references") { this.chatMessageState.references = { "notes": chunk.data.context, "online": chunk.data.onlineContext }; @@ -1059,6 +1067,12 @@ export class KhojChatView extends KhojPaneView { this.chatMessageState.rawResponse += chunkData; this.handleStreamResponse(this.chatMessageState.newResponseTextEl, this.chatMessageState.rawResponse + this.chatMessageState.generatedAssets, this.chatMessageState.loadingEllipsis); } + } else if (chunk.type === "metadata") { + const { turnId } = chunk.data; + if (turnId) { + // Append turnId to chatMessageState + this.chatMessageState.turnId = turnId; + } } } @@ -1164,6 +1178,7 @@ export class KhojChatView extends KhojPaneView { rawResponse: "", isVoice: isVoice, generatedAssets: "", + turnId: "", }; let response = await fetch(chatUrl, { @@ -1466,10 +1481,15 @@ export class KhojChatView extends KhojPaneView { return rawResponse; } - finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null) { + finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null, turnId: string) { if (!!newResponseElement && references != null && Object.keys(references).length > 0) { newResponseElement.appendChild(this.createReferenceSection(references)); } + if (!!newResponseElement && turnId) { + // Set the turnId for the new response and the previous user message + newResponseElement.parentElement?.setAttribute("data-turnId", turnId); + newResponseElement.parentElement?.previousElementSibling?.setAttribute("data-turnId", turnId); + } this.scrollChatToBottom(); let chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; if (chatInput) chatInput.removeAttribute("disabled"); @@ -1544,8 +1564,8 @@ export class KhojChatView extends KhojPaneView { const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; const conversationId = chatBodyEl.dataset.conversationId; - // Get the turn_id from the message's data-meta attribute - const turnId = messageEl.getAttribute("data-meta"); + // Get the turnId from the message's data-turn attribute + const turnId = messageEl.getAttribute("data-turnId"); if (!turnId || !conversationId) return; try { From 83d856f97d1cffbe8491bfd75f7fd165438858e6 Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sun, 19 Jan 2025 22:48:36 -0800 Subject: [PATCH 13/25] Add some basic pagination logic to the knowledge page to prevent overloading the Api or the client --- src/interface/web/app/search/page.tsx | 349 ++++++++++++------ .../web/components/ui/pagination.tsx | 118 ++++++ src/khoj/database/adapters/__init__.py | 13 +- src/khoj/routers/api_content.py | 18 +- 4 files changed, 376 insertions(+), 122 deletions(-) create mode 100644 src/interface/web/components/ui/pagination.tsx diff --git a/src/interface/web/app/search/page.tsx b/src/interface/web/app/search/page.tsx index 03a59b5f..c7ac9b3c 100644 --- a/src/interface/web/app/search/page.tsx +++ b/src/interface/web/app/search/page.tsx @@ -55,6 +55,16 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + import { uploadDataForIndexing } from "../common/chatFunctions"; import { Progress } from "@/components/ui/progress"; interface AdditionalData { @@ -88,6 +98,132 @@ function getNoteTypeIcon(source: string) { return ; } +interface FileCardProps { + file: FileObject; + index: number; + setSelectedFile: (file: string) => void; + setSelectedFileFullText: (file: string) => void; + handleDownload: (fileName: string, content: string) => void; + handleDelete: (fileName: string) => void; + isDeleting: boolean; + selectedFileFullText: string | null; +} + +function FileCard({ file, setSelectedFile, setSelectedFileFullText, handleDownload, handleDelete, isDeleting, selectedFileFullText }: FileCardProps) { + return ( + + + + + +
{ + setSelectedFileFullText( + '', + ); + setSelectedFile( + file.file_name, + ); + }} + > + {file.file_name + .split("/") + .pop()} +
+
+ + + +
+ {file.file_name + .split("/") + .pop()} + +
+
+
+ +

+ {!selectedFileFullText && ( + + )} + { + selectedFileFullText + } +

+
+
+
+ + + + + + + + + + +
+
+ + +

+ {file.raw_text.slice(0, 100)}... +

+
+
+ +
+ {formatDateTime(file.updated_at)} +
+
+
+ ) +} + interface NoteResultProps { note: SearchResult; setFocusSearchResult: (note: SearchResult) => void; @@ -338,6 +474,8 @@ export default function Search() { const [selectedFileFullText, setSelectedFileFullText] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [uploadedFiles, setUploadedFiles] = useState([]); + const [pageNumber, setPageNumber] = useState(0); + const [numPages, setNumPages] = useState(1); const { toast } = useToast(); @@ -365,15 +503,22 @@ export default function Search() { }); } - const fetchFiles = async () => { + const fetchFiles = async (currentPageNumber: number) => { try { - const response = await fetch("/api/content/all"); + const url = `api/content/all?page=${currentPageNumber}`; + const response = await fetch(url); if (!response.ok) throw new Error("Failed to fetch files"); - const filesList = await response.json(); + const filesData = await response.json(); + const filesList = filesData.files; + const totalPages = filesData.num_pages; + + setNumPages(totalPages); + if (Array.isArray(filesList)) { setFiles(filesList.toSorted()); } + } catch (error) { setError("Failed to load files"); console.error("Error fetching files:", error); @@ -382,6 +527,10 @@ export default function Search() { } }; + useEffect(() => { + fetchFiles(pageNumber); + }, [pageNumber]); + const fetchSpecificFile = async (fileName: string) => { try { const response = await fetch(`/api/content/file?file_name=${fileName}`); @@ -439,12 +588,13 @@ export default function Search() { }, [selectedFile]); useEffect(() => { - fetchFiles(); + fetchFiles(pageNumber); }, []); useEffect(() => { if (uploadedFiles.length > 0) { - fetchFiles(); + setPageNumber(0); + fetchFiles(0); } }, [uploadedFiles]); @@ -462,7 +612,8 @@ export default function Search() { }); // Refresh files list - fetchFiles(); + setPageNumber(0); + fetchFiles(0); } catch (error) { toast({ title: "Error deleting file", @@ -582,119 +733,85 @@ export default function Search() {
{files.map((file, index) => ( - - - - - -
{ - setSelectedFileFullText( - null, - ); - setSelectedFile( - file.file_name, - ); - }} - > - {file.file_name - .split("/") - .pop()} -
-
- - - -
- {file.file_name - .split("/") - .pop()} - -
-
-
- -

- {!selectedFileFullText && ( - - )} - { - selectedFileFullText - } -

-
-
-
- - - - - - - - - - -
-
- - -

- {file.raw_text.slice(0, 100)}... -

-
-
- -
- {formatDateTime(file.updated_at)} -
-
-
+ file={file} + index={index} + setSelectedFile={setSelectedFile} + setSelectedFileFullText={setSelectedFileFullText} + handleDownload={handleDownload} + handleDelete={handleDelete} + isDeleting={isDeleting} + selectedFileFullText={selectedFileFullText} + /> ))}
+ + + + {/* Show prev button if not on first page */} + {pageNumber > 0 && ( + + setPageNumber(pageNumber - 1)} /> + + )} + + {/* Show first page if not on first two pages */} + {pageNumber > 1 && ( + + setPageNumber(0)}>1 + + )} + + {/* Show ellipsis if there's a gap */} + {pageNumber > 2 && ( + + + + )} + + {/* Show previous page if not on first page */} + {pageNumber > 0 && ( + + setPageNumber(pageNumber - 1)}>{pageNumber} + + )} + + {/* Current page */} + + {pageNumber + 1} + + + {/* Show next page if not on last page */} + {pageNumber < numPages - 1 && ( + + setPageNumber(pageNumber + 1)}>{pageNumber + 2} + + )} + + {/* Show ellipsis if there's a gap before last page */} + {pageNumber < numPages - 3 && ( + + + + )} + + {/* Show last page if not on last two pages */} + {pageNumber < numPages - 2 && ( + + setPageNumber(numPages - 1)}>{numPages} + + )} + + {/* Show next button if not on last page */} + {pageNumber < numPages - 1 && ( + + setPageNumber(pageNumber + 1)} /> + + )} + + +
)} {searchResults && searchResults.length === 0 && ( diff --git a/src/interface/web/components/ui/pagination.tsx b/src/interface/web/components/ui/pagination.tsx new file mode 100644 index 00000000..c5c99bea --- /dev/null +++ b/src/interface/web/components/ui/pagination.tsx @@ -0,0 +1,118 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +