This commit is contained in:
marko-kraemer 2025-04-17 23:17:48 +01:00
parent 90a953bd49
commit 2f40a98161
16 changed files with 1217 additions and 540 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

180
.gitignore vendored
View File

@ -1,180 +0,0 @@
.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
test/
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# AgentPress
/threads
state.json
/workspace/
/workspace/*
/workspace/**
*.venvy/*
*.venvy*
# SQLite
*.db
# .DS_Store files
.DS_Store
**/.DS_Store

View File

@ -16,6 +16,7 @@ from utils.auth_utils import get_current_user_id, get_user_id_from_stream_auth,
from utils.logger import logger
from utils.billing import check_billing_status, get_account_id_from_thread
from utils.db import update_agent_run_status
from sandbox.sandbox import create_sandbox, get_or_start_sandbox
# Initialize shared resources
router = APIRouter()
@ -268,6 +269,26 @@ async def start_agent(thread_id: str, user_id: str = Depends(get_current_user_id
if active_run_id:
logger.info(f"Stopping existing agent run {active_run_id} before starting new one")
await stop_agent_run(active_run_id)
# Initialize or get sandbox for this project
project = await client.table('projects').select('*').eq('project_id', project_id).execute()
if project.data[0].get('sandbox', {}).get('id'):
sandbox_id = project.data[0]['sandbox']['id']
sandbox_pass = project.data[0]['sandbox']['pass']
sandbox = await get_or_start_sandbox(sandbox_id)
else:
sandbox_pass = str(uuid.uuid4())
sandbox = create_sandbox(sandbox_pass)
logger.info(f"Created new sandbox with preview: {sandbox.get_preview_link(6080)}/vnc_lite.html?password={sandbox_pass}")
sandbox_id = sandbox.id
await client.table('projects').update({
'sandbox': {
'id': sandbox_id,
'pass': sandbox_pass,
'vnc_preview': sandbox.get_preview_link(6080),
'sandbox_url': sandbox.get_preview_link(8080)
}
}).eq('project_id', project_id).execute()
agent_run = await client.table('agent_runs').insert({
"thread_id": thread_id,
@ -293,7 +314,7 @@ async def start_agent(thread_id: str, user_id: str = Depends(get_current_user_id
# Run the agent in the background
task = asyncio.create_task(
run_agent_background(agent_run_id, thread_id, instance_id, project_id)
run_agent_background(agent_run_id, thread_id, instance_id, project_id, sandbox)
)
# Set a callback to clean up when task is done
@ -420,7 +441,7 @@ async def stream_agent_run(
}
)
async def run_agent_background(agent_run_id: str, thread_id: str, instance_id: str, project_id: str):
async def run_agent_background(agent_run_id: str, thread_id: str, instance_id: str, project_id: str, sandbox):
"""Run the agent in the background and handle status updates."""
logger.debug(f"Starting background agent run: {agent_run_id} for thread: {thread_id} (instance: {instance_id})")
client = await db.client
@ -541,7 +562,8 @@ async def run_agent_background(agent_run_id: str, thread_id: str, instance_id: s
# Run the agent
logger.debug(f"Initializing agent generator for thread: {thread_id} (instance: {instance_id})")
agent_gen = run_agent(thread_id, stream=True,
thread_manager=thread_manager, project_id=project_id)
thread_manager=thread_manager, project_id=project_id,
sandbox=sandbox)
# Collect all responses to save to database
all_responses = []
@ -569,7 +591,7 @@ async def run_agent_background(agent_run_id: str, thread_id: str, instance_id: s
# Signal all done if we weren't stopped
if not stop_signal_received:
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
logger.info(f"Agent run completed successfully: {agent_run_id} (duration: {duration:.2f}s, total responses: {total_responses}, instance: {instance_id})")
logger.info(f"Thread Run Response completed successfully: {agent_run_id} (duration: {duration:.2f}s, total responses: {total_responses}, instance: {instance_id})")
# Add completion message to the stream
completion_message = {

View File

@ -4,6 +4,7 @@ from uuid import uuid4
from typing import Optional
# from agent.tools.message_tool import MessageTool
from agent.tools.message_tool import MessageTool
from agent.tools.sb_deploy_tool import SandboxDeployTool
from agent.tools.web_search_tool import WebSearchTool
from dotenv import load_dotenv
@ -20,7 +21,7 @@ from utils.billing import check_billing_status, get_account_id_from_thread
load_dotenv()
async def run_agent(thread_id: str, project_id: str, stream: bool = True, thread_manager: Optional[ThreadManager] = None, native_max_auto_continues: int = 25, max_iterations: int = 150):
async def run_agent(thread_id: str, project_id: str, sandbox, stream: bool = True, thread_manager: Optional[ThreadManager] = None, native_max_auto_continues: int = 25, max_iterations: int = 150):
"""Run the development agent with specified configuration."""
if not thread_manager:
@ -32,43 +33,13 @@ async def run_agent(thread_id: str, project_id: str, stream: bool = True, thread
if not account_id:
raise ValueError("Could not determine account ID for thread")
# Initial billing check
can_run, message, subscription = await check_billing_status(client, account_id)
if not can_run:
error_msg = f"Billing limit reached: {message}"
# Yield a special message to indicate billing limit reached
yield {
"type": "status",
"status": "stopped",
"message": error_msg
}
raise Exception(message)
## probably want to move to api.py
project = await client.table('projects').select('*').eq('project_id', project_id).execute()
if project.data[0].get('sandbox', {}).get('id'):
sandbox_id = project.data[0]['sandbox']['id']
sandbox_pass = project.data[0]['sandbox']['pass']
sandbox = await get_or_start_sandbox(sandbox_id)
else:
sandbox_pass = str(uuid4())
sandbox = create_sandbox(sandbox_pass)
print(f"\033[91m{sandbox.get_preview_link(6080)}/vnc_lite.html?password={sandbox_pass}\033[0m")
sandbox_id = sandbox.id
await client.table('projects').update({
'sandbox': {
'id': sandbox_id,
'pass': sandbox_pass,
'vnc_preview': sandbox.get_preview_link(6080),
'sandbox_url': sandbox.get_preview_link(8080)
}
}).eq('project_id', project_id).execute()
# Note: Billing checks are now done in api.py before this function is called
thread_manager.add_tool(SandboxShellTool, sandbox=sandbox)
thread_manager.add_tool(SandboxFilesTool, sandbox=sandbox)
thread_manager.add_tool(SandboxBrowserTool, sandbox=sandbox, thread_id=thread_id, thread_manager=thread_manager)
thread_manager.add_tool(SandboxDeployTool, sandbox=sandbox)
# thread_manager.add_tool(MessageTool) -> we are just doing this via prompt as there is no need to call it as a tool
thread_manager.add_tool(MessageTool) # we are just doing this via prompt as there is no need to call it as a tool
if os.getenv("EXA_API_KEY"):
thread_manager.add_tool(WebSearchTool)
@ -89,7 +60,7 @@ async def run_agent(thread_id: str, project_id: str, stream: bool = True, thread
iteration_count += 1
print(f"Running iteration {iteration_count}...")
# Billing check on each iteration
# Billing check on each iteration - still needed within the iterations
can_run, message, subscription = await check_billing_status(client, account_id)
if not can_run:
error_msg = f"Billing limit reached: {message}"
@ -109,8 +80,8 @@ async def run_agent(thread_id: str, project_id: str, stream: bool = True, thread
print(f"Last message was from assistant, stopping execution")
continue_execution = False
break
# Get the latest message from messages table that its tpye is browser_state
latest_browser_state = await client.table('messages').select('*').eq('thread_id', thread_id).eq('type', 'browser_state').order('created_at', desc=True).limit(1).execute()
temporary_message = None
if latest_browser_state.data and len(latest_browser_state.data) > 0:
@ -289,7 +260,12 @@ async def process_agent_response(thread_id: str, project_id: str, thread_manager
current_response = ""
tool_call_counter = 0 # Track number of tool calls
async for chunk in run_agent(thread_id=thread_id, project_id=project_id, stream=True, thread_manager=thread_manager, native_max_auto_continues=25):
# Create a test sandbox for processing
sandbox_pass = str(uuid4())
sandbox = create_sandbox(sandbox_pass)
print(f"\033[91mTest sandbox created: {sandbox.get_preview_link(6080)}/vnc_lite.html?password={sandbox_pass}\033[0m")
async for chunk in run_agent(thread_id=thread_id, project_id=project_id, sandbox=sandbox, stream=True, thread_manager=thread_manager, native_max_auto_continues=25):
chunk_counter += 1
if chunk.get('type') == 'content' and 'content' in chunk:

View File

@ -143,7 +143,7 @@ class ContextManager:
self,
thread_id: str,
messages: List[Dict[str, Any]],
model: str = "gpt-3.5-turbo"
model: str = "gpt-4o-mini"
) -> Optional[Dict[str, Any]]:
"""Generate a summary of conversation messages.
@ -237,7 +237,7 @@ The above is a summary of the conversation history. The conversation continues b
self,
thread_id: str,
add_message_callback,
model: str = "gpt-3.5-turbo",
model: str = "gpt-4o-mini",
force: bool = False
) -> bool:
"""Check if thread needs summarization and summarize if so.

View File

@ -132,6 +132,10 @@ class ResponseProcessor:
# Track finish reason
finish_reason = None
# Store message IDs associated with yielded content/tools
last_assistant_message_id = None
tool_result_message_ids = {} # tool_index -> message_id
# logger.debug(f"Starting to process streaming response for thread {thread_id}")
logger.info(f"Config: XML={config.xml_tool_calling}, Native={config.native_tool_calling}, "
f"Execute on stream={config.execute_on_stream}, Execution strategy={config.tool_execution_strategy}")
@ -144,6 +148,15 @@ class ResponseProcessor:
accumulated_token_count = 0
try:
# Generate a unique ID for this response run
thread_run_id = str(uuid.uuid4())
# Yield the overall run start signal
yield {"type": "thread_run_start", "thread_run_id": thread_run_id}
# Yield the assistant response start signal
yield {"type": "assistant_response_start", "thread_run_id": thread_run_id}
async for chunk in llm_response:
# Default content to yield
@ -177,7 +190,7 @@ class ResponseProcessor:
logger.info("XML tool call limit reached - not yielding more content")
else:
# Always yield the content chunk if we haven't reached the limit
yield {"type": "content", "content": chunk_content}
yield {"type": "content", "content": chunk_content, "thread_run_id": thread_run_id}
# Parse XML tool calls if enabled
if config.xml_tool_calling:
@ -208,12 +221,12 @@ class ResponseProcessor:
# Execute tool if needed, but in background
if config.execute_tools and config.execute_on_stream:
# Yield tool execution start message
yield self._yield_tool_started(context)
yield self._yield_tool_started(context, thread_run_id)
# Start tool execution as a background task
execution_task = asyncio.create_task(self._execute_tool(tool_call))
# Store the task for later retrieval
# Store the task for later retrieval (to get result after stream)
pending_tool_executions.append({
"task": execution_task,
"tool_call": tool_call,
@ -260,7 +273,8 @@ class ResponseProcessor:
# Yield the chunk data
yield {
"type": "content",
"tool_call": tool_call_data
"tool_call": tool_call_data,
"thread_run_id": thread_run_id
}
# Log the tool call chunk for debugging
@ -316,12 +330,12 @@ class ResponseProcessor:
)
# Yield tool execution start message
yield self._yield_tool_started(context)
yield self._yield_tool_started(context, thread_run_id)
# Start tool execution as a background task
execution_task = asyncio.create_task(self._execute_tool(tool_call_data))
# Store the task for later retrieval
# Store the task for later retrieval (to get result after stream)
pending_tool_executions.append({
"task": execution_task,
"tool_call": tool_call_data,
@ -353,7 +367,7 @@ class ResponseProcessor:
tool_call = execution["tool_call"]
tool_index = execution.get("tool_index", -1)
# Store result for later
# Store result for later processing AFTER assistant message is saved
tool_results_buffer.append((tool_call, result, tool_index))
# Get or create the context
@ -369,13 +383,13 @@ class ResponseProcessor:
logger.info(f"Skipping duplicate yield for tool index {tool_index}")
continue
# Yield tool status message first
yield self._yield_tool_completed(context)
# Yield tool status message first (without DB message ID yet)
yield self._yield_tool_completed(context, tool_message_id=None, thread_run_id=thread_run_id)
# Yield tool execution result
yield self._yield_tool_result(context)
# Track that we've yielded this tool result
# DO NOT yield the tool_result chunk here yet.
# It will be yielded after the assistant message is saved.
# Track that we've yielded this tool result (status, not the result itself)
yielded_tool_indices.add(tool_index)
except Exception as e:
logger.error(f"Error processing remaining tool execution: {str(e)}")
@ -398,7 +412,7 @@ class ResponseProcessor:
context.error = e
# Yield error status for the tool
yield self._yield_tool_error(context)
yield self._yield_tool_error(context, thread_run_id)
# Track that we've yielded this tool error
yielded_tool_indices.add(tool_index)
@ -407,7 +421,8 @@ class ResponseProcessor:
if finish_reason == "xml_tool_limit_reached":
yield {
"type": "finish",
"finish_reason": "xml_tool_limit_reached"
"finish_reason": "xml_tool_limit_reached",
"thread_run_id": thread_run_id
}
logger.info(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls")
@ -452,106 +467,129 @@ class ResponseProcessor:
"content": accumulated_content,
"tool_calls": complete_native_tool_calls if config.native_tool_calling and complete_native_tool_calls else None
}
await self.add_message(
last_assistant_message_id = await self.add_message(
thread_id=thread_id,
type="assistant",
content=message_data,
is_llm_message=True
)
# Now add all buffered tool results AFTER the assistant message, but don't yield if already yielded
for tool_call, result, result_tool_index in tool_results_buffer:
# Add result based on tool type to the conversation history
await self._add_tool_result(
thread_id,
tool_call,
result,
config.xml_adding_strategy
)
# We don't need to yield again for tools that were already yielded during streaming
if result_tool_index in yielded_tool_indices:
logger.info(f"Skipping duplicate yield for tool index {result_tool_index}")
continue
# Create context for tool result
context = self._create_tool_context(tool_call, result_tool_index)
context.result = result
# Yield tool execution result
yield self._yield_tool_result(context)
# Increment tool index for next tool
tool_index += 1
# Yield the assistant response end signal *immediately* after saving
if last_assistant_message_id:
yield {
"type": "assistant_response_end",
"assistant_message_id": last_assistant_message_id,
"thread_run_id": thread_run_id
}
else:
# Handle case where saving failed (though it should raise an exception)
yield {
"type": "assistant_response_end",
"assistant_message_id": None,
"thread_run_id": thread_run_id
}
# Execute any remaining tool calls if not done during streaming
# Only process if we haven't reached the XML limit
if config.execute_tools and not config.execute_on_stream and (config.max_xml_tool_calls == 0 or xml_tool_call_count < config.max_xml_tool_calls):
tool_calls_to_execute = []
# --- Process All Tool Calls Now ---
if config.execute_tools:
final_tool_calls_to_process = []
# Process native tool calls
# Gather native tool calls from buffer
if config.native_tool_calling and complete_native_tool_calls:
for tool_call in complete_native_tool_calls:
tool_calls_to_execute.append({
"function_name": tool_call["function"]["name"],
"arguments": tool_call["function"]["arguments"],
"id": tool_call["id"]
for tc in complete_native_tool_calls:
final_tool_calls_to_process.append({
"function_name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
"id": tc["id"]
})
# Process XML tool calls - only if we haven't hit the limit
if config.xml_tool_calling and (config.max_xml_tool_calls == 0 or xml_tool_call_count < config.max_xml_tool_calls):
# Extract any remaining complete XML chunks
# Gather XML tool calls from buffer (up to limit)
if config.xml_tool_calling:
xml_chunks = self._extract_xml_chunks(current_xml_content)
xml_chunks_buffer.extend(xml_chunks)
# Only process up to the limit
remaining_xml_calls = config.max_xml_tool_calls - xml_tool_call_count if config.max_xml_tool_calls > 0 else len(xml_chunks_buffer)
xml_chunks_to_process = xml_chunks_buffer[:remaining_xml_calls] if remaining_xml_calls > 0 else []
for xml_chunk in xml_chunks_to_process:
tool_call = self._parse_xml_tool_call(xml_chunk)
if tool_call:
tool_calls_to_execute.append(tool_call)
xml_tool_call_count += 1
remaining_limit = config.max_xml_tool_calls - xml_tool_call_count if config.max_xml_tool_calls > 0 else len(xml_chunks_buffer)
xml_chunks_to_process = xml_chunks_buffer[:remaining_limit]
for chunk in xml_chunks_to_process:
tc = self._parse_xml_tool_call(chunk)
if tc: final_tool_calls_to_process.append(tc)
# Execute all collected tool calls
if tool_calls_to_execute:
tool_results = await self._execute_tools(
tool_calls_to_execute,
config.tool_execution_strategy
)
# Get results (either from pending tasks or by executing now)
tool_results_map = {} # tool_index -> (tool_call, result)
if config.execute_on_stream and pending_tool_executions:
logger.info(f"Waiting for {len(pending_tool_executions)} pending streamed tool executions")
tasks = {exec["tool_index"]: exec["task"] for exec in pending_tool_executions}
tool_calls_by_index = {exec["tool_index"]: exec["tool_call"] for exec in pending_tool_executions}
done, _ = await asyncio.wait(tasks.values())
for idx, task in tasks.items():
try:
result = task.result()
tool_results_map[idx] = (tool_calls_by_index[idx], result)
except Exception as e:
logger.error(f"Error getting result for streamed tool index {idx}: {e}")
tool_results_map[idx] = (tool_calls_by_index[idx], ToolResult(success=False, output=f"Error: {e}"))
elif final_tool_calls_to_process: # Execute tools now if not streamed
logger.info(f"Executing {len(final_tool_calls_to_process)} tools sequentially/parallelly")
results_list = await self._execute_tools(final_tool_calls_to_process, config.tool_execution_strategy)
# Map results back to original tool index if possible (difficult without original index)
# For simplicity, we'll process them in the order returned
current_tool_idx = 0 # Reset index for non-streamed execution results
for tc, res in results_list:
tool_results_map[current_tool_idx] = (tc, res)
current_tool_idx += 1
# Now, process and yield each result sequentially
logger.info(f"Processing and yielding {len(tool_results_map)} tool results")
processed_tool_indices = set()
# We need a deterministic order, sort by index
for tool_idx in sorted(tool_results_map.keys()):
tool_call, result = tool_results_map[tool_idx]
context = self._create_tool_context(tool_call, tool_idx)
context.result = result
for tool_call, result in tool_results:
# Add result based on tool type
await self._add_tool_result(
thread_id,
tool_call,
result,
config.xml_adding_strategy
)
# Create context for tool result
context = self._create_tool_context(tool_call, tool_index)
context.result = result
# Yield tool execution result
yield self._yield_tool_result(context)
# Increment tool index for next tool
tool_index += 1
# Yield start status (even if streamed, yield again here for strict order)
yield self._yield_tool_started(context, thread_run_id)
# Save result to DB and get ID
tool_msg_id = await self._add_tool_result(thread_id, tool_call, result, config.xml_adding_strategy)
if tool_msg_id:
tool_result_message_ids[tool_idx] = tool_msg_id # Store for reference
else:
logger.error(f"Failed to get message ID for tool index {tool_idx}")
# Yield completed status with ID
yield self._yield_tool_completed(context, tool_message_id=tool_msg_id, thread_run_id=thread_run_id)
# Yield result with ID
yield self._yield_tool_result(context, tool_message_id=tool_msg_id, thread_run_id=thread_run_id)
processed_tool_indices.add(tool_idx)
# Finally, if we detected a finish reason, yield it
if finish_reason and finish_reason != "xml_tool_limit_reached": # Already yielded if limit reached
yield {
"type": "finish",
"finish_reason": finish_reason
"finish_reason": finish_reason,
"thread_run_id": thread_run_id
}
except Exception as e:
logger.error(f"Error processing stream: {str(e)}", exc_info=True)
yield {"type": "error", "message": str(e)}
yield {"type": "error", "message": str(e), "thread_run_id": thread_run_id if 'thread_run_id' in locals() else None}
finally:
# Yield a finish signal including the final assistant message ID
if last_assistant_message_id:
# Yield the overall run end signal
yield {
"type": "thread_run_end",
"thread_run_id": thread_run_id
}
else:
# Yield the overall run end signal
yield {
"type": "thread_run_end",
"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None
}
pass
# track the cost and token count
# todo: there is a bug as it adds every chunk to db because finally will run every time even in yield
@ -570,8 +608,8 @@ class ResponseProcessor:
self,
llm_response: Any,
thread_id: str,
config: ProcessorConfig = ProcessorConfig(),
) -> AsyncGenerator:
config: ProcessorConfig = ProcessorConfig()
) -> AsyncGenerator[Dict[str, Any], None]:
"""Process a non-streaming LLM response, handling tool calls and execution.
Args:
@ -585,13 +623,20 @@ class ResponseProcessor:
try:
# Extract content and tool calls from response
content = ""
# Generate a unique ID for this thread run
thread_run_id = str(uuid.uuid4())
tool_calls = []
# Tool execution counter
tool_index = 0
# XML tool call counter
xml_tool_call_count = 0
# Set to track yielded tool results
yielded_tool_indices = set()
# yielded_tool_indices = set() # Not needed for non-streaming as we yield all at once
# Store message IDs
assistant_message_id = None
tool_result_message_ids = {} # tool_index -> message_id
# Extract finish_reason if available
finish_reason = None
@ -662,7 +707,7 @@ class ResponseProcessor:
"content": content,
"tool_calls": native_tool_calls if config.native_tool_calling and 'native_tool_calls' in locals() else None
}
await self.add_message(
assistant_message_id = await self.add_message(
thread_id=thread_id,
type="assistant",
content=message_data,
@ -670,7 +715,27 @@ class ResponseProcessor:
)
# Yield content first
yield {"type": "content", "content": content}
yield {
"type": "content",
"content": content,
"assistant_message_id": assistant_message_id,
"thread_run_id": thread_run_id
}
# Yield the assistant response end signal *immediately* after saving
if assistant_message_id:
yield {
"type": "assistant_response_end",
"assistant_message_id": assistant_message_id,
"thread_run_id": thread_run_id
}
else:
# Handle case where saving failed (though it should raise an exception)
yield {
"type": "assistant_response_end",
"assistant_message_id": None,
"thread_run_id": thread_run_id
}
# Execute tools if needed - AFTER assistant message has been added
if config.execute_tools and tool_calls:
@ -684,23 +749,22 @@ class ResponseProcessor:
)
for tool_call, result in tool_results:
# Add result based on tool type
await self._add_tool_result(
# Capture the message ID for this tool result
message_id = await self._add_tool_result(
thread_id,
tool_call,
result,
config.xml_adding_strategy
)
if message_id:
tool_result_message_ids[tool_index] = message_id
# Create context for tool result
context = self._create_tool_context(tool_call, tool_index)
context.result = result
# Yield tool execution result
yield self._yield_tool_result(context)
# Track that we've yielded this tool result
yielded_tool_indices.add(tool_index)
yield self._yield_tool_result(context, tool_message_id=message_id, thread_run_id=thread_run_id)
# Increment tool index for next tool
tool_index += 1
@ -709,19 +773,21 @@ class ResponseProcessor:
if finish_reason == "xml_tool_limit_reached":
yield {
"type": "finish",
"finish_reason": "xml_tool_limit_reached"
"finish_reason": "xml_tool_limit_reached",
"thread_run_id": thread_run_id
}
logger.info(f"Non-streaming response finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls")
# Otherwise yield the regular finish reason if available
elif finish_reason:
yield {
"type": "finish",
"finish_reason": finish_reason
"finish_reason": finish_reason,
"thread_run_id": thread_run_id
}
except Exception as e:
logger.error(f"Error processing response: {str(e)}", exc_info=True)
yield {"type": "error", "message": str(e)}
yield {"type": "error", "message": str(e), "thread_run_id": thread_run_id if 'thread_run_id' in locals() else None}
# XML parsing methods
def _extract_tag_content(self, xml_chunk: str, tag_name: str) -> Tuple[Optional[str], Optional[str]]:
@ -1125,7 +1191,7 @@ class ResponseProcessor:
tool_call: Dict[str, Any],
result: ToolResult,
strategy: Union[XmlAddingStrategy, str] = "assistant_message"
):
) -> Optional[str]: # Return the message ID
"""Add a tool result to the conversation thread based on the specified format.
This method formats tool results and adds them to the conversation history,
@ -1141,6 +1207,7 @@ class ResponseProcessor:
("user_message", "assistant_message", or "inline_edit")
"""
try:
message_id = None # Initialize message_id
# Check if this is a native function call (has id field)
if "id" in tool_call:
# Format as a proper tool message according to OpenAI spec
@ -1175,13 +1242,13 @@ class ResponseProcessor:
# Add as a tool message to the conversation history
# This makes the result visible to the LLM in the next turn
await self.add_message(
message_id = await self.add_message(
thread_id=thread_id,
type="tool", # Special type for tool responses
content=tool_message,
is_llm_message=True
)
return
return message_id # Return the message ID
# For XML and other non-native tools, continue with the original logic
# Determine message role based on strategy
@ -1200,12 +1267,13 @@ class ResponseProcessor:
"role": result_role,
"content": content
}
await self.add_message(
message_id = await self.add_message(
thread_id=thread_id,
type="tool",
content=result_message,
is_llm_message=True
)
return message_id # Return the message ID
except Exception as e:
logger.error(f"Error adding tool result: {str(e)}", exc_info=True)
# Fallback to a simple message
@ -1214,14 +1282,16 @@ class ResponseProcessor:
"role": "user",
"content": str(result)
}
await self.add_message(
message_id = await self.add_message(
thread_id=thread_id,
type="tool",
content=fallback_message,
is_llm_message=True
)
return message_id # Return the message ID
except Exception as e2:
logger.error(f"Failed even with fallback message: {str(e2)}", exc_info=True)
return None # Return None on error
def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) -> str:
"""Format a tool result wrapped in a <tool_result> tag.
@ -1243,15 +1313,17 @@ class ResponseProcessor:
return f"Result for {function_name}: {str(result)}"
# At class level, define a method for yielding tool results
def _yield_tool_result(self, context: ToolExecutionContext) -> Dict[str, Any]:
def _yield_tool_result(self, context: ToolExecutionContext, tool_message_id: Optional[str], thread_run_id: str) -> Dict[str, Any]:
"""Format and return a tool result message."""
if not context.result:
return {
"type": "tool_result",
"function_name": context.function_name,
"xml_tag_name": context.xml_tag_name,
"result": "No result available",
"tool_index": context.tool_index
"result": "Error: No result available in context",
"tool_index": context.tool_index,
"tool_message_id": tool_message_id,
"thread_run_id": thread_run_id
}
formatted_result = self._format_xml_tool_result(context.tool_call, context.result)
@ -1260,7 +1332,9 @@ class ResponseProcessor:
"function_name": context.function_name,
"xml_tag_name": context.xml_tag_name,
"result": formatted_result,
"tool_index": context.tool_index
"tool_index": context.tool_index,
"tool_message_id": tool_message_id,
"thread_run_id": thread_run_id
}
def _create_tool_context(self, tool_call: Dict[str, Any], tool_index: int) -> ToolExecutionContext:
@ -1281,7 +1355,7 @@ class ResponseProcessor:
return context
def _yield_tool_started(self, context: ToolExecutionContext) -> Dict[str, Any]:
def _yield_tool_started(self, context: ToolExecutionContext, thread_run_id: str) -> Dict[str, Any]:
"""Format and return a tool started status message."""
tool_name = context.xml_tag_name or context.function_name
return {
@ -1290,13 +1364,14 @@ class ResponseProcessor:
"function_name": context.function_name,
"xml_tag_name": context.xml_tag_name,
"message": f"Starting execution of {tool_name}",
"tool_index": context.tool_index
"tool_index": context.tool_index,
"thread_run_id": thread_run_id
}
def _yield_tool_completed(self, context: ToolExecutionContext) -> Dict[str, Any]:
def _yield_tool_completed(self, context: ToolExecutionContext, tool_message_id: Optional[str], thread_run_id: str) -> Dict[str, Any]:
"""Format and return a tool completed/failed status message."""
if not context.result:
return self._yield_tool_error(context)
return self._yield_tool_error(context, thread_run_id)
tool_name = context.xml_tag_name or context.function_name
return {
@ -1305,10 +1380,12 @@ class ResponseProcessor:
"function_name": context.function_name,
"xml_tag_name": context.xml_tag_name,
"message": f"Tool {tool_name} {'completed successfully' if context.result.success else 'failed'}",
"tool_index": context.tool_index
"tool_index": context.tool_index,
"tool_message_id": tool_message_id,
"thread_run_id": thread_run_id
}
def _yield_tool_error(self, context: ToolExecutionContext) -> Dict[str, Any]:
def _yield_tool_error(self, context: ToolExecutionContext, thread_run_id: str) -> Dict[str, Any]:
"""Format and return a tool error status message."""
error_msg = str(context.error) if context.error else "Unknown error"
tool_name = context.xml_tag_name or context.function_name
@ -1318,5 +1395,7 @@ class ResponseProcessor:
"function_name": context.function_name,
"xml_tag_name": context.xml_tag_name,
"message": f"Error executing tool: {error_msg}",
"tool_index": context.tool_index
"tool_index": context.tool_index,
"tool_message_id": None,
"thread_run_id": thread_run_id
}

View File

@ -83,8 +83,18 @@ class ThreadManager:
}
try:
result = await client.table('messages').insert(data_to_insert).execute()
# Add returning='representation' to get the inserted row data including the id
result = await client.table('messages').insert(data_to_insert, returning='representation').execute()
logger.info(f"Successfully added message to thread {thread_id}")
print(f"MESSAGE RESULT: {result}")
# Check the structure of result.data before accessing
if result.data and len(result.data) > 0 and isinstance(result.data[0], dict) and 'message_id' in result.data[0]:
return result.data[0]['message_id']
else:
logger.error(f"Insert operation failed or did not return expected data structure for thread {thread_id}. Result data: {result.data}")
return None
except Exception as e:
logger.error(f"Failed to add message to thread {thread_id}: {str(e)}", exc_info=True)
raise
@ -130,8 +140,6 @@ class ThreadManager:
if isinstance(tool_call, dict) and 'function' in tool_call:
# Ensure function.arguments is a string
if 'arguments' in tool_call['function'] and not isinstance(tool_call['function']['arguments'], str):
# Log and fix the issue
# logger.warning(f"Found non-string arguments in tool_call, converting to string")
tool_call['function']['arguments'] = json.dumps(tool_call['function']['arguments'])
return messages

View File

@ -247,7 +247,7 @@ async def test_openrouter():
# Test with standard OpenRouter model
print("\n--- Testing standard OpenRouter model ---")
response = await make_llm_api_call(
model_name="openrouter/openai/gpt-3.5-turbo",
model_name="openrouter/openai/gpt-4o-mini",
messages=test_messages,
temperature=0.7,
max_tokens=100

4
frontend/.gitignore vendored
View File

@ -1,5 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.env
.env.local
frontend/.env
# dependencies
/node_modules
/.pnp

View File

@ -74,6 +74,7 @@
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/diff": "^7.0.2",
"@types/lodash": "^4.17.16",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
@ -4157,6 +4158,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.16",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
"dev": true
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",

View File

@ -75,6 +75,7 @@
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/diff": "^7.0.2",
"@types/lodash": "^4.17.16",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",

View File

@ -15,13 +15,10 @@ import { FileViewerModal } from '@/components/thread/file-viewer-modal';
import { SiteHeader } from "@/components/thread/thread-site-header"
import { ToolCallSidePanel, SidePanelContent, ToolCallData } from "@/components/thread/tool-call-side-panel";
import { useSidebar } from "@/components/ui/sidebar";
import { TodoPanel } from '@/components/thread/todo-panel';
// Import types and utils
import { ApiMessage, ThreadParams, isToolSequence } from '@/components/thread/types';
import { getToolIcon, extractPrimaryParam, groupMessages, SHOULD_RENDER_TOOL_RESULTS } from '@/components/thread/utils';
export default function ThreadPage({ params }: { params: Promise<ThreadParams> }) {
const unwrappedParams = React.use(params);
const threadId = unwrappedParams.threadId;
@ -180,15 +177,6 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
finish_reason?: string;
function_name?: string;
xml_tag_name?: string;
tool_call?: {
id: string;
function: {
name: string;
arguments: string;
};
type: string;
index: number;
};
} | null = null;
// Handle data: prefix format (SSE standard)
@ -288,26 +276,6 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
return;
}
// --- Handle Live Tool Call Updates for Side Panel ---
if (jsonData?.type === 'tool_call' && jsonData.tool_call) {
console.log('[PAGE] Received tool_call update:', jsonData.tool_call);
const currentLiveToolCall: ToolCallData = {
id: jsonData.tool_call.id,
name: jsonData.tool_call.function.name,
arguments: jsonData.tool_call.function.arguments,
index: jsonData.tool_call.index,
};
setToolCallData(currentLiveToolCall); // Keep for stream content rendering
setCurrentPairIndex(null); // Live data means not viewing a historical pair
setSidePanelContent(currentLiveToolCall); // Update side panel
if (!isSidePanelOpen) {
// Optionally auto-open side panel? Maybe only if user hasn't closed it recently.
// setIsSidePanelOpen(true);
}
return;
}
// If we reach here and have JSON data but it's not a recognized type,
// log it for debugging purposes
console.log('[PAGE] Unhandled message type:', jsonData?.type);
@ -554,7 +522,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
};
}, [threadId, handleStreamAgent, agentRunId, agentStatus, isStreaming]);
const handleSubmitMessage = async (message: string) => {
const handleSubmitMessage = useCallback(async (message: string) => {
if (!message.trim()) return;
setIsSending(true);
@ -594,9 +562,9 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
} finally {
setIsSending(false);
}
};
}, [threadId, handleStreamAgent]);
const handleStopAgent = async () => {
const handleStopAgent = useCallback(async () => {
if (!agentRunId) {
console.warn('[PAGE] No agent run ID to stop');
return;
@ -651,7 +619,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
setAgentRunId(null);
setStreamContent('');
}
};
}, [agentRunId, threadId]);
// Auto-focus on textarea when component loads
useEffect(() => {
@ -794,9 +762,9 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
}, [isStreaming, agentRunId, agentStatus]);
// Open the file viewer modal
const handleOpenFileViewer = () => {
const handleOpenFileViewer = useCallback(() => {
setFileViewerOpen(true);
};
}, []);
// Click handler for historical tool previews
const handleHistoricalToolClick = (pair: { assistantCall: ApiMessage, userResult: ApiMessage }) => {
@ -1109,7 +1077,6 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
<div className="space-y-2">
{(() => { // IIFE for scope
const toolName = message.tool_call.function.name;
const IconComponent = getToolIcon(toolName);
const paramDisplay = extractPrimaryParam(toolName, message.tool_call.function.arguments);
return (
<button
@ -1217,12 +1184,14 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
</div>
);
}
})}
{/* ---- End of Message Mapping ---- */}
{/* ---- Rendering Logic for Streaming ---- */}
{streamContent && (
<div
ref={latestMessageRef}
<div
ref={latestMessageRef}
className="py-2 border-t border-gray-100"
>
{/* Simplified header with logo and name */}
@ -1480,11 +1449,13 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
)}
</div>
)}
{/* Empty div for scrolling - MOVED HERE */}
<div ref={messagesEndRef} />
</div>
<div
<div
className="sticky bottom-6 flex justify-center"
style={{
style={{
opacity: buttonOpacity,
transition: 'opacity 0.3s ease-in-out',
visibility: showScrollButton ? 'visible' : 'hidden'
@ -1501,16 +1472,16 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
</div>
</div>
<div className="bg-sidebar backdrop-blur-sm">
<div>
<div className="mx-auto max-w-3xl px-6 py-2">
{/* Show Todo panel above chat input when side panel is closed */}
{!isSidePanelOpen && sandboxId && (
{/* {!isSidePanelOpen && sandboxId && (
<TodoPanel
sandboxId={sandboxId}
isSidePanelOpen={isSidePanelOpen}
className="mb-3"
/>
)}
)} */}
<ChatInput
value={newMessage}

View File

@ -227,7 +227,7 @@
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(54.65% 0.246 262.87);
--secondary: oklch(60.5% 0.18 240); /* Standard blue */
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
@ -263,7 +263,7 @@
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(54.65% 0.246 262.87); /* #2B7FFF */
--secondary: oklch(55% 0.16 240); /* Standard blue for dark mode */
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);

View File

@ -5,6 +5,7 @@ import {
BookOpen,
CalendarClock,
} from "lucide-react"
import Link from "next/link"
import { NavAgents } from "@/components/dashboard/sidebar/nav-agents"
import { NavUserWithTeams } from "@/components/dashboard/sidebar/nav-user-with-teams"
@ -77,7 +78,9 @@ export function SidebarLeft({
<Sidebar collapsible="icon" className="border-r-0 bg-background/95 backdrop-blur-sm" {...props}>
<SidebarHeader className="px-2 py-2">
<div className="flex h-[40px] items-center px-1 relative">
<KortixLogo />
<Link href="/dashboard">
<KortixLogo />
</Link>
{state !== "collapsed" && (
<div className="ml-2 transition-all duration-200 ease-in-out whitespace-nowrap">
{/* <span className="font-semibold"> SUNA</span> */}

View File

@ -3,9 +3,17 @@
import React, { useState, useRef, useEffect } from 'react';
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Send, Square, Loader2, File, Upload, X } from "lucide-react";
import { Send, Square, Loader2, File, Upload, X, Paperclip, FileText } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { toast } from "sonner";
import { AnimatePresence, motion } from "framer-motion";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
// Define API_URL
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
@ -48,6 +56,7 @@ export function ChatInput({
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isDraggingOver, setIsDraggingOver] = useState(false);
// Allow controlled or uncontrolled usage
const isControlled = value !== undefined && onChange !== undefined;
@ -137,13 +146,43 @@ export function ChatInput({
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
};
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
if (!sandboxId || !e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
const files = Array.from(e.dataTransfer.files);
await uploadFiles(files);
};
const processFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!sandboxId || !event.target.files || event.target.files.length === 0) return;
const files = Array.from(event.target.files);
await uploadFiles(files);
// Reset the input
event.target.value = '';
};
const uploadFiles = async (files: File[]) => {
try {
setIsUploading(true);
const files = Array.from(event.target.files);
const newUploadedFiles: UploadedFile[] = [];
for (const file of files) {
@ -198,8 +237,6 @@ export function ChatInput({
toast.error(typeof error === 'string' ? error : (error instanceof Error ? error.message : "Failed to upload file"));
} finally {
setIsUploading(false);
// Reset the input
event.target.value = '';
}
};
@ -213,31 +250,87 @@ export function ChatInput({
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
};
const getFileIcon = (fileName: string) => {
const extension = fileName.split('.').pop()?.toLowerCase();
switch(extension) {
case 'pdf':
return <FileText className="h-4 w-4 text-red-500" />;
case 'doc':
case 'docx':
return <FileText className="h-4 w-4 text-blue-500" />;
case 'xls':
case 'xlsx':
return <FileText className="h-4 w-4 text-green-500" />;
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'svg':
return <FileText className="h-4 w-4 text-purple-500" />;
default:
return <FileText className="h-4 w-4 text-gray-500" />;
}
};
return (
<div className="w-full">
<form onSubmit={handleSubmit} className="relative">
<div
className={cn(
"w-full border rounded-lg transition-all duration-200 shadow-sm",
uploadedFiles.length > 0 ? "border-border" : "border-input",
isDraggingOver ? "border-primary border-dashed bg-primary/5" : ""
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<AnimatePresence>
{uploadedFiles.length > 0 && (
<div className="mb-2 space-y-1">
{uploadedFiles.map((file, index) => (
<div key={index} className="p-2 bg-secondary/20 rounded-md flex items-center justify-between">
<div className="flex items-center text-sm">
<File className="h-4 w-4 mr-2 text-primary" />
<span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground ml-2">
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="px-3 pt-3 overflow-hidden"
>
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto pr-1 pb-2">
{uploadedFiles.map((file, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
className="px-2 py-1 bg-secondary/20 rounded-full flex items-center gap-1.5 group border border-secondary/30 hover:border-secondary/50 transition-colors text-sm"
>
{getFileIcon(file.name)}
<span className="font-medium truncate max-w-[120px]">{file.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
({formatFileSize(file.size)})
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => removeUploadedFile(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 ml-0.5 rounded-full p-0 hover:bg-secondary/50"
onClick={() => removeUploadedFile(index)}
>
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</div>
<div className="h-px bg-border/40 my-2 mx-1" />
</motion.div>
)}
</AnimatePresence>
<div className="relative">
{isDraggingOver && (
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
<div className="flex flex-col items-center">
<Upload className="h-6 w-6 text-primary mb-2" />
<p className="text-sm font-medium text-primary">Drop files to upload</p>
</div>
</div>
)}
@ -251,28 +344,44 @@ export function ChatInput({
? "Agent is thinking..."
: placeholder
}
className="min-h-[50px] max-h-[200px] pr-20 resize-none"
className={cn(
"min-h-[50px] max-h-[200px] pr-24 resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-white",
isDraggingOver ? "opacity-20" : "",
isAgentRunning ? "rounded-t-lg" : "rounded-lg"
)}
disabled={loading || (disabled && !isAgentRunning)}
rows={1}
/>
<div className="absolute right-2 bottom-2 flex items-center space-x-1">
<div className="absolute right-2 bottom-2 flex items-center space-x-1.5">
{/* Upload file button */}
<Button
type="button"
onClick={handleFileUpload}
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
disabled={loading || (disabled && !isAgentRunning) || isUploading}
aria-label="Upload files"
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
onClick={handleFileUpload}
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full transition-all hover:bg-primary/10 hover:text-primary",
isUploading && "text-primary"
)}
disabled={loading || (disabled && !isAgentRunning) || isUploading}
aria-label="Upload files"
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Attach files</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Hidden file input */}
<input
@ -285,51 +394,77 @@ export function ChatInput({
{/* File browser button */}
{onFileBrowse && (
<Button
type="button"
onClick={onFileBrowse}
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
disabled={loading || (disabled && !isAgentRunning)}
aria-label="Browse files"
>
<File className="h-4 w-4" />
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
onClick={onFileBrowse}
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full transition-all hover:bg-primary/10 hover:text-primary"
disabled={loading || (disabled && !isAgentRunning)}
aria-label="Browse files"
>
<File className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Browse workspace files</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
type={isAgentRunning ? 'button' : 'submit'}
onClick={isAgentRunning ? onStopAgent : undefined}
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
disabled={((!inputValue.trim() && uploadedFiles.length === 0) && !isAgentRunning) || loading || (disabled && !isAgentRunning)}
aria-label={isAgentRunning ? 'Stop agent' : 'Send message'}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isAgentRunning ? (
<Square className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
onClick={isAgentRunning ? onStopAgent : handleSubmit}
variant={isAgentRunning ? "destructive" : "default"}
size="icon"
className={cn(
"h-8 w-8 rounded-full",
!isAgentRunning && "bg-primary hover:bg-primary/90",
isAgentRunning && "bg-destructive hover:bg-destructive/90"
)}
disabled={((!inputValue.trim() && uploadedFiles.length === 0) && !isAgentRunning) || loading || (disabled && !isAgentRunning)}
aria-label={isAgentRunning ? 'Stop agent' : 'Send message'}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isAgentRunning ? (
<Square className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{isAgentRunning ? 'Stop agent' : 'Send message'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</form>
</div>
{isAgentRunning && (
<div className="mt-2 flex items-center justify-center gap-2">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="py-2 px-3 flex items-center justify-center bg-white border-t rounded-b-lg"
>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span className="inline-flex items-center">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
<Loader2 className="h-3 w-3 animate-spin mr-1.5" />
Agent is thinking...
</span>
<span className="text-muted-foreground/60 border-l pl-1.5">
Press <kbd className="inline-flex items-center justify-center p-0.5 bg-muted border rounded text-xs"><Square className="h-2.5 w-2.5" /></kbd> to stop
<span className="text-muted-foreground/60 border-l pl-2">
Press <kbd className="inline-flex items-center justify-center p-0.5 mx-1 bg-muted border rounded text-xs"><Square className="h-2.5 w-2.5" /></kbd> to stop
</span>
</div>
</div>
</motion.div>
)}
</div>
);

View File

@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
import { X, Package, Info, Terminal, CheckCircle, SkipBack, SkipForward, MonitorPlay, FileSymlink, FileDiff, FileEdit, Search, Globe, ExternalLink, Database, Code, ListFilter } from "lucide-react";
import { X, Package, Info, Terminal, CheckCircle, SkipBack, SkipForward, MonitorPlay, FileSymlink, FileDiff, FileEdit, Search, Globe, ExternalLink, Database, Code, ListFilter, Rocket, Laptop, Command, ArrowUpCircle } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { Project } from "@/lib/api";
import { TodoPanel } from "./todo-panel";
@ -93,37 +93,77 @@ function CommandToolView({ assistantContent, userContent }: { assistantContent?:
const isSuccessful = exitCode === 0;
return (
<div className="border border-muted rounded-md overflow-hidden">
{/* Terminal header */}
<div className="bg-gray-900 p-2 flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
<div className="h-3 w-3 rounded-full bg-green-500"></div>
<span className="text-xs text-slate-400 ml-2 flex-1">Terminal</span>
<div className="border rounded-md overflow-hidden">
<div className="flex items-center p-2 bg-muted justify-between">
<div className="flex items-center">
<Terminal className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-sm font-medium">Terminal</span>
</div>
{exitCode !== null && (
<span className={`text-xs px-2 py-0.5 rounded ${isSuccessful ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'}`}>
<span className={`text-xs flex items-center ${isSuccessful ? 'text-green-600' : 'text-red-600'}`}>
<span className="h-1.5 w-1.5 rounded-full mr-1.5 animate-pulse bg-current"></span>
Exit: {exitCode}
</span>
)}
</div>
{/* Unified terminal output area */}
<div className="bg-black font-mono text-xs p-3 text-slate-300 whitespace-pre-wrap max-h-[500px] overflow-y-auto">
<div className="px-4 py-2 border-t border-b bg-muted/50 flex justify-between items-center">
{command && (
<>
<span className="text-green-400">user@workspace:~$ </span>
<span className="text-slate-200">{command}</span>
{output && (
<>
<div className="pt-1 pb-2"></div>
<div className="text-slate-300">{output}</div>
<div className="text-xs font-mono truncate flex items-center space-x-2">
<span className="text-muted-foreground">Command:</span>
<span className="bg-muted px-2 py-0.5 rounded">{command}</span>
</div>
)}
</div>
<div className={`terminal-container overflow-auto max-h-[500px] ${output ? 'bg-muted/10' : 'bg-muted/5'}`}>
<div className="p-4 font-mono text-sm space-y-3">
{command && output && (
<>
<div className="flex items-start">
<span className="text-emerald-600 dark:text-emerald-400 shrink-0 mr-2">$</span>
<span className="font-semibold">{command}</span>
</div>
<div className="text-muted-foreground whitespace-pre-wrap break-all text-sm">
{output}
</div>
</>
)}
{!output && <div className="animate-pulse mt-1"></div>}
</>
)}
{!command && <span className="text-gray-500">No command available</span>}
{command && !output && (
<div className="text-center p-6">
<div className="animate-pulse flex justify-center">
<div className="h-1 w-1 mx-0.5 bg-muted-foreground rounded-full"></div>
<div className="h-1 w-1 mx-0.5 bg-muted-foreground rounded-full animation-delay-200"></div>
<div className="h-1 w-1 mx-0.5 bg-muted-foreground rounded-full animation-delay-500"></div>
</div>
<p className="text-xs text-muted-foreground mt-2">Command running...</p>
</div>
)}
{!command && !output && (
<div className="text-center p-6">
<Terminal className="h-6 w-6 mx-auto mb-2 text-muted-foreground opacity-40" />
<p className="text-muted-foreground">No command available</p>
</div>
)}
</div>
</div>
{isSuccessful && output && (
<div className="border-t px-4 py-2 bg-green-50 dark:bg-green-950/10 flex items-center">
<CheckCircle className="h-4 w-4 text-green-600 mr-2" />
<span className="text-xs text-green-700 dark:text-green-400">Command completed successfully</span>
</div>
)}
{exitCode !== null && !isSuccessful && (
<div className="border-t px-4 py-2 bg-red-50 dark:bg-red-950/10 flex items-center">
<X className="h-4 w-4 text-red-600 mr-2" />
<span className="text-xs text-red-700 dark:text-red-400">Command failed with exit code {exitCode}</span>
</div>
)}
</div>
);
}
@ -563,6 +603,11 @@ function getUnifiedToolView(toolName: string | undefined, assistantContent: stri
const lowerToolName = toolName.toLowerCase();
// Check for deploy commands first
if (lowerToolName === 'deploy' || (assistantContent && assistantContent.includes('<deploy'))) {
return <DeployToolView assistantContent={assistantContent} userContent={userContent} />;
}
if (lowerToolName === 'execute-command' || (assistantContent && assistantContent.includes('<execute-command>'))) {
return <CommandToolView assistantContent={assistantContent} userContent={userContent} />;
}
@ -786,7 +831,303 @@ function WebSearchToolView({ assistantContent, userContent }: { assistantContent
// Check if search was successful
const isSuccess = userContent?.includes('success=True');
// Get raw output without trying to parse as JSON
// Parse search results to a more structured format
const parseSearchResults = (): Array<{title: string, url: string, snippet?: string, publishedDate?: string}> => {
if (!userContent) return [];
try {
// Extract the results section
const outputMatch = userContent.match(/output='([\s\S]*?)(?='\))/);
if (!outputMatch || !outputMatch[1]) return [];
const output = outputMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
// Try to extract search results from the output
const results: Array<{title: string, url: string, snippet?: string, publishedDate?: string}> = [];
// Try to parse as JSON first
try {
if (output.trim().startsWith('{') || output.trim().startsWith('[')) {
const parsedOutput = JSON.parse(output);
// Handle array of results - this is the most common format
if (Array.isArray(parsedOutput)) {
return parsedOutput
.map(item => {
// Handle variations in field names (capitalized and lowercase)
const title = item.Title || item.title || '';
let url = item.URL || item.Url || item.url || '';
const snippet = item.Snippet || item.snippet || item.Description || item.description || '';
const publishedDate = item["Published Date"] || item.publishedDate || item.Date || item.date || '';
// Clean up URLs - sometimes they are quoted or have URL: prefix
if (url && typeof url === 'string') {
url = cleanUrl(url);
}
// Skip entries without a real URL
if (!url || url === '"' || url === "'") return null;
return {
title: cleanTitle(title) || extractTitleFromUrl(url),
url: url,
snippet: typeof snippet === 'string' ? snippet : '',
publishedDate: typeof publishedDate === 'string' ? publishedDate : ''
};
})
.filter(Boolean) // Remove null entries
.filter((item, index, self) =>
// Remove duplicates based on URL
index === self.findIndex((t) => t.url === item.url)
);
}
// Handle object with results array
if (parsedOutput.results && Array.isArray(parsedOutput.results)) {
return parsedOutput.results
.map(item => {
const title = item.Title || item.title || '';
let url = item.URL || item.Url || item.url || '';
url = cleanUrl(url);
// Skip entries without a real URL
if (!url || url === '"' || url === "'") return null;
return {
title: cleanTitle(title) || extractTitleFromUrl(url),
url: url,
snippet: item.Snippet || item.snippet || item.Description || item.description || '',
publishedDate: item["Published Date"] || item.publishedDate || item.Date || item.date || ''
};
})
.filter(Boolean) // Remove null entries
.filter((item, index, self) =>
index === self.findIndex((t) => t.url === item.url)
);
}
}
} catch (e) {
console.log('JSON parsing failed, trying text parsing');
}
// Fallback to text parsing for non-JSON or parsing errors
// First, try to detect if the output is in a specific format but not valid JSON
// Handle cases where JSON is malformed but recognizable
// For instance, missing quotes around property names or values
if (output.includes('"Title"') || output.includes('"URL"')) {
// Try to extract title/URL pairs using regex
const urlRegex = /"URL":\s*"?([^",\n]+)"?,?/gi;
const titleRegex = /"Title":\s*"([^"]+)"/gi;
let urlMatch;
let titleMatch;
// Extract all URLs
const urls: {url: string, index: number}[] = [];
while ((urlMatch = urlRegex.exec(output)) !== null) {
if (urlMatch[1] && urlMatch[1].trim()) {
urls.push({
url: cleanUrl(urlMatch[1]),
index: urlMatch.index
});
}
}
// Extract all titles
const titles: {title: string, index: number}[] = [];
while ((titleMatch = titleRegex.exec(output)) !== null) {
if (titleMatch[1] && titleMatch[1].trim()) {
titles.push({
title: cleanTitle(titleMatch[1]),
index: titleMatch.index
});
}
}
// Match titles with URLs by proximity
urls.forEach(urlItem => {
let nearestTitle = '';
let minDistance = Infinity;
titles.forEach(titleItem => {
const distance = Math.abs(titleItem.index - urlItem.index);
if (distance < minDistance) {
minDistance = distance;
nearestTitle = titleItem.title;
}
});
if (urlItem.url && !results.some(r => r.url === urlItem.url)) {
results.push({
title: nearestTitle || extractTitleFromUrl(urlItem.url),
url: urlItem.url,
snippet: ''
});
}
});
if (results.length > 0) {
return results;
}
}
// General URL pattern fallback
const urlPattern = /(https?:\/\/[^\s"'<>]+)/g;
const urlMatches = [...output.matchAll(urlPattern)];
if (urlMatches.length > 0) {
// For each URL, try to find a title nearby
for (let i = 0; i < urlMatches.length; i++) {
const url = cleanUrl(urlMatches[i][0]);
// Skip if we already have this URL
if (results.some(r => r.url === url)) continue;
// Get context around URL (10 lines before and after)
const urlIndex = output.indexOf(urlMatches[i][0]);
const startContextIndex = Math.max(0, output.lastIndexOf('\n', urlIndex - 200));
const endContextIndex = Math.min(output.length, output.indexOf('\n', urlIndex + 200));
const context = output.substring(startContextIndex, endContextIndex);
// Try to find a title in the context
let title = '';
// Look for something that looks like a title (text followed by the URL)
const lines = context.split('\n');
for (let j = 0; j < lines.length; j++) {
const line = lines[j].trim();
// Skip empty lines or lines that contain the URL
if (!line || line.includes(url)) continue;
// If line looks like a title (not a URL, reasonable length)
if (!line.includes('http') && line.length > 5 && line.length < 100) {
title = cleanTitle(line);
break;
}
}
if (url && !results.some(r => r.url === url)) {
results.push({
title: title || extractTitleFromUrl(url),
url: url,
snippet: ''
});
}
}
}
return results;
} catch (e) {
console.error("Failed to parse search results", e);
return [];
}
};
// Clean up a title
const cleanTitle = (title: string): string => {
if (!title) return '';
// Remove surrounding quotes
let cleaned = title.replace(/^["'](.+)["']$/g, '$1');
// Remove URL: prefix if accidentally included
cleaned = cleaned.replace(/^URL:\s*/i, '');
// Remove "title" or "Title" prefix
cleaned = cleaned.replace(/^(title|Title):\s*/i, '');
// Remove leading/trailing spaces
cleaned = cleaned.trim();
return cleaned;
};
// Clean up a URL
const cleanUrl = (url: string): string => {
if (!url) return '';
// Remove quotes
let cleaned = url.replace(/^["'](.+)["']$/g, '$1');
// Remove "URL:" prefix
cleaned = cleaned.replace(/^URL:\s*/i, '');
// Remove trailing commas, quotes, brackets
cleaned = cleaned.replace(/[,"'\]\}]+$/, '');
// Remove any trailing whitespace
cleaned = cleaned.trim();
return cleaned;
};
// Extract a title from a URL if no title is available
const extractTitleFromUrl = (url: string): string => {
try {
const urlObj = new URL(url);
// Get the domain name first
const domain = urlObj.hostname.replace('www.', '');
// Get the path without the domain, clean it up and make it presentable
let path = urlObj.pathname;
if (path === '/' || !path) {
// If it's just the homepage, use the domain name
return domain.charAt(0).toUpperCase() + domain.slice(1);
}
// Remove trailing slashes, split by slashes and get the last meaningful segment
path = path.replace(/\/+$/, '');
const segments = path.split('/').filter(s => s.length > 0);
if (segments.length > 0) {
// Get the last segment, replace dashes and underscores with spaces, and capitalize
const lastSegment = segments[segments.length - 1]
.replace(/[-_]/g, ' ')
.replace(/\.([a-z]+)$/, '') // Remove file extensions
.trim();
if (lastSegment.length > 0) {
return lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1);
}
}
// Fallback to domain name
return domain.charAt(0).toUpperCase() + domain.slice(1);
} catch (e) {
// If URL parsing fails, extract something that looks like a title
const matches = url.match(/https?:\/\/(?:www\.)?([^\/]+)/);
return matches ? matches[1] : url.substring(0, 30);
}
};
// Format a human-readable date
const formatDate = (dateString: string): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
// Check if it's a valid date
if (isNaN(date.getTime())) return '';
// Only show if it's within the last 3 years or in the future
const now = new Date();
const threeYearsAgo = new Date();
threeYearsAgo.setFullYear(now.getFullYear() - 3);
if (date < threeYearsAgo) return '';
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short' });
} catch (e) {
return '';
}
};
const searchResults = parseSearchResults();
const hasResults = searchResults.length > 0;
// Get raw output for fallback
const getRawOutput = (): string => {
if (!userContent) return "";
@ -794,7 +1135,7 @@ function WebSearchToolView({ assistantContent, userContent }: { assistantContent
// Extract the results section
const outputMatch = userContent.match(/output='([\s\S]*?)(?='\))/);
if (outputMatch && outputMatch[1]) {
return outputMatch[1];
return outputMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
return "";
} catch (e) {
@ -805,6 +1146,28 @@ function WebSearchToolView({ assistantContent, userContent }: { assistantContent
const rawOutput = getRawOutput();
// Format a URL for display
const formatUrl = (url: string): string => {
try {
const urlObj = new URL(url);
let formattedUrl = urlObj.hostname.replace('www.', '');
// Add path if it's not just the root
if (urlObj.pathname && urlObj.pathname !== '/') {
// Limit path length
const path = urlObj.pathname.length > 25
? urlObj.pathname.substring(0, 25) + '...'
: urlObj.pathname;
formattedUrl += path;
}
return formattedUrl;
} catch (e) {
// Return a shortened version of the URL if parsing fails
return url.length > 40 ? url.substring(0, 40) + '...' : url;
}
};
return (
<div className="border rounded-md overflow-hidden">
<div className="flex items-center p-2 bg-muted justify-between">
@ -814,22 +1177,61 @@ function WebSearchToolView({ assistantContent, userContent }: { assistantContent
</div>
</div>
<div className="px-3 py-2 border-t border-b bg-muted/50">
<div className="px-4 py-3 border-t border-b bg-muted/50">
<div className="flex items-center">
<div className="text-sm font-medium mr-2">Query:</div>
<div className="text-sm font-mono bg-muted py-1 px-2 rounded flex-1">{query}</div>
<div className="text-sm bg-muted py-1 px-3 rounded-md flex-1">{query}</div>
</div>
<div className="mt-1.5 text-xs text-muted-foreground">
{hasResults ? `Found ${searchResults.length} results` : 'No results found'}
</div>
</div>
<div className="overflow-auto bg-muted/20 max-h-[500px]">
{rawOutput ? (
<pre className="text-xs font-mono p-3 whitespace-pre-wrap break-all">
{hasResults ? (
<div className="divide-y">
{searchResults.map((result, idx) => (
<div key={idx} className="p-4 space-y-1.5">
<div className="flex flex-col">
<div className="text-xs text-emerald-600 dark:text-emerald-400 truncate flex items-center">
<span className="truncate">{formatUrl(result.url)}</span>
{result.publishedDate && (
<span className="text-muted-foreground ml-2 whitespace-nowrap">
{formatDate(result.publishedDate)}
</span>
)}
</div>
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium"
>
{result.title}
</a>
</div>
{result.snippet && (
<p className="text-sm text-muted-foreground line-clamp-2">
{result.snippet}
</p>
)}
</div>
))}
</div>
) : rawOutput ? (
<div className="p-4">
<div className="text-sm mb-2 text-muted-foreground">
Showing raw search results:
</div>
<pre className="text-xs font-mono bg-muted/30 p-3 rounded whitespace-pre-wrap break-all">
{rawOutput}
</pre>
</div>
) : (
<div className="p-4 text-center text-muted-foreground">
<Search className="h-5 w-5 mx-auto mb-2 opacity-50" />
<p className="text-sm">{isSuccess ? "No results found" : "Search results unavailable"}</p>
<div className="p-6 text-center text-muted-foreground">
<Search className="h-6 w-6 mx-auto mb-2 opacity-50" />
<p className="text-sm font-medium">No results found</p>
<p className="text-xs mt-1">Try a different search query</p>
</div>
)}
</div>
@ -841,22 +1243,102 @@ function WebSearchToolView({ assistantContent, userContent }: { assistantContent
function WebCrawlToolView({ assistantContent, userContent }: { assistantContent?: string; userContent?: string }) {
if (!assistantContent) return <div>No content available</div>;
// Extract URL
// Extract URL from assistantContent
const urlMatch = assistantContent.match(/url=["']([\s\S]*?)["']/);
const url = urlMatch ? urlMatch[1] : "";
// Check if crawl was successful
const isSuccess = userContent?.includes('success=True');
// Get raw output without trying to parse as JSON
// Parse crawled content - keep it simple with just title, url, and text
const parseCrawlResult = (): { title: string; url: string; text: string; } | null => {
if (!userContent) return null;
try {
// Extract the output section
const outputMatch = userContent.match(/output='([\s\S]*?)(?='\))/);
if (!outputMatch || !outputMatch[1]) return null;
const output = outputMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
// Try to parse as JSON
try {
if (output.trim().startsWith('{') || output.trim().startsWith('[')) {
const parsedOutput = JSON.parse(output);
// Handle array with one item (common format)
if (Array.isArray(parsedOutput) && parsedOutput.length > 0) {
const item = parsedOutput[0];
return {
title: item.Title || item.title || '',
url: cleanUrl(item.URL || item.Url || item.url || ''),
text: item.Text || item.text || item.Content || item.content || ''
};
}
// Handle single object
if (!Array.isArray(parsedOutput)) {
return {
title: parsedOutput.Title || parsedOutput.title || '',
url: cleanUrl(parsedOutput.URL || parsedOutput.Url || parsedOutput.url || ''),
text: parsedOutput.Text || parsedOutput.text || parsedOutput.Content || parsedOutput.content || ''
};
}
}
} catch (e) {
console.log('JSON parsing failed');
}
// If JSON parsing fails, just return the raw text
return {
title: url,
url: url,
text: output
};
} catch (e) {
console.error("Failed to parse crawl result", e);
return null;
}
};
// Clean up a URL
const cleanUrl = (url: string): string => {
if (!url) return '';
// Remove quotes
let cleaned = url.replace(/^["'](.+)["']$/g, '$1');
// Remove "URL:" prefix
cleaned = cleaned.replace(/^URL:\s*/i, '');
// Remove trailing commas, quotes, brackets
cleaned = cleaned.replace(/[,"'\]\}]+$/, '');
// Remove any trailing whitespace
cleaned = cleaned.trim();
return cleaned;
};
// Format clean URL for display
const formatUrl = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.hostname + (urlObj.pathname !== '/' ? urlObj.pathname : '');
} catch (e) {
return url;
}
};
// Get raw output for fallback
const getRawOutput = (): string => {
if (!userContent) return "";
try {
// Extract the results section
// Extract the output section
const outputMatch = userContent.match(/output='([\s\S]*?)(?='\))/);
if (outputMatch && outputMatch[1]) {
return outputMatch[1];
return outputMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
return "";
} catch (e) {
@ -865,6 +1347,7 @@ function WebCrawlToolView({ assistantContent, userContent }: { assistantContent?
}
};
const crawlData = parseCrawlResult();
const rawOutput = getRawOutput();
return (
@ -872,33 +1355,49 @@ function WebCrawlToolView({ assistantContent, userContent }: { assistantContent?
<div className="flex items-center p-2 bg-muted justify-between">
<div className="flex items-center">
<Globe className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-sm font-medium">Webpage Content</span>
<span className="text-sm font-medium">Web Content</span>
</div>
</div>
<div className="px-3 py-2 border-t border-b bg-muted/50">
<div className="flex items-center truncate">
<div className="px-4 py-3 border-t border-b bg-muted/50">
<a
href={url}
href={crawlData?.url || url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline flex items-center gap-1"
className="text-blue-600 hover:underline flex items-center gap-1 text-sm font-medium"
>
{url.length > 50 ? `${url.substring(0, 50)}...` : url}
<ExternalLink className="h-3 w-3" />
{formatUrl(crawlData?.url || url)}
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
<div className="overflow-auto bg-muted/20 max-h-[500px]">
{rawOutput ? (
<pre className="text-xs font-mono p-3 whitespace-pre-wrap break-all">
{crawlData ? (
<div className="p-4">
{crawlData.title && crawlData.title !== url && (
<h3 className="text-base font-semibold mb-3">{crawlData.title}</h3>
)}
<div className="space-y-2 text-sm">
{crawlData.text.split('\n').map((paragraph, idx) => (
paragraph.trim() ? (
<p key={idx} className="text-muted-foreground">
{paragraph}
</p>
) : null
))}
</div>
</div>
) : rawOutput ? (
<div className="p-4">
<pre className="text-xs font-mono bg-muted/30 p-3 rounded whitespace-pre-wrap break-all">
{rawOutput}
</pre>
</div>
) : (
<div className="p-4 text-center text-muted-foreground">
<Globe className="h-5 w-5 mx-auto mb-2 opacity-50" />
<p className="text-sm">{isSuccess ? "No content extracted" : "Webpage content unavailable"}</p>
<div className="p-6 text-center text-muted-foreground">
<Globe className="h-6 w-6 mx-auto mb-2 opacity-50" />
<p className="text-sm font-medium">{isSuccess ? "No content extracted" : "Webpage content unavailable"}</p>
</div>
)}
</div>
@ -1182,6 +1681,94 @@ function DataProviderCallView({ assistantContent, userContent }: { assistantCont
);
}
// Component for deploy tool
function DeployToolView({ assistantContent, userContent }: { assistantContent?: string; userContent?: string }) {
if (!assistantContent) return <div>No content available</div>;
// Extract project name
const nameMatch = assistantContent.match(/name=["']([\s\S]*?)["']/);
const projectName = nameMatch ? nameMatch[1] : "unknown";
// Extract directory path
const dirMatch = assistantContent.match(/directory_path=["']([\s\S]*?)["']/);
const directory = dirMatch ? dirMatch[1] : "unknown";
// Check if operation was successful
const isSuccess = userContent?.includes('Success!') || userContent?.includes('Deployment complete!') || userContent?.includes('Successfully created');
// Extract deployment URL from the response
const extractDeploymentUrl = (): string | null => {
if (!userContent) return null;
// Try to find the URL pattern in the output
const urlMatch = userContent.match(/https:\/\/[a-zA-Z0-9-]+\.pages\.dev/);
return urlMatch ? urlMatch[0] : null;
};
const deploymentUrl = extractDeploymentUrl();
return (
<div className="border rounded-md overflow-hidden">
<div className="flex items-center p-3 bg-muted justify-between">
<div className="flex items-center">
<Rocket className="h-5 w-5 mr-2 text-primary" />
<span className="text-base font-medium">Deployment</span>
</div>
{isSuccess && (
<span className="text-sm text-green-600 flex items-center">
<CheckCircle className="h-4 w-4 mr-1" /> Deployed
</span>
)}
</div>
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Project:</div>
<div className="text-sm font-mono">{projectName}</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Directory:</div>
<div className="text-sm font-mono">{directory}</div>
</div>
</div>
{deploymentUrl && (
<div className="p-4 bg-green-50 dark:bg-green-950/20 space-y-4">
<div className="space-y-1">
<div className="text-sm font-medium text-green-700 dark:text-green-300">Deployment URL:</div>
<a
href={deploymentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1 break-all"
>
{deploymentUrl}
<ExternalLink className="h-4 w-4 flex-shrink-0" />
</a>
</div>
<div className="text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-3 rounded-md flex items-start gap-2">
<Info className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>It may take a few minutes before the site becomes reachable.</span>
</div>
<div className="flex justify-end">
<a
href={deploymentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2"
>
Visit Site
<ExternalLink className="h-4 w-4 ml-1" />
</a>
</div>
</div>
)}
</div>
);
}
export function ToolCallSidePanel({
isOpen,
onClose,
@ -1199,11 +1786,43 @@ export function ToolCallSidePanel({
// Get the sandbox ID from project for todo.md fetching
const sandboxId = project?.sandbox?.id || null;
// Get tool name for display
const getToolName = (): { name: string, icon: React.ReactNode } => {
if (isHistoricalPair(content)) {
const toolName = content.assistantCall.name || '';
if (toolName.toLowerCase() === 'deploy') {
return { name: 'DEPLOY', icon: <Rocket className="h-4 w-4" /> };
}
if (toolName.toLowerCase().includes('command')) {
return { name: 'EXECUTE', icon: <Terminal className="h-4 w-4" /> };
}
return { name: toolName.toUpperCase(), icon: <Command className="h-4 w-4" /> };
}
if (content && 'name' in content) {
const toolName = content.name || '';
if (toolName.toLowerCase() === 'deploy') {
return { name: 'DEPLOY', icon: <Rocket className="h-4 w-4" /> };
}
if (toolName.toLowerCase().includes('command')) {
return { name: 'EXECUTE', icon: <Terminal className="h-4 w-4" /> };
}
return { name: toolName.toUpperCase(), icon: <Command className="h-4 w-4" /> };
}
return { name: '', icon: null };
};
return (
<div
className={`
${isOpen ? 'w-full sm:w-[85%] md:w-[75%] lg:w-[60%] xl:w-[50%] 2xl:w-[40%] max-w-[1000px]' : 'w-0'}
${isOpen ? 'w-full sm:w-[100%] md:w-[45%] lg:w-[42.5%] xl:w-[40%] 2xl:w-[35%] max-w-[800px]' : 'w-0'}
border-l bg-sidebar h-screen flex flex-col
transition-all duration-300 ease-in-out overflow-hidden
fixed sm:sticky top-0 right-0 z-30
@ -1212,27 +1831,29 @@ export function ToolCallSidePanel({
{/* Ensure content doesn't render or is hidden when closed to prevent layout shifts */}
{isOpen && (
<>
<div className="flex items-center justify-between p-4 shrink-0">
<div className="bg-muted/30 backdrop-blur-sm sticky top-0 z-10">
<div className="flex items-center justify-between px-5 py-3">
<div className="flex flex-col">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Info className="h-5 w-5" />
Suna´s Computer
<Laptop className="h-5 w-5 text-primary" />
<span>Suna's Computer</span>
</h2>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={onClose} className="h-8 w-8 rounded-full">
<X className="h-4 w-4" />
<span className="sr-only">Close Panel</span>
</Button>
</div>
</div>
{/* Tool name display - Vercel-style minimalist */}
</div>
<div className="flex-1 p-4 overflow-y-auto">
{content ? (
// ---- Render Historical Pair ----
'type' in content && content.type === 'historical' ? (
<div className="space-y-6">
{/* Tool name header */}
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground border-b pb-3">
<Terminal className="h-4 w-4" />
<span>{content.assistantCall.name || 'Tool Call'}</span>
</div>
{/* Unified tool view - shows request and result together */}
{getUnifiedToolView(
content.assistantCall.name,
@ -1334,9 +1955,29 @@ export function ToolCallSidePanel({
{showNavigation && (
<div className="px-4 pt-2 pb-2 border-t">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
{currentIndex + 1 === totalPairs ? (
<div className="flex items-center">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30 mr-1.5">
<CheckCircle className="h-3 w-3 text-green-600" />
</span>
<span className="text-sm font-medium">
{totalPairs > 1 ?
`Completed ${totalPairs} steps` :
"Step completed"}
</span>
</div>
) : (
<>
<span className="text-sm font-medium text-muted-foreground">
Step {currentIndex + 1} of {totalPairs}
Step {currentIndex + 1}
</span>
<span className="text-xs text-muted-foreground/70 font-mono">
of {totalPairs}
</span>
</>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
@ -1358,22 +1999,34 @@ export function ToolCallSidePanel({
</Button>
</div>
</div>
<div className="relative">
<Slider
value={[currentIndex]} // Slider value is an array
value={[currentIndex]}
max={totalPairs - 1}
step={1}
onValueChange={(value) => onNavigate(value[0])} // onValueChange gives an array
/>
onValueChange={(value) => onNavigate(value[0])}
className={currentIndex + 1 === totalPairs ? "accent-green-600" : ""}
/>
{/* Progress markers */}
{totalPairs > 3 && (
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 flex justify-between pointer-events-none">
<div className="w-1 h-1 rounded-full bg-muted-foreground/40"></div>
<div className="w-1 h-1 rounded-full bg-muted-foreground/40"></div>
</div>
)}
</div>
</div>
)}
{/* Todo Panel at the bottom of side panel */}
{sandboxId && (
{/* {sandboxId && (
<TodoPanel
sandboxId={sandboxId}
isSidePanelOpen={isOpen}
/>
)}
)} */}
</>
)}
</div>