mirror of https://github.com/kortix-ai/suna.git
Compare commits
12 Commits
401faee76e
...
504ac13e18
Author | SHA1 | Date |
---|---|---|
|
504ac13e18 | |
|
6e864680b0 | |
|
8a55491775 | |
|
f8dc2f970c | |
|
8a96bb2ed4 | |
|
daf36ee154 | |
|
1abf7f0bfd | |
|
67a5bfa431 | |
|
524758d1dd | |
|
bcba33cdb0 | |
|
0abe7a629f | |
|
0eafcf6c9f |
|
@ -113,6 +113,53 @@ class ResponseProcessor:
|
|||
return format_for_yield(message_obj)
|
||||
return None
|
||||
|
||||
def _serialize_model_response(self, model_response) -> Dict[str, Any]:
|
||||
"""Convert a LiteLLM ModelResponse object to a JSON-serializable dictionary.
|
||||
|
||||
Args:
|
||||
model_response: The LiteLLM ModelResponse object
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the ModelResponse
|
||||
"""
|
||||
try:
|
||||
# Try to use the model_dump method if available (Pydantic v2)
|
||||
if hasattr(model_response, 'model_dump'):
|
||||
return model_response.model_dump()
|
||||
|
||||
# Try to use the dict method if available (Pydantic v1)
|
||||
elif hasattr(model_response, 'dict'):
|
||||
return model_response.dict()
|
||||
|
||||
# Fallback: manually extract common attributes
|
||||
else:
|
||||
result = {}
|
||||
|
||||
# Common LiteLLM ModelResponse attributes
|
||||
for attr in ['id', 'object', 'created', 'model', 'choices', 'usage', 'system_fingerprint']:
|
||||
if hasattr(model_response, attr):
|
||||
value = getattr(model_response, attr)
|
||||
# Recursively handle nested objects
|
||||
if hasattr(value, 'model_dump'):
|
||||
result[attr] = value.model_dump()
|
||||
elif hasattr(value, 'dict'):
|
||||
result[attr] = value.dict()
|
||||
elif isinstance(value, list):
|
||||
result[attr] = [
|
||||
item.model_dump() if hasattr(item, 'model_dump')
|
||||
else item.dict() if hasattr(item, 'dict')
|
||||
else item for item in value
|
||||
]
|
||||
else:
|
||||
result[attr] = value
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to serialize ModelResponse: {str(e)}, falling back to string representation")
|
||||
# Ultimate fallback: convert to string
|
||||
return {"raw_response": str(model_response), "serialization_error": str(e)}
|
||||
|
||||
async def _add_message_with_agent_info(
|
||||
self,
|
||||
thread_id: str,
|
||||
|
@ -1009,11 +1056,14 @@ class ResponseProcessor:
|
|||
# --- Save and Yield assistant_response_end ---
|
||||
if assistant_message_object: # Only save if assistant message was saved
|
||||
try:
|
||||
# Save the full LiteLLM response object directly in content
|
||||
# Convert LiteLLM ModelResponse to a JSON-serializable dictionary
|
||||
response_dict = self._serialize_model_response(llm_response)
|
||||
|
||||
# Save the serialized response object in content
|
||||
await self.add_message(
|
||||
thread_id=thread_id,
|
||||
type="assistant_response_end",
|
||||
content=llm_response,
|
||||
content=response_dict,
|
||||
is_llm_message=False,
|
||||
metadata={"thread_run_id": thread_run_id}
|
||||
)
|
||||
|
|
|
@ -20,7 +20,7 @@ You can modify the sandbox environment for development or to add new capabilitie
|
|||
```
|
||||
cd backend/sandbox/docker
|
||||
docker compose build
|
||||
docker push kortix/suna:0.1.3.9
|
||||
docker push kortix/suna:0.1.3.11
|
||||
```
|
||||
3. Test your changes locally using docker-compose
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ services:
|
|||
dockerfile: ${DOCKERFILE:-Dockerfile}
|
||||
args:
|
||||
TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64}
|
||||
image: kortix/suna:0.1.3.9
|
||||
image: kortix/suna:0.1.3.11
|
||||
ports:
|
||||
- "6080:6080" # noVNC web interface
|
||||
- "5901:5901" # VNC port
|
||||
|
|
|
@ -74,11 +74,11 @@ class PresentationToPDFAPI:
|
|||
|
||||
for slide_num, slide_data in slides.items():
|
||||
filename = slide_data.get('filename')
|
||||
file_path = slide_data.get('file_path')
|
||||
title = slide_data.get('title', f'Slide {slide_num}')
|
||||
|
||||
if filename:
|
||||
# Treat filename as absolute path only
|
||||
html_path = Path(filename)
|
||||
if file_path:
|
||||
html_path = Path(f"/workspace/{file_path}")
|
||||
print(f"Using path: {html_path}")
|
||||
|
||||
# Verify the path exists
|
||||
|
|
|
@ -6,4 +6,5 @@ pydantic==2.6.1
|
|||
pytesseract==0.3.13
|
||||
pandas==2.3.0
|
||||
playwright>=1.40.0
|
||||
PyPDF2>=3.0.0
|
||||
PyPDF2>=3.0.0
|
||||
bs4==0.0.2
|
|
@ -535,6 +535,7 @@ def inject_editor_functionality(html_content: str, file_path: str) -> str:
|
|||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.save-btn, .cancel-btn {
|
||||
|
|
|
@ -116,11 +116,20 @@ def get_model_pricing(model: str) -> tuple[float, float] | None:
|
|||
Returns:
|
||||
Tuple of (input_cost_per_million_tokens, output_cost_per_million_tokens) or None if not found
|
||||
"""
|
||||
# Use the model manager to get pricing
|
||||
pricing = model_manager.get_pricing(model)
|
||||
if pricing:
|
||||
return pricing.input_cost_per_million_tokens, pricing.output_cost_per_million_tokens
|
||||
# First try to resolve the model ID to handle aliases
|
||||
resolved_model = model_manager.resolve_model_id(model)
|
||||
logger.debug(f"Resolving model '{model}' -> '{resolved_model}'")
|
||||
|
||||
# Try the resolved model first, then fallback to original
|
||||
for model_to_try in [resolved_model, model]:
|
||||
model_obj = model_manager.get_model(model_to_try)
|
||||
if model_obj and model_obj.pricing:
|
||||
logger.debug(f"Found pricing for model {model_to_try}: input=${model_obj.pricing.input_cost_per_million_tokens}/M, output=${model_obj.pricing.output_cost_per_million_tokens}/M")
|
||||
return model_obj.pricing.input_cost_per_million_tokens, model_obj.pricing.output_cost_per_million_tokens
|
||||
else:
|
||||
logger.debug(f"No pricing for model_to_try='{model_to_try}' (model_obj: {model_obj is not None}, has_pricing: {model_obj.pricing is not None if model_obj else False})")
|
||||
|
||||
logger.warning(f"No pricing found for model '{model}' (resolved: '{resolved_model}')")
|
||||
return None
|
||||
|
||||
|
||||
|
@ -725,9 +734,12 @@ def calculate_token_cost(prompt_tokens: int, completion_tokens: int, model: str)
|
|||
prompt_tokens = int(prompt_tokens) if prompt_tokens is not None else 0
|
||||
completion_tokens = int(completion_tokens) if completion_tokens is not None else 0
|
||||
|
||||
logger.debug(f"Calculating token cost for model '{model}' with {prompt_tokens} input tokens and {completion_tokens} output tokens")
|
||||
|
||||
# Try to resolve the model name using new model manager first
|
||||
from models import model_manager
|
||||
resolved_model = model_manager.resolve_model_id(model)
|
||||
logger.debug(f"Model '{model}' resolved to '{resolved_model}'")
|
||||
|
||||
# Check if we have hardcoded pricing for this model (try both original and resolved)
|
||||
hardcoded_pricing = get_model_pricing(model) or get_model_pricing(resolved_model)
|
||||
|
|
|
@ -306,8 +306,8 @@ class Configuration:
|
|||
STRIPE_PRODUCT_ID_STAGING: str = 'prod_SCgIj3G7yPOAWY'
|
||||
|
||||
# Sandbox configuration
|
||||
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3.9"
|
||||
SANDBOX_SNAPSHOT_NAME = "kortix/suna:0.1.3.9"
|
||||
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3.11"
|
||||
SANDBOX_SNAPSHOT_NAME = "kortix/suna:0.1.3.11"
|
||||
SANDBOX_ENTRYPOINT = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf"
|
||||
|
||||
# LangFuse configuration
|
||||
|
|
|
@ -127,8 +127,8 @@ As part of the setup, you'll need to:
|
|||
1. Create a Daytona account
|
||||
2. Generate an API key
|
||||
3. Create a Snapshot:
|
||||
- Name: `kortix/suna:0.1.3.9`
|
||||
- Image name: `kortix/suna:0.1.3.9`
|
||||
- Name: `kortix/suna:0.1.3.11`
|
||||
- Image name: `kortix/suna:0.1.3.11`
|
||||
- Entrypoint: `/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf`
|
||||
|
||||
## Manual Configuration
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
SkipBack,
|
||||
SkipForward,
|
||||
Edit,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
@ -44,6 +45,7 @@ interface FullScreenPresentationViewerProps {
|
|||
presentationName?: string;
|
||||
sandboxUrl?: string;
|
||||
initialSlide?: number;
|
||||
onPDFDownload?: (setIsDownloadingPDF: (isDownloading: boolean) => void) => void;
|
||||
}
|
||||
|
||||
export function FullScreenPresentationViewer({
|
||||
|
@ -52,12 +54,15 @@ export function FullScreenPresentationViewer({
|
|||
presentationName,
|
||||
sandboxUrl,
|
||||
initialSlide = 1,
|
||||
onPDFDownload,
|
||||
}: FullScreenPresentationViewerProps) {
|
||||
const [metadata, setMetadata] = useState<PresentationMetadata | null>(null);
|
||||
const [currentSlide, setCurrentSlide] = useState(initialSlide);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [isDownloadingPDF, setIsDownloadingPDF] = useState(false);
|
||||
|
||||
// Create a stable refresh timestamp when metadata changes (like PresentationViewer)
|
||||
const refreshTimestamp = useMemo(() => Date.now(), [metadata]);
|
||||
|
@ -115,6 +120,24 @@ export function FullScreenPresentationViewer({
|
|||
}
|
||||
}, [isOpen, loadMetadata, initialSlide]);
|
||||
|
||||
// Reload metadata when exiting editor mode to refresh with latest changes
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
if (!showEditor) {
|
||||
// Add a small delay to allow the editor to save changes
|
||||
timeoutId = setTimeout(() => {
|
||||
loadMetadata();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [showEditor, loadMetadata]);
|
||||
|
||||
// Navigation functions
|
||||
const goToNextSlide = useCallback(() => {
|
||||
if (currentSlide < totalSlides) {
|
||||
|
@ -133,6 +156,7 @@ export function FullScreenPresentationViewer({
|
|||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
// Prevent default for all our handled keys
|
||||
const handledKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' ', 'Home', 'End', 'Escape'];
|
||||
if (handledKeys.includes(e.key)) {
|
||||
|
@ -157,7 +181,11 @@ export function FullScreenPresentationViewer({
|
|||
setCurrentSlide(totalSlides);
|
||||
break;
|
||||
case 'Escape':
|
||||
onClose();
|
||||
if (showEditor) {
|
||||
setShowEditor(false);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
@ -165,7 +193,7 @@ export function FullScreenPresentationViewer({
|
|||
// Add event listener to document with capture to ensure we get the events first
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [isOpen, goToNextSlide, goToPreviousSlide, totalSlides, onClose]);
|
||||
}, [isOpen, goToNextSlide, goToPreviousSlide, totalSlides, onClose, showEditor]);
|
||||
|
||||
|
||||
|
||||
|
@ -247,11 +275,11 @@ export function FullScreenPresentationViewer({
|
|||
}}
|
||||
>
|
||||
<iframe
|
||||
key={`slide-${slide.number}-${refreshTimestamp}`} // Key with stable timestamp ensures iframe refreshes when metadata changes
|
||||
src={slideUrlWithCacheBust}
|
||||
key={`slide-${slide.number}-${refreshTimestamp}-${showEditor}`} // Key with stable timestamp ensures iframe refreshes when metadata changes
|
||||
src={showEditor ? `${sandboxUrl}/api/html/${slide.file_path}/editor` : slideUrlWithCacheBust}
|
||||
title={`Slide ${slide.number}: ${slide.title}`}
|
||||
className="border-0 rounded-xl"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
sandbox="allow-same-origin allow-scripts allow-modals"
|
||||
style={{
|
||||
width: '1920px',
|
||||
height: '1080px',
|
||||
|
@ -261,7 +289,7 @@ export function FullScreenPresentationViewer({
|
|||
transformOrigin: '0 0',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
left: `calc((100% - ${1920 * scale}px) / 2)`,
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitBackfaceVisibility: 'hidden'
|
||||
|
@ -278,7 +306,7 @@ export function FullScreenPresentationViewer({
|
|||
|
||||
SlideIframeComponent.displayName = 'SlideIframeComponent';
|
||||
return SlideIframeComponent;
|
||||
}, [sandboxUrl, refreshTimestamp]);
|
||||
}, [sandboxUrl, refreshTimestamp, showEditor]);
|
||||
|
||||
// Render slide iframe with proper scaling
|
||||
const renderSlide = useMemo(() => {
|
||||
|
@ -317,9 +345,10 @@ export function FullScreenPresentationViewer({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
title="Edit presentation"
|
||||
title={showEditor ? "Close editor" : "Edit presentation"}
|
||||
onClick={() => setShowEditor(!showEditor)}
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
{showEditor ? <Presentation className="h-3.5 w-3.5" /> : <Edit className='h-3.5 w-3.5'/>}
|
||||
</Button>
|
||||
|
||||
{/* Export dropdown */}
|
||||
|
@ -331,11 +360,15 @@ export function FullScreenPresentationViewer({
|
|||
className="h-8 w-8 p-0"
|
||||
title="Export presentation"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{isDownloadingPDF ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={() => onPDFDownload(setIsDownloadingPDF)}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
PDF
|
||||
</DropdownMenuItem>
|
||||
|
|
|
@ -34,6 +34,7 @@ import { constructHtmlPreviewUrl } from '@/lib/utils/url';
|
|||
import { CodeBlockCode } from '@/components/ui/code-block';
|
||||
import { LoadingState } from '../shared/LoadingState';
|
||||
import { FullScreenPresentationViewer } from './FullScreenPresentationViewer';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface SlideMetadata {
|
||||
title: string;
|
||||
|
@ -75,6 +76,7 @@ export function PresentationViewer({
|
|||
const [visibleSlide, setVisibleSlide] = useState<number | null>(null);
|
||||
const [isFullScreenOpen, setIsFullScreenOpen] = useState(false);
|
||||
const [fullScreenInitialSlide, setFullScreenInitialSlide] = useState<number | null>(null);
|
||||
const [isDownloadingPDF, setIsDownloadingPDF] = useState(false);
|
||||
|
||||
// Extract presentation info from tool data
|
||||
const { toolResult } = extractToolData(toolContent);
|
||||
|
@ -423,8 +425,52 @@ export function PresentationViewer({
|
|||
return <SlideIframe slide={slide} />;
|
||||
}, [SlideIframe]);
|
||||
|
||||
const downloadPresentationAsPDF = async (sandboxUrl: string, presentationName: string): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(`${sandboxUrl}/presentation/convert-to-pdf`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
presentation_path: `/workspace/presentations/${presentationName}`,
|
||||
download: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download PDF');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${presentationName}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading PDF:', error);
|
||||
// You could show a toast notification here
|
||||
toast.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handlePDFDownload = useCallback(async (setIsDownloadingPDF: (isDownloading: boolean) => void) => {
|
||||
|
||||
if (!project?.sandbox?.sandbox_url || !extractedPresentationName) return;
|
||||
|
||||
setIsDownloadingPDF(true);
|
||||
try{
|
||||
await downloadPresentationAsPDF(project.sandbox.sandbox_url, extractedPresentationName);
|
||||
} catch (error) {
|
||||
console.error('Error downloading PDF:', error);
|
||||
} finally {
|
||||
setIsDownloadingPDF(false);
|
||||
}
|
||||
}, [project?.sandbox?.sandbox_url, extractedPresentationName]);
|
||||
|
||||
return (
|
||||
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
|
||||
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
|
||||
|
@ -465,15 +511,16 @@ export function PresentationViewer({
|
|||
className="h-8 w-8 p-0"
|
||||
title="Export presentation"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{isDownloadingPDF ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
// TODO: Implement PDF export
|
||||
console.log('Export as PDF');
|
||||
}}
|
||||
onClick={() => handlePDFDownload(setIsDownloadingPDF)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
|
@ -647,10 +694,15 @@ export function PresentationViewer({
|
|||
onClose={() => {
|
||||
setIsFullScreenOpen(false);
|
||||
setFullScreenInitialSlide(null);
|
||||
// Reload metadata after closing full screen viewer in case edits were made
|
||||
setTimeout(() => {
|
||||
loadMetadata();
|
||||
}, 300);
|
||||
}}
|
||||
presentationName={extractedPresentationName}
|
||||
sandboxUrl={project?.sandbox?.sandbox_url}
|
||||
initialSlide={fullScreenInitialSlide || visibleSlide || currentSlideNumber || slides[0]?.number || 1}
|
||||
onPDFDownload={handlePDFDownload}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
|
4
setup.py
4
setup.py
|
@ -669,8 +669,8 @@ class SetupWizard:
|
|||
f"Visit {Colors.GREEN}https://app.daytona.io/dashboard/snapshots{Colors.ENDC}{Colors.CYAN} to create a snapshot."
|
||||
)
|
||||
print_info("Create a snapshot with these exact settings:")
|
||||
print_info(f" - Name:\t\t{Colors.GREEN}kortix/suna:0.1.3.9{Colors.ENDC}")
|
||||
print_info(f" - Snapshot name:\t{Colors.GREEN}kortix/suna:0.1.3.9{Colors.ENDC}")
|
||||
print_info(f" - Name:\t\t{Colors.GREEN}kortix/suna:0.1.3.11{Colors.ENDC}")
|
||||
print_info(f" - Snapshot name:\t{Colors.GREEN}kortix/suna:0.1.3.11{Colors.ENDC}")
|
||||
print_info(
|
||||
f" - Entrypoint:\t{Colors.GREEN}/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf{Colors.ENDC}"
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue