diff --git a/src/jrdev/agents/research_agent.py b/src/jrdev/agents/research_agent.py new file mode 100644 index 0000000..b2c8ce3 --- /dev/null +++ b/src/jrdev/agents/research_agent.py @@ -0,0 +1,112 @@ +import json +from typing import Any, Dict, List, Optional + +from jrdev.core.tool_call import ToolCall +from jrdev.file_operations.file_utils import cutoff_string +from jrdev.messages.thread import MessageThread +from jrdev.messages.message_builder import MessageBuilder +from jrdev.prompts.prompt_utils import PromptManager +from jrdev.services.llm_requests import generate_llm_response +from jrdev.ui.ui import PrintType + + +class ResearchAgent: + ALLOWED_TOOLS = {"web_search", "web_scrape_url"} + + def __init__(self, app: Any, thread: MessageThread): + self.app = app + self.logger = app.logger + self.thread = thread + + async def interpret( + self, user_input: str, worker_id: str, previous_tool_calls: List[ToolCall] = None + ) -> Optional[Dict[str, Any]]: + """ + Interpret user input for research, decide on a tool to use, or provide a summary. + Returns a dictionary representing the LLM's decision. + """ + builder = MessageBuilder(self.app) + # Use the agent's private message history + if self.thread.messages: + builder.add_historical_messages(self.thread.messages) + + # Build the prompt for the LLM + research_prompt = PromptManager().load("researcher/research_prompt") + builder.add_system_message(research_prompt) + + # Add the actual user request + builder.append_to_user_section(f"User Research Request: {user_input}") + if previous_tool_calls: + call_summaries = "\n--- Previous Research Actions For This Request ---\n" + for tc in previous_tool_calls: + call_summaries += f"Tool Used: {tc.formatted_cmd}\nTool Results: {tc.result}\n" + builder.append_to_user_section(call_summaries) + + builder.finalize_user_section() + + messages = builder.build() + + # The user's input is part of the request, so add it to history. + if not previous_tool_calls: + self.thread.messages.append({"role": "user", "content": f"**Researching**: {user_input}"}) + + # Use a specific model for this task + research_model = self.app.state.model + response_text = await generate_llm_response(self.app, research_model, messages, task_id=worker_id) + + json_content = "" + try: + json_content = cutoff_string(response_text, "```json", "```") + response_json = json.loads(json_content) + except (json.JSONDecodeError, KeyError) as e: + self.logger.error( + f"Failed to parse research agent LLM response: {e}\nResponse:\n {response_text}\nRaw:\n{json_content}") + self.app.ui.print_text( + "Research agent had an issue parsing its own response. This may be a temporary issue. Aborting research task.", + print_type=PrintType.ERROR, + ) + return None + + # Add the structured assistant response to history *after* successful parsing. + self.thread.messages.append( + {"role": "assistant", "content": json.dumps(response_json, indent=2)} + ) + + decision = response_json.get("decision") + + if decision == "execute_action": + action = response_json.get("action") + if not action: + self.logger.error(f"Research agent decision was 'execute_action' but no action was provided. Response: {response_json}") + self.app.ui.print_text("Research agent decided to execute an action, but encountered an error. Aborting.", print_type=PrintType.ERROR) + return None + + tool_name = action.get("name") + if tool_name not in self.ALLOWED_TOOLS: + self.logger.error( + f"Research agent attempted to use an unauthorized tool: {tool_name}. Allowed tools are: {self.ALLOWED_TOOLS}" + ) + self.app.ui.print_text( + f"Research agent tried to use an unauthorized tool '{tool_name}'. Aborting research task.", + print_type=PrintType.ERROR, + ) + return None + + tool_call = ToolCall( + action_type="tool", + command=tool_name, + args=action["args"], + reasoning=response_json.get("reasoning", ""), + has_next=True, # Research agent always has a next step until it summarizes + ) + return {"type": "tool_call", "data": tool_call} + + if decision == "summary": + summary = response_json.get("response", "") + # Add summary to thread as final assistant message + self.thread.messages.append({"role": "assistant", "content": summary}) + return {"type": "summary", "data": summary} + + self.logger.error(f"Research agent returned an unknown decision: {decision}. Aborting.") + self.app.ui.print_text(f"Research agent returned an unknown decision: {decision}. Aborting.", print_type=PrintType.ERROR) + return None diff --git a/src/jrdev/commands/__init__.py b/src/jrdev/commands/__init__.py index fa18296..30f32ea 100644 --- a/src/jrdev/commands/__init__.py +++ b/src/jrdev/commands/__init__.py @@ -23,6 +23,7 @@ from jrdev.commands.models import handle_models from jrdev.commands.projectcontext import handle_projectcontext from jrdev.commands.provider import handle_provider +from jrdev.commands.handle_research import handle_research from jrdev.commands.routeragent import handle_routeragent from jrdev.commands.stateinfo import handle_stateinfo from jrdev.commands.tasks import handle_tasks @@ -49,6 +50,7 @@ "handle_modelprofile", "handle_projectcontext", "handle_provider", + "handle_research", "handle_routeragent", "handle_stateinfo", "handle_tasks", diff --git a/src/jrdev/commands/handle_research.py b/src/jrdev/commands/handle_research.py new file mode 100644 index 0000000..dd4a518 --- /dev/null +++ b/src/jrdev/commands/handle_research.py @@ -0,0 +1,152 @@ +import asyncio +import httpx +from typing import Any, List, Optional + +from jrdev.agents import agent_tools +from jrdev.agents.research_agent import ResearchAgent +from jrdev.core.tool_call import ToolCall +from jrdev.ui.ui import PrintType + + +async def handle_research(app: Any, args: List[str], worker_id: str, chat_thread_id: Optional[str] = None) -> None: + """ + Initiates a research agent to investigate a topic using web search and scraping tools. + Usage: /research + If chat_thread_id is provided, it runs in the context of that chat. + """ + if len(args) < 2: + if not chat_thread_id: # Only show usage error for command line + app.ui.print_text("Usage: /research ", print_type=PrintType.ERROR) + return + + user_input = " ".join(args[1:]) + + # Determine the research thread and initial output + if chat_thread_id: + research_thread = app.get_thread(chat_thread_id) + else: + app.ui.print_text(f'Starting research for: "{user_input}"\n', print_type=PrintType.INFO) + new_thread_id = app.create_thread(meta_data={"type": "research", "topic": user_input}) + research_thread = app.get_thread(new_thread_id) + + # Initialize the research agent + research_agent = ResearchAgent(app, research_thread) + + calls_made: List[ToolCall] = [] + max_iter = app.user_settings.max_router_iterations + i = 0 + summary = None + + while i < max_iter: + i += 1 + if not chat_thread_id: + app.ui.print_text(f"--- Research Iteration {i}/{max_iter} ---", print_type=PrintType.INFO) + await asyncio.sleep(0.01) + + # Create a sub-task ID for this iteration for tracking purposes + sub_task_id = worker_id + if worker_id: + sub_task_id = f"{worker_id}:{i}" + app.ui.update_task_info( + worker_id, update={"new_sub_task": sub_task_id, "description": "Research: Thinking..."} + ) + + decision = await research_agent.interpret(user_input, sub_task_id, calls_made) + + if worker_id: + app.ui.update_task_info(sub_task_id, update={"sub_task_finished": True}) + + if not decision: + msg = "Research task concluded due to an agent error." + if chat_thread_id: + app.ui.stream_chunk(chat_thread_id, msg) + app.ui.chat_thread_update(chat_thread_id) + else: + app.ui.print_text(msg, print_type=PrintType.ERROR) + break + + decision_type = decision.get("type") + data = decision.get("data") + + if decision_type == "summary": + summary = data + break + + if decision_type == "tool_call": + tool_call: ToolCall = data + command_to_execute = tool_call.formatted_cmd + + # Check for duplicate calls to avoid redundant work and cost + cached_call = next((call for call in calls_made if call.formatted_cmd == command_to_execute), None) + + if cached_call: + tool_call.result = cached_call.result + if not chat_thread_id: + app.ui.print_text(f"Skipping duplicate tool call (using cached result): {command_to_execute}", print_type=PrintType.WARNING) + else: + if chat_thread_id: + feedback_msg = f"Running: `{command_to_execute}`\n> {tool_call.reasoning}" + app.ui.stream_chunk(chat_thread_id, feedback_msg) + app.ui.chat_thread_update(chat_thread_id) + else: # Only print progress to terminal + app.ui.print_text(f"Running tool: {command_to_execute}\nPurpose: {tool_call.reasoning}\n", + print_type=PrintType.PROCESSING) + + try: + if tool_call.command == "web_search": + tool_call.result = agent_tools.web_search(tool_call.args) + elif tool_call.command == "web_scrape_url": + tool_call.result = await agent_tools.web_scrape_url(tool_call.args) + else: + error_msg = f"Error: Research Agent tried to use an unauthorized tool: '{tool_call.command}'" + if not chat_thread_id: + app.ui.print_text(error_msg, PrintType.ERROR) + tool_call.result = error_msg + except httpx.HTTPStatusError as e: + error_message = f"HTTP error during '{tool_call.command}': {e.response.status_code} {e.response.reason_phrase} for URL {e.request.url}" + app.logger.error(f"Tool execution failed: {error_message}", exc_info=True) + tool_call.result = error_message + except httpx.RequestError as e: + error_message = f"Network error during '{tool_call.command}': {str(e)}. This could be a timeout, DNS issue, or invalid URL." + app.logger.error(f"Tool execution failed: {error_message}", exc_info=True) + tool_call.result = error_message + except asyncio.TimeoutError: + error_message = f"Timeout during '{tool_call.command}'. The operation took too long to complete." + app.logger.error(f"Tool execution failed: {error_message}", exc_info=True) + tool_call.result = error_message + except (ValueError, IndexError) as e: + error_message = f"Invalid arguments for tool '{tool_call.command}': {str(e)}" + app.logger.error(f"Tool execution failed: {error_message}", exc_info=True) + tool_call.result = error_message + except Exception as e: + error_message = f"An unexpected error occurred while executing tool '{tool_call.command}': {str(e)}" + app.logger.error(f"Tool execution failed: {error_message}", exc_info=True) + tool_call.result = error_message + + calls_made.append(tool_call) + else: + msg = f"Unknown decision type from research agent: {decision_type}" + if chat_thread_id: + app.ui.stream_chunk(chat_thread_id, msg) + app.ui.chat_thread_update(chat_thread_id) + else: + app.ui.print_text(msg, print_type=PrintType.ERROR) + break + + if summary: + if chat_thread_id: + # The agent adds the summary to the thread history. We stream it to the UI. + app.ui.stream_chunk(chat_thread_id, summary) + app.ui.chat_thread_update(chat_thread_id) + else: + # For command-line, print the summary to the terminal. + app.ui.print_text("\n--- Research Summary ---\n", print_type=PrintType.SUCCESS) + app.ui.print_text(summary, print_type=PrintType.INFO) + + if i >= max_iter and not summary: + msg = "Research agent hit maximum iterations for this request. You can adjust this limit using the /routeragent command." + if chat_thread_id: + app.ui.stream_chunk(chat_thread_id, msg) + app.ui.chat_thread_update(chat_thread_id) + else: + app.ui.print_text(msg, print_type=PrintType.WARNING) diff --git a/src/jrdev/commands/thread.py b/src/jrdev/commands/thread.py index a47c0b2..f02baf4 100644 --- a/src/jrdev/commands/thread.py +++ b/src/jrdev/commands/thread.py @@ -12,6 +12,9 @@ - /thread view [COUNT]: View conversation history in the current thread (default: 10) - /thread delete THREAD_ID: Delete an existing thread +Additional toggle: +- /thread websearch on|off: Enable or disable per-thread web search mode used by chat input + For more details, see the docs/threads.md documentation. """ @@ -130,6 +133,20 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None: ) delete_parser.add_argument("thread_id", type=str, help="Unique ID of the thread to delete") + # Web search toggle (per-thread) + websearch_parser = subparsers.add_parser( + "websearch", + help="Toggle web search for current thread", + description="Enable or disable per-thread web search mode", + epilog=f"Example: {format_command_with_args_plain('/thread websearch', 'on')}", + ) + websearch_parser.add_argument( + "state", + type=str, + choices=["on", "off"], + help="Turn web search on or off for the current thread", + ) + try: if any(arg in ["-h", "--help"] for arg in args[1:]): if len(args) == 2 and args[1] in ["-h", "--help"]: @@ -167,6 +184,8 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None: await _handle_view_conversation(app, parsed_args) elif parsed_args.subcommand == "delete": await _handle_delete_thread(app, parsed_args) + elif parsed_args.subcommand == "websearch": + await _handle_websearch_toggle(app, parsed_args) else: app.ui.print_text("Error: Missing subcommand", PrintType.ERROR) app.ui.print_text("Available Thread Subcommands:", PrintType.HEADER) @@ -228,6 +247,11 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None: format_command_with_args_plain("/thread delete", ""), "Remove an unwanted thread\nExample: /thread delete thread_abc", ), + ( + "Web Search", + format_command_with_args_plain("/thread websearch", "on|off"), + "Enable or disable per-thread web search mode used by chat input\nExample: /thread websearch on", + ), ] for header, cmd, desc in sections: @@ -419,3 +443,27 @@ async def _handle_delete_thread(app: Any, args: argparse.Namespace) -> None: thread_id = f"thread_{thread_id}" app.ui.print_text(f"Deleted thread: {thread_id}", PrintType.SUCCESS) app.ui.chat_thread_update(app.state.active_thread) + + +async def _handle_websearch_toggle(app: Any, args: argparse.Namespace) -> None: + """Enable or disable per-thread web search mode. + + When enabled, chat input will route the user's message through the research agent + rather than the default chat model for the active thread. + """ + thread = app.state.get_current_thread() + if not thread: + app.ui.print_text("No active thread.", PrintType.ERROR) + return + + enable = args.state.lower() == "on" + thread.metadata["web_search_enabled"] = enable + try: + thread.save() # Persist toggle if possible + except Exception: + # Non-fatal if persistence fails; runtime state still updated + pass + + state_str = "enabled" if enable else "disabled" + app.ui.print_text(f"Web search {state_str} for thread {thread.thread_id}", PrintType.SUCCESS) + app.ui.chat_thread_update(thread.thread_id) diff --git a/src/jrdev/core/application.py b/src/jrdev/core/application.py index 0c65b9d..a2bef0d 100644 --- a/src/jrdev/core/application.py +++ b/src/jrdev/core/application.py @@ -8,6 +8,7 @@ from jrdev.agents import agent_tools from jrdev.agents.router_agent import CommandInterpretationAgent +from jrdev.commands.handle_research import handle_research from jrdev.commands.keys import check_existing_keys, save_keys_to_env from jrdev.core.clients import APIClients from jrdev.core.commands import Command, CommandHandler @@ -353,9 +354,9 @@ def switch_thread(self, thread_id): self.logger.info(f"Switching thread to {thread_id}") return self.state.switch_thread(thread_id) - def create_thread(self, thread_id="") -> str: - """Create a new thread""" - return self.state.create_thread(thread_id) + def create_thread(self, thread_id: str = "", meta_data: Dict[str, str] | None = None) -> str: + """Create a new thread, optionally with metadata.""" + return self.state.create_thread(thread_id, meta_data) def stage_code_context(self, file_path) -> None: """Stage files that will be added as context to the next /code command""" @@ -620,6 +621,25 @@ async def process_chat_input(self, user_input, worker_id=None): thread_id = msg_thread.thread_id # 2) tell UI “I’m starting a new chat” (e.g. highlight the thread) self.ui.chat_thread_update(thread_id) + + # Check if web search is enabled for this thread + if msg_thread.metadata.get("web_search_enabled"): + # The UI has already added the user's message to the view. + # We need to add it to the thread history for persistence. + content = f"{USER_INPUT_PREFIX}{user_input}" + msg_thread.messages.append({"role": "user", "content": content}) + + # Use research agent. It will run within the context of the current chat. + # The handle_research function expects args to be a list, with the first + # element being the command name (which we can fake) and the rest being the query. + await handle_research( + self, ["/research", user_input], worker_id, chat_thread_id=thread_id + ) + + # Notify UI that the process is complete and to refresh state + self.ui.chat_thread_update(thread_id) + return + # 3) stream the LLM response content = f"{USER_INPUT_PREFIX}{user_input}" async for chunk in self.message_service.stream_message(msg_thread, content, worker_id): diff --git a/src/jrdev/core/commands.py b/src/jrdev/core/commands.py index 8adf0be..634fe15 100644 --- a/src/jrdev/core/commands.py +++ b/src/jrdev/core/commands.py @@ -20,6 +20,7 @@ handle_modelprofile, handle_projectcontext, handle_provider, + handle_research, handle_routeragent, handle_stateinfo, handle_tasks, @@ -63,6 +64,7 @@ def _register_core_commands(self) -> None: "/git": handle_git, "/keys": handle_keys, "/provider": handle_provider, + "/research": handle_research, "/routeragent": handle_routeragent, "/thread": handle_thread, "/migrate": handle_migrate diff --git a/src/jrdev/messages/thread.py b/src/jrdev/messages/thread.py index 50ac46f..4795b02 100644 --- a/src/jrdev/messages/thread.py +++ b/src/jrdev/messages/thread.py @@ -48,6 +48,14 @@ def __init__(self, thread_id: str): def to_dict(self) -> Dict[str, Any]: """Convert the thread's state to a serializable dictionary.""" + # Serialize metadata including any additional fields beyond timestamps + metadata_serializable: Dict[str, Any] = {} + for key, value in self.metadata.items(): + if isinstance(value, datetime): + metadata_serializable[key] = value.isoformat() + else: + metadata_serializable[key] = value + return { "thread_id": self.thread_id, "name": self.name, @@ -55,10 +63,7 @@ def to_dict(self) -> Dict[str, Any]: "context": list(self.context), "embedded_files": list(self.embedded_files), "token_usage": self.token_usage, - "metadata": { - "created_at": self.metadata["created_at"].isoformat(), - "last_modified": self.metadata["last_modified"].isoformat(), - } + "metadata": metadata_serializable, } @classmethod @@ -71,10 +76,29 @@ def from_dict(cls, data: Dict[str, Any]) -> 'MessageThread': thread.embedded_files = set(data.get("embedded_files", [])) thread.token_usage = data.get("token_usage", {"input": 0, "output": 0}) metadata_dict = data.get("metadata", {}) - thread.metadata = { - "created_at": datetime.fromisoformat(metadata_dict.get("created_at", datetime.now().isoformat())), - "last_modified": datetime.fromisoformat(metadata_dict.get("last_modified", datetime.now().isoformat())), - } + # Preserve all metadata keys; parse timestamps back to datetime + thread.metadata = {} + created_at_raw = metadata_dict.get("created_at", datetime.now().isoformat()) + last_modified_raw = metadata_dict.get("last_modified", datetime.now().isoformat()) + try: + thread.metadata["created_at"] = ( + datetime.fromisoformat(created_at_raw) if isinstance(created_at_raw, str) else created_at_raw + ) + except Exception: + thread.metadata["created_at"] = datetime.now() + + try: + thread.metadata["last_modified"] = ( + datetime.fromisoformat(last_modified_raw) if isinstance(last_modified_raw, str) else last_modified_raw + ) + except Exception: + thread.metadata["last_modified"] = datetime.now() + + # Add any additional metadata fields + for k, v in metadata_dict.items(): + if k in ("created_at", "last_modified"): + continue + thread.metadata[k] = v return thread def save(self) -> None: diff --git a/src/jrdev/prompts/researcher/research_prompt.md b/src/jrdev/prompts/researcher/research_prompt.md new file mode 100644 index 0000000..7753f5e --- /dev/null +++ b/src/jrdev/prompts/researcher/research_prompt.md @@ -0,0 +1,105 @@ +You are a specialized research agent. Your purpose is to conduct web research to answer a user's query. You operate in a loop, making one decision at a time. + +## Core Task +Given a user's request, you will perform a cycle of web searches and content scraping until you have gathered enough information to provide a comprehensive answer. + +## Decision Process +1. **Analyze Request**: Understand the user's query to formulate an initial search strategy. +2. **Search**: `execute_action` with the `web_search` tool. +3. **Analyze Search Results**: Review the summaries from the search results to identify the most promising URLs. +4. **Scrape**: `execute_action` with the `web_scrape_url` tool for each promising URL. +5. **Synthesize & Evaluate**: After each scrape, review the gathered information. Is it sufficient to answer the user's query? + * If NO, refine your search query or identify new URLs from the scraped content and go back to step 2 (Search) or 4 (Scrape). + * If YES, proceed to step 6. +6. **Final Answer**: `summary` with a comprehensive, well-structured answer synthesized from all the information you have gathered. + +## Available Tools +- `web_search`: Searches the web for a query. +- `web_scrape_url`: Scrapes content from a given URL. + +## Critical Rules +1. **Always start with `web_search`**. Do not assume you know a URL. +2. **Analyze search results before scraping**. Do not scrape irrelevant or low-quality links. +3. **Iterate until complete**. Continue the search-scrape-synthesize cycle until you are confident you can fully answer the user's query. +4. **Synthesize, don't just dump**. Your final answer should be a coherent synthesis of the information, not just a concatenation of scraped content. +5. **Cite your sources**. When possible, mention the URLs where you found key pieces of information in your final summary. +6. **Manage complexity**. If a query is too broad, break it down into sub-questions and research them sequentially. +7. **Signal completion clearly**. The `summary` decision indicates that you believe the research is complete and you have a final answer. + +## Response Schema +Your response must be a JSON object wrapped in ```json``` markers. + +```json +{ + "decision": "execute_action" | "summary", + "reasoning": "string", // Explain your thought process and why you are taking this action. + + // For execute_action only: + "action"?: { + "type": "tool", + "name": "web_search" | "web_scrape_url", + "args": ["string"] + }, + + // For summary only: + "response"?: "string" // The final, synthesized answer to the user's query. +} +``` + +## Example Workflow + +User Query: "What are the pros and cons of using FastAPI vs. Flask for a new web service?" + +### Round 1: Initial Search +```json +{ + "decision": "execute_action", + "reasoning": "I will start by performing a broad search to find comparison articles and official documentation for both FastAPI and Flask.", + "action": { + "type": "tool", + "name": "web_search", + "args": ["FastAPI vs Flask pros and cons"] + } +} +``` + +**(Assistant receives search results with URLs and summaries)** + +### Round 2: Scrape a promising article +```json +{ + "decision": "execute_action", + "reasoning": "The search results show a promising article from realpython.com that seems to directly compare the two frameworks. I will scrape it to get detailed information.", + "action": { + "type": "tool", + "name": "web_scrape_url", + "args": ["https://realpython.com/fastapi-vs-flask/"] + } +} +``` + +**(Assistant receives scraped content from the article)** + +### Round 3: Scrape another source for more depth +```json +{ + "decision": "execute_action", + "reasoning": "The first article was very helpful. Now I will scrape a second source from testdriven.io to get another perspective and ensure my answer is well-rounded.", + "action": { + "type": "tool", + "name": "web_scrape_url", + "args": ["https://testdriven.io/blog/fastapi-vs-flask/"] + } +} +``` + +**(Assistant receives more scraped content)** + +### Round 4: Final Summary +```json +{ + "decision": "summary", + "reasoning": "I have gathered information from multiple sources comparing FastAPI and Flask. I have enough detail on performance, async support, data validation, and developer experience to provide a comprehensive answer.", + "response": "Here is a summary of the pros and cons of FastAPI versus Flask for a new web service:\n\n**FastAPI**\n\n*Pros:*\n- High performance, on par with NodeJS and Go, thanks to Starlette and Pydantic.\n- Built-in support for asynchronous operations (`async`/`await`).\n- Automatic data validation and serialization using Python type hints.\n- Automatically generated, interactive API documentation (Swagger UI and ReDoc).\n\n*Cons:*\n- Younger framework with a smaller community and ecosystem compared to Flask.\n- Can have a slightly steeper learning curve if you are unfamiliar with async programming or Python type hints.\n\n**Flask**\n\n*Pros:*\n- Mature, stable, and has a very large community and a vast number of extensions.\n- Simple, flexible, and unopinionated, making it easy to get started with.\n- Excellent documentation and a wealth of tutorials available.\n\n*Cons:*\n- Slower performance out-of-the-box compared to FastAPI.\n- No built-in async support (requires extensions like Gevent or Eventlet).\n- Data validation and API documentation require separate libraries (e.g., Marshmallow, Flask-RESTX).\n\n**Conclusion:**\nChoose **FastAPI** if your priority is performance, you need native async support, and you value automatic data validation and API docs. It is ideal for building modern, high-throughput APIs.\n\nChoose **Flask** if you prefer a more flexible, minimalist framework, are building a traditional web application or a simpler API, or if you value its mature ecosystem and large community support.\n\n*Sources: realpython.com, testdriven.io*" +} +``` \ No newline at end of file diff --git a/src/jrdev/services/streaming/openai_stream.py b/src/jrdev/services/streaming/openai_stream.py index 93e74c6..259fcf3 100644 --- a/src/jrdev/services/streaming/openai_stream.py +++ b/src/jrdev/services/streaming/openai_stream.py @@ -70,6 +70,7 @@ async def stream_openai_format(app, model, messages, task_id=None, print_stream= stream_start_time = None # Track when we start receiving chunks # notify ui of tokens + input_token_estimate = 0 if task_id: try: input_chunk_content = "" @@ -80,8 +81,8 @@ async def stream_openai_format(app, model, messages, task_id=None, print_stream= for item in msg["content"]: if isinstance(item, dict) and item.get("type") == "text": input_chunk_content += item.get("text", "") - input_token_estimate = token_encoder.encode(input_chunk_content) - app.ui.update_task_info(task_id, update={"input_token_estimate": len(input_token_estimate), "model": model}) + input_token_estimate = len(token_encoder.encode(input_chunk_content)) + app.ui.update_task_info(task_id, update={"input_token_estimate": input_token_estimate, "model": model}) except Exception as e: app.logger.error(f"Error estimating input tokens: {e}") @@ -126,3 +127,4 @@ async def stream_openai_format(app, model, messages, task_id=None, print_stream= elapsed_seconds = round(end_time - start_time, 2) stream_elapsed = end_time - stream_start_time app.logger.info(f"Response completed (no usage data in final chunk): {model}, {elapsed_seconds}s, {chunk_count} chunks, {round(chunk_count/stream_elapsed,2) if stream_elapsed > 0 else 0} chunks/sec") + await get_instance().add_use(model, input_token_estimate, output_tokens_estimate) \ No newline at end of file diff --git a/src/jrdev/ui/tui/chat/chat_view_widget.py b/src/jrdev/ui/tui/chat/chat_view_widget.py index bf0f9ac..4d6d9a6 100644 --- a/src/jrdev/ui/tui/chat/chat_view_widget.py +++ b/src/jrdev/ui/tui/chat/chat_view_widget.py @@ -1,4 +1,5 @@ import os + from textual import on from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll @@ -36,25 +37,32 @@ class ChatViewWidget(Widget): min-height: 0; } #chat_controls_container { - height: auto; + height: 2; width: 100%; layout: horizontal; - border-top: #5e5e5e; + border: #5e5e5e; border-bottom: none; border-left: none; border-right: none; } + #toggle_container { + height: 3; + width: 100%; + layout: horizontal; + border: #5e5e5e round; + border-title-background: #1e1e1e; + } #chat_context_display_container { height: auto; /* Allow wrapping for multiple files */ width: 100%; layout: horizontal; - padding: 0 1; /* Horizontal padding */ + padding: 0 1; } #chat_context_title_label { height: 1; width: auto; margin-right: 1; - color: #63f554; /* Match other labels */ + color: #63f554; } #chat_context_files_label { height: 1; @@ -74,6 +82,12 @@ class ChatViewWidget(Widget): max-width: 10; margin-left: 1; } + #settings_button { + height: 1; + width: auto; + max-width: 3; + margin-left: 1; + } #context_switch { height: 1; width: auto; @@ -86,6 +100,18 @@ class ChatViewWidget(Widget): #context_label:disabled { color: #365b2d; } + #web_search_switch { + height: 1; + width: auto; + margin-left: 1; + border: none; + } + #web_search_label { + color: #63f554; + } + #web_search_label:disabled { + color: #365b2d; + } #chat-model-list { border: round #63f554; layer: top; @@ -111,6 +137,7 @@ def __init__(self, core_app, id: Optional[str] = None) -> None: #controls and input self.layout_output = Vertical(id="chat_output_layout") self.layout_chat_controls = Horizontal(id="chat_controls_container") + self.layout_context_display = Horizontal(id="chat_context_display_container") self.terminal_button = Button(label="⇤", id="terminal_button") self.model_button = Button(label=core_app.state.model, id="model-button", variant="primary", classes="chat-model-btn", tooltip="Model used for chat responses") self.models_text_width = 1 @@ -118,8 +145,13 @@ def __init__(self, core_app, id: Optional[str] = None) -> None: self.model_listview.visible = False self.change_name_button = Button(label="Rename", id="change_name_button") self.delete_button = Button(label="Delete", id="delete_button") + self.settings_button = Button("⚙", id="settings_button", tooltip="Chat Settings") + self.toggle_container: Optional[Horizontal] = None + self.settings_expanded = False self.context_switch = Switch(value=False, id="context_switch", tooltip="When enabled, summarized information about the project is added as context to the chat, this includes select file summaries, file tree, and a project overview") self.context_label = Label("Project Ctx", id="context_label") + self.web_search_switch = Switch(value=False, id="web_search_switch", tooltip="When enabled, the LLM can perform a web search to answer questions.") + self.web_search_label = Label("Web Search", id="web_search_label") self.input_widget = ChatInputWidget(id="chat_input") self.input_name = None self.label_delete_prompt = None @@ -143,14 +175,24 @@ def compose(self) -> ComposeResult: yield self.model_button yield self.change_name_button yield self.delete_button - yield self.context_switch - yield self.context_label + yield self.settings_button yield self.model_listview - with Horizontal(id="chat_context_display_container"): + with self.layout_context_display: yield self.chat_context_title_label yield self.chat_context_files_label yield self.input_widget + async def _mount_settings(self) -> None: + """Mount the settings toggle container with its children.""" + self.toggle_container = Horizontal(id="toggle_container") + self.toggle_container.styles.border_title_color = "#fabd2f" + self.toggle_container.border_title = "Chat Settings" + await self.layout_output.mount(self.toggle_container, after=self.layout_context_display) + await self.toggle_container.mount(self.context_switch) + await self.toggle_container.mount(self.context_label) + await self.toggle_container.mount(self.web_search_switch) + await self.toggle_container.mount(self.web_search_label) + async def on_mount(self) -> None: """Set up the widget when mounted.""" self.layout_output.styles.border = ("round", Color.parse("#5e5e5e")) @@ -159,6 +201,7 @@ async def on_mount(self) -> None: self.terminal_button.can_focus = False self.context_switch.can_focus = False + self.web_search_switch.can_focus = False self.input_widget.styles.height = 8 @@ -300,6 +343,16 @@ def handle_switch_change(self, event: Switch.Changed) -> None: else: self.send_commands = True # Reset for next user interaction + @on(Switch.Changed, "#web_search_switch") + def handle_web_search_switch_change(self, event: Switch.Changed) -> None: + """Handles user interaction with the web search switch.""" + self.web_search_label.disabled = not event.value + if self.send_commands: + # This command modifies the metadata of the current thread. + self.post_message(CommandRequest(f"/thread websearch {'on' if event.value else 'off'}")) + else: + self.send_commands = True # Reset for next user interaction + @on(Button.Pressed, "#model-button") def handle_model_pressed(self) -> None: self.model_listview.set_visible(not self.model_listview.visible) @@ -311,6 +364,15 @@ def handle_model_selection(self, selected: ModelListView.ModelSelected): self.model_listview.set_visible(False) self.model_button.styles.max_width = len(model_name) + 2 + @on(Button.Pressed, "#settings_button") + async def handle_settings_toggle(self) -> None: + self.settings_expanded = not self.settings_expanded + if self.settings_expanded: + await self._mount_settings() + else: + if self.toggle_container and self.toggle_container.is_mounted: + await self.toggle_container.remove() + @on(Button.Pressed, "#terminal_button") async def handle_show_terminal(self): if self.name_edit_mode: @@ -341,13 +403,14 @@ async def set_delete_prompt_mode(self, is_delete_mode): self.terminal_button.styles.max_width = 5 self.change_name_button.label = "Cancel" self.delete_button.visible = False - self.context_label.visible = False - self.context_switch.visible = False self.model_button.visible = False self.model_button.styles.max_width = 0 + self.settings_button.visible = False + if self.toggle_container and self.toggle_container.is_mounted: + await self.toggle_container.remove() # detemine thread name - self.label_delete_prompt = Label(f"Delete chat thread \"{self.current_thread_id}?\"") + self.label_delete_prompt = Label(f'Delete chat thread "{self.current_thread_id}?"') await self.layout_chat_controls.mount(self.label_delete_prompt, before=0) else: # return widgets to their normal state @@ -355,10 +418,11 @@ async def set_delete_prompt_mode(self, is_delete_mode): self.terminal_button.width = 5 self.change_name_button.label = "Rename" self.delete_button.visible = True - self.context_switch.visible = True - self.context_label.visible = True self.model_button.visible = True self.model_button.styles.max_width = 15 + self.settings_button.visible = True + if self.settings_expanded: + await self._mount_settings() await self.label_delete_prompt.remove() self.label_delete_prompt = None @@ -402,22 +466,22 @@ async def set_name_edit_mode(self, is_edit_mode: bool) -> None: # hide other elements self.delete_button.visible = False - self.context_switch.visible = False - self.context_label.visible = False - - # have to set width to 0 to get alignment right self.model_button.visible = False self.model_button.styles.max_width = 0 + self.settings_button.visible = False + if self.toggle_container and self.toggle_container.is_mounted: + await self.toggle_container.remove() else: # return widgets to their normal state self.terminal_button.label = "⇤" self.terminal_button.max_width = 5 self.change_name_button.label = "Rename" self.delete_button.visible = True - self.context_switch.visible = True - self.context_label.visible = True self.model_button.visible = True self.model_button.styles.max_width = 15 + self.settings_button.visible = True + if self.settings_expanded: + await self._mount_settings() await self.input_name.remove() self.input_name = None @@ -427,6 +491,14 @@ async def set_name_edit_mode(self, is_edit_mode: bool) -> None: async def on_thread_switched(self) -> None: """Called when the core application signals a thread switch.""" await self._load_current_thread() + thread = self.core_app.get_current_thread() + if thread: + is_enabled = thread.metadata.get("web_search_enabled", False) + self.web_search_switch.value = is_enabled + self.web_search_label.disabled = not is_enabled + else: + self.web_search_switch.value = False + self.web_search_label.disabled = True def update_models(self) -> None: self.model_button.label = self.core_app.state.model @@ -434,4 +506,4 @@ def update_models(self) -> None: def handle_external_update(self, is_enabled: bool) -> None: """Handles external updates to the project context state (e.g., from core app).""" if self.context_switch.value != is_enabled: - self.set_project_context_on(is_enabled) + self.set_project_context_on(is_enabled) \ No newline at end of file diff --git a/src/jrdev/ui/tui/git/git_overview_widget.py b/src/jrdev/ui/tui/git/git_overview_widget.py index b1c14a0..7e99631 100644 --- a/src/jrdev/ui/tui/git/git_overview_widget.py +++ b/src/jrdev/ui/tui/git/git_overview_widget.py @@ -601,6 +601,7 @@ def handle_perform_commit(self): if success: self.notify("Commit successful.", severity="information") + self.commit_message_textarea.text = "" self._toggle_commit_view(False) self.refresh_git_status() self.reset_file_label()