Add file viewer tool to enable researcher to read documents

Allow reading whole file contents or content in specified line range
in user's knowledge base. This allows for more deterministic
traversal.
This commit is contained in:
Debanjum
2025-06-13 22:20:36 -07:00
parent 721c55a37b
commit 2f9f608cff
3 changed files with 132 additions and 3 deletions

View File

@@ -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}]

View File

@@ -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

View File

@@ -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 = {