mirror of https://github.com/kortix-ai/suna.git
Merge branch 'main' into agent-knowledge-base
This commit is contained in:
commit
028f33ae34
|
@ -3,6 +3,8 @@ FROM ghcr.io/astral-sh/uv:python3.11-alpine
|
|||
ENV ENV_MODE production
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Install Python dependencies
|
||||
COPY pyproject.toml uv.lock ./
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
|
|
@ -4,11 +4,12 @@ import mimetypes
|
|||
from typing import Optional, Tuple
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from agentpress.tool import ToolResult, openapi_schema, xml_schema
|
||||
from sandbox.tool_base import SandboxToolsBase
|
||||
from agentpress.thread_manager import ThreadManager
|
||||
import json
|
||||
import requests
|
||||
|
||||
# Add common image MIME types if mimetypes module is limited
|
||||
mimetypes.add_type("image/webp", ".webp")
|
||||
|
@ -100,17 +101,55 @@ class SandboxVisionTool(SandboxToolsBase):
|
|||
print(f"[SeeImage] Failed to compress image: {str(e)}. Using original.")
|
||||
return image_bytes, mime_type
|
||||
|
||||
def is_url(self, file_path: str) -> bool:
|
||||
"""check if the file path is url"""
|
||||
parsed_url = urlparse(file_path)
|
||||
return parsed_url.scheme in ('http', 'https')
|
||||
|
||||
def download_image_from_url(self, url: str) -> Tuple[bytes, str]:
|
||||
"""Download image from a URL"""
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0" # Some servers block default Python
|
||||
}
|
||||
|
||||
# HEAD request to get the image size
|
||||
head_response = requests.head(url, timeout=10, headers=headers, stream=True)
|
||||
head_response.raise_for_status()
|
||||
|
||||
# Check content length
|
||||
content_length = int(head_response.headers.get('Content-Length'))
|
||||
if content_length and content_length > MAX_IMAGE_SIZE:
|
||||
raise Exception(f"Image is too large ({(content_length)/(1024*1024):.2f}MB) for the maximum allowed size of {MAX_IMAGE_SIZE/(1024*1024):.2f}MB")
|
||||
|
||||
# Download the image
|
||||
response = requests.get(url, timeout=10, headers=headers, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
image_bytes = response.content
|
||||
if len(image_bytes) > MAX_IMAGE_SIZE:
|
||||
raise Exception(f"Downloaded image is too large ({(len(image_bytes))/(1024*1024):.2f}MB). Maximum allowed size of {MAX_IMAGE_SIZE/(1024*1024):.2f}MB")
|
||||
|
||||
# Get MIME type
|
||||
mime_type = response.headers.get('Content-Type')
|
||||
if not mime_type or not mime_type.startswith('image/'):
|
||||
raise Exception(f"URL does not point to an image (Content-Type: {mime_type}): {url}")
|
||||
|
||||
return image_bytes, mime_type
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Failed to download image from URL: {str(e)}")
|
||||
|
||||
@openapi_schema({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "see_image",
|
||||
"description": "Allows the agent to 'see' an image file located in the /workspace directory. Provide the relative path to the image. The image will be compressed before sending to reduce token usage. The image content will be made available in the next turn's context.",
|
||||
"description": "Allows the agent to 'see' an image file located in the /workspace directory or from a URL. Provide either a relative path to a local image or the URL to an image. The image will be compressed before sending to reduce token usage. The image content will be made available in the next turn's context.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "The relative path to the image file within the /workspace directory (e.g., 'screenshots/image.png'). Supported formats: JPG, PNG, GIF, WEBP. Max size: 10MB."
|
||||
"description": "Either a relative path to the image file within the /workspace directory (e.g., 'screenshots/image.png') or a URL to an image (e.g., 'https://example.com/image.jpg'). Supported formats: JPG, PNG, GIF, WEBP. Max size: 10MB."
|
||||
}
|
||||
},
|
||||
"required": ["file_path"]
|
||||
|
@ -123,53 +162,72 @@ class SandboxVisionTool(SandboxToolsBase):
|
|||
{"param_name": "file_path", "node_type": "attribute", "path": "."}
|
||||
],
|
||||
example='''
|
||||
<!-- Example: Request to see an image named 'diagram.png' inside the 'docs' folder -->
|
||||
<!-- Example: Request to see a local image named 'diagram.png' inside the 'docs' folder -->
|
||||
<function_calls>
|
||||
<invoke name="see_image">
|
||||
<parameter name="file_path">docs/diagram.png</parameter>
|
||||
</invoke>
|
||||
</function_calls>
|
||||
|
||||
<!-- Example: Request to see an image from a URL -->
|
||||
<function_calls>
|
||||
<invoke name="see_image">
|
||||
<parameter name="file_path">https://example.com/image.jpg</parameter>
|
||||
</invoke>
|
||||
</function_calls>
|
||||
'''
|
||||
)
|
||||
async def see_image(self, file_path: str) -> ToolResult:
|
||||
"""Reads an image file, compresses it, converts it to base64, and adds it as a temporary message."""
|
||||
"""Reads an image file from local file system or from a URL, compresses it, converts it to base64, and adds it as a temporary message."""
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
is_url = self.is_url(file_path)
|
||||
if is_url:
|
||||
try:
|
||||
image_bytes, mime_type = self.download_image_from_url(file_path)
|
||||
original_size = len(image_bytes)
|
||||
cleaned_path = file_path
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Failed to download image from URL: {str(e)}")
|
||||
else:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
# Clean and construct full path
|
||||
cleaned_path = self.clean_path(file_path)
|
||||
full_path = f"{self.workspace_path}/{cleaned_path}"
|
||||
# Clean and construct full path
|
||||
cleaned_path = self.clean_path(file_path)
|
||||
full_path = f"{self.workspace_path}/{cleaned_path}"
|
||||
|
||||
# Check if file exists and get info
|
||||
try:
|
||||
file_info = self.sandbox.fs.get_file_info(full_path)
|
||||
if file_info.is_dir:
|
||||
return self.fail_response(f"Path '{cleaned_path}' is a directory, not an image file.")
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Image file not found at path: '{cleaned_path}'")
|
||||
# Check if file exists and get info
|
||||
try:
|
||||
file_info = self.sandbox.fs.get_file_info(full_path)
|
||||
if file_info.is_dir:
|
||||
return self.fail_response(f"Path '{cleaned_path}' is a directory, not an image file.")
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Image file not found at path: '{cleaned_path}'")
|
||||
|
||||
# Check file size
|
||||
if file_info.size > MAX_IMAGE_SIZE:
|
||||
return self.fail_response(f"Image file '{cleaned_path}' is too large ({file_info.size / (1024*1024):.2f}MB). Maximum size is {MAX_IMAGE_SIZE / (1024*1024)}MB.")
|
||||
# Check file size
|
||||
if file_info.size > MAX_IMAGE_SIZE:
|
||||
return self.fail_response(f"Image file '{cleaned_path}' is too large ({file_info.size / (1024*1024):.2f}MB). Maximum size is {MAX_IMAGE_SIZE / (1024*1024)}MB.")
|
||||
|
||||
# Read image file content
|
||||
try:
|
||||
image_bytes = self.sandbox.fs.download_file(full_path)
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Could not read image file: {cleaned_path}")
|
||||
# Read image file content
|
||||
try:
|
||||
image_bytes = self.sandbox.fs.download_file(full_path)
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Could not read image file: {cleaned_path}")
|
||||
|
||||
# Determine MIME type
|
||||
mime_type, _ = mimetypes.guess_type(full_path)
|
||||
if not mime_type or not mime_type.startswith('image/'):
|
||||
# Basic fallback based on extension if mimetypes fails
|
||||
ext = os.path.splitext(cleaned_path)[1].lower()
|
||||
if ext == '.jpg' or ext == '.jpeg': mime_type = 'image/jpeg'
|
||||
elif ext == '.png': mime_type = 'image/png'
|
||||
elif ext == '.gif': mime_type = 'image/gif'
|
||||
elif ext == '.webp': mime_type = 'image/webp'
|
||||
else:
|
||||
return self.fail_response(f"Unsupported or unknown image format for file: '{cleaned_path}'. Supported: JPG, PNG, GIF, WEBP.")
|
||||
# Determine MIME type
|
||||
mime_type, _ = mimetypes.guess_type(full_path)
|
||||
if not mime_type or not mime_type.startswith('image/'):
|
||||
# Basic fallback based on extension if mimetypes fails
|
||||
ext = os.path.splitext(cleaned_path)[1].lower()
|
||||
if ext == '.jpg' or ext == '.jpeg': mime_type = 'image/jpeg'
|
||||
elif ext == '.png': mime_type = 'image/png'
|
||||
elif ext == '.gif': mime_type = 'image/gif'
|
||||
elif ext == '.webp': mime_type = 'image/webp'
|
||||
else:
|
||||
return self.fail_response(f"Unsupported or unknown image format for file: '{cleaned_path}'. Supported: JPG, PNG, GIF, WEBP.")
|
||||
|
||||
original_size = file_info.size
|
||||
|
||||
|
||||
# Compress the image
|
||||
compressed_bytes, compressed_mime_type = self.compress_image(image_bytes, mime_type, cleaned_path)
|
||||
|
@ -186,7 +244,7 @@ class SandboxVisionTool(SandboxToolsBase):
|
|||
"mime_type": compressed_mime_type,
|
||||
"base64": base64_image,
|
||||
"file_path": cleaned_path, # Include path for context
|
||||
"original_size": file_info.size,
|
||||
"original_size": original_size,
|
||||
"compressed_size": len(compressed_bytes)
|
||||
}
|
||||
|
||||
|
@ -200,7 +258,7 @@ class SandboxVisionTool(SandboxToolsBase):
|
|||
)
|
||||
|
||||
# Inform the agent the image will be available next turn
|
||||
return self.success_response(f"Successfully loaded and compressed the image '{cleaned_path}' (reduced from {file_info.size / 1024:.1f}KB to {len(compressed_bytes) / 1024:.1f}KB).")
|
||||
return self.success_response(f"Successfully loaded and compressed the image '{cleaned_path}' (reduced from {original_size / 1024:.1f}KB to {len(compressed_bytes) / 1024:.1f}KB).")
|
||||
|
||||
except Exception as e:
|
||||
return self.fail_response(f"An unexpected error occurred while trying to see the image: {str(e)}")
|
|
@ -75,7 +75,7 @@ services:
|
|||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "uv", "run", "worker_health.py"]
|
||||
timeout: 10s
|
||||
timeout: 20s
|
||||
interval: 30s
|
||||
start_period: 40s
|
||||
|
||||
|
|
|
@ -276,7 +276,7 @@ async def get_usage_logs(client, user_id: str, page: int = 0, items_per_page: in
|
|||
|
||||
# Use fixed cutoff date: June 26, 2025 midnight UTC
|
||||
# Ignore all token counts before this date
|
||||
cutoff_date = datetime(2025, 6, 28, 18, 0, 0, tzinfo=timezone.utc)
|
||||
cutoff_date = datetime(2025, 6, 30, 9, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
start_of_month = max(start_of_month, cutoff_date)
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ async def main():
|
|||
await retry(lambda: redis.initialize_async())
|
||||
key = uuid.uuid4().hex
|
||||
run_agent_background.check_health.send(key)
|
||||
timeout = 5 # seconds
|
||||
timeout = 20 # seconds
|
||||
elapsed = 0
|
||||
while elapsed < timeout:
|
||||
if await redis.get(key) == "healthy":
|
||||
|
|
|
@ -31,8 +31,8 @@ import { useAgentStream } from '@/hooks/useAgentStream';
|
|||
import { threadErrorCodeMessages } from '@/lib/constants/errorCodeMessages';
|
||||
import { ThreadSkeleton } from '@/components/thread/content/ThreadSkeleton';
|
||||
import { useAgent } from '@/hooks/react-query/agents/use-agents';
|
||||
import { extractToolName } from '@/components/thread/tool-views/xml-parser';
|
||||
|
||||
// Extend the base Message type with the expected database fields
|
||||
interface ApiMessageType extends BaseApiMessageType {
|
||||
message_id?: string;
|
||||
thread_id?: string;
|
||||
|
@ -48,7 +48,6 @@ interface ApiMessageType extends BaseApiMessageType {
|
|||
};
|
||||
}
|
||||
|
||||
// Add a simple interface for streaming tool calls
|
||||
interface StreamingToolCall {
|
||||
id?: string;
|
||||
name?: string;
|
||||
|
@ -78,7 +77,6 @@ export default function ThreadPage({
|
|||
const [currentToolIndex, setCurrentToolIndex] = useState<number>(0);
|
||||
const [autoOpenedPanel, setAutoOpenedPanel] = useState(false);
|
||||
|
||||
// Playback control states
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
|
@ -194,7 +192,7 @@ export default function ThreadPage({
|
|||
(toolCall: StreamingToolCall | null) => {
|
||||
if (!toolCall) return;
|
||||
|
||||
// Normalize the tool name by replacing underscores with hyphens
|
||||
// Normalize the tool name like the project thread page does
|
||||
const rawToolName = toolCall.name || toolCall.xml_tag_name || 'Unknown Tool';
|
||||
const toolName = rawToolName.replace(/_/g, '-').toLowerCase();
|
||||
|
||||
|
@ -210,6 +208,7 @@ export default function ThreadPage({
|
|||
// Format the arguments in a way that matches the expected XML format for each tool
|
||||
// This ensures the specialized tool views render correctly
|
||||
let formattedContent = toolArguments;
|
||||
|
||||
if (
|
||||
toolName.includes('command') &&
|
||||
!toolArguments.includes('<execute-command>')
|
||||
|
@ -432,17 +431,10 @@ export default function ThreadPage({
|
|||
return assistantMsg.content;
|
||||
}
|
||||
})();
|
||||
|
||||
// Try to extract tool name from content
|
||||
const xmlMatch = assistantContent.match(
|
||||
/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/,
|
||||
);
|
||||
if (xmlMatch) {
|
||||
// Normalize tool name: replace underscores with hyphens and lowercase
|
||||
const rawToolName = xmlMatch[1] || xmlMatch[2] || 'unknown';
|
||||
toolName = rawToolName.replace(/_/g, '-').toLowerCase();
|
||||
const extractedToolName = extractToolName(assistantContent);
|
||||
if (extractedToolName) {
|
||||
toolName = extractedToolName;
|
||||
} else {
|
||||
// Fallback to checking for tool_calls JSON structure
|
||||
const assistantContentParsed = safeJsonParse<{
|
||||
tool_calls?: Array<{ function?: { name?: string }; name?: string }>;
|
||||
}>(assistantMsg.content, {});
|
||||
|
@ -452,7 +444,6 @@ export default function ThreadPage({
|
|||
) {
|
||||
const firstToolCall = assistantContentParsed.tool_calls[0];
|
||||
const rawName = firstToolCall.function?.name || firstToolCall.name || 'unknown';
|
||||
// Normalize tool name here too
|
||||
toolName = rawName.replace(/_/g, '-').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
@ -460,7 +451,6 @@ export default function ThreadPage({
|
|||
|
||||
let isSuccess = true;
|
||||
try {
|
||||
// Parse tool result content
|
||||
const toolResultContent = (() => {
|
||||
try {
|
||||
const parsed = safeJsonParse<{ content?: string }>(resultMessage.content, {});
|
||||
|
@ -469,15 +459,11 @@ export default function ThreadPage({
|
|||
return resultMessage.content;
|
||||
}
|
||||
})();
|
||||
|
||||
// Check for ToolResult pattern first
|
||||
if (toolResultContent && typeof toolResultContent === 'string') {
|
||||
// Look for ToolResult(success=True/False) pattern
|
||||
const toolResultMatch = toolResultContent.match(/ToolResult\s*\(\s*success\s*=\s*(True|False|true|false)/i);
|
||||
if (toolResultMatch) {
|
||||
isSuccess = toolResultMatch[1].toLowerCase() === 'true';
|
||||
} else {
|
||||
// Fallback: only check for error keywords if no ToolResult pattern found
|
||||
const toolContent = toolResultContent.toLowerCase();
|
||||
isSuccess = !(toolContent.includes('failed') ||
|
||||
toolContent.includes('error') ||
|
||||
|
@ -489,19 +475,17 @@ export default function ThreadPage({
|
|||
historicalToolPairs.push({
|
||||
assistantCall: {
|
||||
name: toolName,
|
||||
content: assistantMsg.content, // Store original content
|
||||
content: assistantMsg.content,
|
||||
timestamp: assistantMsg.created_at,
|
||||
},
|
||||
toolResult: {
|
||||
content: resultMessage.content, // Store original content
|
||||
content: resultMessage.content,
|
||||
isSuccess: isSuccess,
|
||||
timestamp: resultMessage.created_at,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort the tool calls chronologically by timestamp
|
||||
historicalToolPairs.sort((a, b) => {
|
||||
const timeA = new Date(a.assistantCall.timestamp || '').getTime();
|
||||
const timeB = new Date(b.assistantCall.timestamp || '').getTime();
|
||||
|
@ -509,8 +493,6 @@ export default function ThreadPage({
|
|||
});
|
||||
|
||||
setToolCalls(historicalToolPairs);
|
||||
|
||||
// When loading is complete, prepare for playback
|
||||
initialLoadCompleted.current = true;
|
||||
}
|
||||
} catch (err) {
|
||||
|
@ -525,9 +507,7 @@ export default function ThreadPage({
|
|||
if (isMounted) setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
|
@ -546,7 +526,6 @@ export default function ThreadPage({
|
|||
messagesEndRef.current?.scrollIntoView({ behavior });
|
||||
};
|
||||
|
||||
// Handle tool clicks
|
||||
const handleToolClick = useCallback(
|
||||
(clickedAssistantMessageId: string | null, clickedToolName: string) => {
|
||||
if (!clickedAssistantMessageId) {
|
||||
|
@ -557,7 +536,6 @@ export default function ThreadPage({
|
|||
return;
|
||||
}
|
||||
|
||||
// Reset user closed state when explicitly clicking a tool
|
||||
userClosedPanelRef.current = false;
|
||||
|
||||
console.log(
|
||||
|
@ -567,21 +545,16 @@ export default function ThreadPage({
|
|||
clickedToolName,
|
||||
);
|
||||
|
||||
// Find the index of the tool call associated with the clicked assistant message
|
||||
const toolIndex = toolCalls.findIndex((tc) => {
|
||||
// Check if the assistant message ID matches the one stored in the tool result's metadata
|
||||
if (!tc.toolResult?.content || tc.toolResult.content === 'STREAMING')
|
||||
return false; // Skip streaming or incomplete calls
|
||||
return false;
|
||||
|
||||
// Find the original assistant message based on the ID
|
||||
const assistantMessage = messages.find(
|
||||
(m) =>
|
||||
m.message_id === clickedAssistantMessageId &&
|
||||
m.type === 'assistant',
|
||||
);
|
||||
if (!assistantMessage) return false;
|
||||
|
||||
// Find the corresponding tool message using metadata
|
||||
const toolMessage = messages.find((m) => {
|
||||
if (m.type !== 'tool' || !m.metadata) return false;
|
||||
try {
|
||||
|
@ -593,9 +566,6 @@ export default function ThreadPage({
|
|||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if the current toolCall 'tc' corresponds to this assistant/tool message pair
|
||||
// Compare the original content directly without parsing
|
||||
return (
|
||||
tc.assistantCall?.content === assistantMessage.content &&
|
||||
tc.toolResult?.content === toolMessage?.content
|
||||
|
@ -608,7 +578,7 @@ export default function ThreadPage({
|
|||
);
|
||||
setExternalNavIndex(toolIndex);
|
||||
setCurrentToolIndex(toolIndex);
|
||||
setIsSidePanelOpen(true); // Explicitly open the panel
|
||||
setIsSidePanelOpen(true);
|
||||
|
||||
setTimeout(() => setExternalNavIndex(undefined), 100);
|
||||
} else {
|
||||
|
@ -630,7 +600,6 @@ export default function ThreadPage({
|
|||
setFileViewerOpen(true);
|
||||
}, []);
|
||||
|
||||
// Initialize PlaybackControls
|
||||
const playbackController: PlaybackController = PlaybackControls({
|
||||
messages,
|
||||
isSidePanelOpen,
|
||||
|
@ -641,7 +610,6 @@ export default function ThreadPage({
|
|||
projectName: projectName || 'Shared Conversation',
|
||||
});
|
||||
|
||||
// Extract the playback state and functions
|
||||
const {
|
||||
playbackState,
|
||||
renderHeader,
|
||||
|
@ -652,21 +620,17 @@ export default function ThreadPage({
|
|||
skipToEnd,
|
||||
} = playbackController;
|
||||
|
||||
// Connect playbackState to component state
|
||||
useEffect(() => {
|
||||
// Keep the isPlaying state in sync with playbackState
|
||||
setIsPlaying(playbackState.isPlaying);
|
||||
setCurrentMessageIndex(playbackState.currentMessageIndex);
|
||||
}, [playbackState.isPlaying, playbackState.currentMessageIndex]);
|
||||
|
||||
// Auto-scroll when new messages appear during playback
|
||||
useEffect(() => {
|
||||
if (playbackState.visibleMessages.length > 0 && !userHasScrolled) {
|
||||
scrollToBottom('smooth');
|
||||
}
|
||||
}, [playbackState.visibleMessages, userHasScrolled]);
|
||||
|
||||
// Scroll button visibility
|
||||
useEffect(() => {
|
||||
if (!latestMessageRef.current || playbackState.visibleMessages.length === 0)
|
||||
return;
|
||||
|
@ -683,7 +647,6 @@ export default function ThreadPage({
|
|||
`[PAGE] 🔄 Page AgentStatus: ${agentStatus}, Hook Status: ${streamHookStatus}, Target RunID: ${agentRunId || 'none'}, Hook RunID: ${currentHookRunId || 'none'}`,
|
||||
);
|
||||
|
||||
// If the stream hook reports completion/stopping but our UI hasn't updated
|
||||
if (
|
||||
(streamHookStatus === 'completed' ||
|
||||
streamHookStatus === 'stopped' ||
|
||||
|
@ -700,7 +663,6 @@ export default function ThreadPage({
|
|||
}
|
||||
}, [agentStatus, streamHookStatus, agentRunId, currentHookRunId]);
|
||||
|
||||
// Auto-scroll function for use throughout the component
|
||||
const autoScrollToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = 'smooth') => {
|
||||
if (!userHasScrolled && messagesEndRef.current) {
|
||||
|
@ -710,31 +672,21 @@ export default function ThreadPage({
|
|||
[userHasScrolled],
|
||||
);
|
||||
|
||||
// Very direct approach to update the tool index during message playback
|
||||
useEffect(() => {
|
||||
if (!isPlaying || currentMessageIndex <= 0 || !messages.length) return;
|
||||
|
||||
// Check if current message is a tool message
|
||||
const currentMsg = messages[currentMessageIndex - 1]; // Look at previous message that just played
|
||||
|
||||
const currentMsg = messages[currentMessageIndex - 1];
|
||||
if (currentMsg?.type === 'tool' && currentMsg.metadata) {
|
||||
try {
|
||||
const metadata = safeJsonParse<ParsedMetadata>(currentMsg.metadata, {});
|
||||
const assistantId = metadata.assistant_message_id;
|
||||
|
||||
if (assistantId) {
|
||||
// Find the tool call that matches this assistant message
|
||||
const toolIndex = toolCalls.findIndex((tc) => {
|
||||
// Find the assistant message
|
||||
const assistantMessage = messages.find(
|
||||
(m) => m.message_id === assistantId && m.type === 'assistant'
|
||||
);
|
||||
if (!assistantMessage) return false;
|
||||
|
||||
// Check if this tool call matches
|
||||
return tc.assistantCall?.content === assistantMessage.content;
|
||||
});
|
||||
|
||||
if (toolIndex !== -1) {
|
||||
console.log(
|
||||
`Direct mapping: Setting tool index to ${toolIndex} for message ${assistantId}`,
|
||||
|
@ -748,28 +700,19 @@ export default function ThreadPage({
|
|||
}
|
||||
}, [currentMessageIndex, isPlaying, messages, toolCalls]);
|
||||
|
||||
// Force an explicit update to the tool panel based on the current message index
|
||||
useEffect(() => {
|
||||
// Skip if not playing or no messages
|
||||
if (!isPlaying || messages.length === 0 || currentMessageIndex <= 0) return;
|
||||
|
||||
// Get all messages up to the current index
|
||||
const currentMessages = messages.slice(0, currentMessageIndex);
|
||||
|
||||
// Find the most recent tool message to determine which panel to show
|
||||
for (let i = currentMessages.length - 1; i >= 0; i--) {
|
||||
const msg = currentMessages[i];
|
||||
if (msg.type === 'tool' && msg.metadata) {
|
||||
try {
|
||||
const metadata = safeJsonParse<ParsedMetadata>(msg.metadata, {});
|
||||
const assistantId = metadata.assistant_message_id;
|
||||
|
||||
if (assistantId) {
|
||||
console.log(
|
||||
`Looking for tool panel for assistant message ${assistantId}`,
|
||||
);
|
||||
|
||||
// Scan for matching tool call
|
||||
for (let j = 0; j < toolCalls.length; j++) {
|
||||
const content = toolCalls[j].assistantCall?.content || '';
|
||||
if (content.includes(assistantId)) {
|
||||
|
@ -788,14 +731,12 @@ export default function ThreadPage({
|
|||
}
|
||||
}, [currentMessageIndex, isPlaying, messages, toolCalls]);
|
||||
|
||||
// Loading skeleton UI
|
||||
if (isLoading && !initialLoadCompleted.current) {
|
||||
return (
|
||||
<ThreadSkeleton isSidePanelOpen={isSidePanelOpen} showHeader={true} />
|
||||
);
|
||||
}
|
||||
|
||||
// Error state UI
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
|
|
|
@ -364,6 +364,12 @@ export function constructImageUrl(filePath: string, project?: { sandbox?: { sand
|
|||
}
|
||||
|
||||
const cleanPath = filePath.replace(/^['"](.*)['"]$/, '$1');
|
||||
|
||||
// Check if it's a URL first, before trying to construct sandbox paths
|
||||
if (cleanPath.startsWith('http')) {
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
const sandboxId = typeof project?.sandbox === 'string'
|
||||
? project.sandbox
|
||||
: project?.sandbox?.id;
|
||||
|
@ -390,10 +396,6 @@ export function constructImageUrl(filePath: string, project?: { sandbox?: { sand
|
|||
return fullUrl;
|
||||
}
|
||||
|
||||
if (cleanPath.startsWith('http')) {
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
console.warn('No sandbox URL or ID available, using path as-is:', cleanPath);
|
||||
return cleanPath;
|
||||
}
|
Loading…
Reference in New Issue