mirror of https://github.com/kortix-ai/suna.git
wip
This commit is contained in:
parent
90a953bd49
commit
2f40a98161
|
@ -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
|
|
@ -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 = {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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> */}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue