diff --git a/src/khoj/routers/helpers.py b/src/khoj/routers/helpers.py index 0fba26fb..23977444 100644 --- a/src/khoj/routers/helpers.py +++ b/src/khoj/routers/helpers.py @@ -2801,3 +2801,80 @@ def get_notion_auth_url(user: KhojUser): if not NOTION_OAUTH_CLIENT_ID or not NOTION_OAUTH_CLIENT_SECRET or not NOTION_REDIRECT_URI: return None return f"https://api.notion.com/v1/oauth/authorize?client_id={NOTION_OAUTH_CLIENT_ID}&redirect_uri={NOTION_REDIRECT_URI}&response_type=code&state={user.uuid}" + + +async def view_file_content( + path: str, + start_line: Optional[int] = None, + end_line: Optional[int] = None, + user: KhojUser = None, +): + """ + View the contents of a file from the user's document database with optional line range specification. + """ + query = f"View file: {path}" + if start_line and end_line: + query += f" (lines {start_line}-{end_line})" + + try: + # Get the file object from the database by name + file_objects = await FileObjectAdapters.aget_file_objects_by_name(user, path) + + if not file_objects: + error_msg = f"File '{path}' not found in user documents" + logger.warning(error_msg) + yield [{"query": query, "file": path, "compiled": error_msg}] + return + + # Use the first file object if multiple exist + file_object = file_objects[0] + raw_text = file_object.raw_text + + # Apply line range filtering if specified + if start_line is None and end_line is None: + filtered_text = raw_text + else: + lines = raw_text.split("\n") + start_line = start_line or 1 + end_line = end_line or len(lines) + + # Validate line range + if start_line < 1 or end_line < 1 or start_line > end_line: + error_msg = f"Invalid line range: {start_line}-{end_line}" + logger.warning(error_msg) + yield [{"query": query, "file": path, "compiled": error_msg}] + return + if start_line > len(lines): + error_msg = f"Start line {start_line} exceeds total number of lines {len(lines)}" + logger.warning(error_msg) + yield [{"query": query, "file": path, "compiled": error_msg}] + return + + # Convert from 1-based to 0-based indexing and ensure bounds + start_idx = max(0, start_line - 1) + end_idx = min(len(lines), end_line) + + selected_lines = lines[start_idx:end_idx] + filtered_text = "\n".join(selected_lines) + + # Truncate the text if it's too long + if len(filtered_text) > 10000: + filtered_text = filtered_text[:10000] + "\n\n[Truncated. Use line numbers to view specific sections.]" + + # Format the result as a document reference + document_results = [ + { + "query": query, + "file": path, + "compiled": filtered_text, + } + ] + + yield document_results + + except Exception as e: + error_msg = f"Error viewing file {path}: {str(e)}" + logger.error(error_msg, exc_info=True) + + # Return an error result in the expected format + yield [{"query": query, "file": path, "compiled": error_msg}] diff --git a/src/khoj/routers/research.py b/src/khoj/routers/research.py index 6bb4b4bc..8cee7e8f 100644 --- a/src/khoj/routers/research.py +++ b/src/khoj/routers/research.py @@ -26,6 +26,7 @@ from khoj.routers.helpers import ( generate_summary_from_files, search_documents, send_message_to_model_wrapper, + view_file_content, ) from khoj.utils.helpers import ( ConversationCommand, @@ -89,10 +90,11 @@ async def apick_next_tool( # Skip showing operator tool as an option if not enabled if tool == ConversationCommand.Operator and not is_operator_enabled(): continue + # Skip showing document related tools if user has no documents + if (tool == ConversationCommand.Notes or tool == ConversationCommand.ViewFile) and not user_has_entries: + continue # Skip showing Notes tool as an option if user has no entries - elif tool == ConversationCommand.Notes: - if not user_has_entries: - continue + if tool == ConversationCommand.Notes: description = tool_data.description.format(max_search_queries=max_document_searches) elif tool == ConversationCommand.Webpage: description = tool_data.description.format(max_webpages_to_read=max_webpages_to_read) @@ -426,6 +428,25 @@ async def research( this_iteration.warning = f"Error operating browser: {e}" logger.error(this_iteration.warning, exc_info=True) + elif this_iteration.query.name == ConversationCommand.ViewFile: + try: + async for result in view_file_content( + **this_iteration.query.args, + user=user, + ): + if isinstance(result, dict) and ChatEvent.STATUS in result: + yield result[ChatEvent.STATUS] + else: + if this_iteration.context is None: + this_iteration.context = [] + document_results: List[Dict[str, str]] = result # type: ignore + this_iteration.context += document_results + async for result in send_status_func(f"**Viewed file**: {this_iteration.query.args['path']}"): + yield result + except Exception as e: + this_iteration.warning = f"Error viewing file: {e}" + logger.error(this_iteration.warning, exc_info=True) + else: # No valid tools. This is our exit condition. current_iteration = MAX_ITERATIONS diff --git a/src/khoj/utils/helpers.py b/src/khoj/utils/helpers.py index e4a02320..d0f18480 100644 --- a/src/khoj/utils/helpers.py +++ b/src/khoj/utils/helpers.py @@ -429,6 +429,7 @@ class ConversationCommand(str, Enum): Diagram = "diagram" Research = "research" Operator = "operator" + ViewFile = "view_file" command_descriptions = { @@ -442,6 +443,7 @@ command_descriptions = { ConversationCommand.Diagram: "Draw a flowchart, diagram, or any other visual representation best expressed with primitives like lines, rectangles, and text.", ConversationCommand.Research: "Do deep research on a topic. This will take longer than usual, but give a more detailed, comprehensive answer.", ConversationCommand.Operator: "Operate and perform tasks using a computer.", + ConversationCommand.ViewFile: "View the contents of a file with optional line range specification.", } command_descriptions_for_agent = { @@ -545,6 +547,35 @@ tools_for_research_llm = { "required": ["query"], }, ), + ConversationCommand.ViewFile: ToolDefinition( + name="view_file", + description=dedent( + """ + To view the contents of specific note or document in the user's personal knowledge base. + Especially helpful if the question expects context from the user's notes or documents. + It can be used after finding the document path with the document search tool. + Optionally specify a line range to view only specific sections of large files. + """ + ).strip(), + schema={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The file path to view (can be absolute or relative).", + }, + "start_line": { + "type": "integer", + "description": "Optional starting line number for viewing a specific range (1-indexed).", + }, + "end_line": { + "type": "integer", + "description": "Optional ending line number for viewing a specific range (1-indexed).", + }, + }, + "required": ["path"], + }, + ), } mode_descriptions_for_llm = {