mirror of https://github.com/kortix-ai/suna.git
Merge branch 'main' into versioning2
This commit is contained in:
commit
d180b143f7
|
@ -1038,9 +1038,9 @@ async def initiate_agent_with_files(
|
|||
if hasattr(sandbox, 'fs') and hasattr(sandbox.fs, 'upload_file'):
|
||||
import inspect
|
||||
if inspect.iscoroutinefunction(sandbox.fs.upload_file):
|
||||
await sandbox.fs.upload_file(target_path, content)
|
||||
await sandbox.fs.upload_file(content, target_path)
|
||||
else:
|
||||
sandbox.fs.upload_file(target_path, content)
|
||||
sandbox.fs.upload_file(content, target_path)
|
||||
logger.debug(f"Called sandbox.fs.upload_file for {target_path}")
|
||||
upload_successful = True
|
||||
else:
|
||||
|
|
|
@ -135,7 +135,7 @@ class SandboxFilesTool(SandboxToolsBase):
|
|||
self.sandbox.fs.create_folder(parent_dir, "755")
|
||||
|
||||
# Write the file content
|
||||
self.sandbox.fs.upload_file(full_path, file_contents.encode())
|
||||
self.sandbox.fs.upload_file(file_contents.encode(), full_path)
|
||||
self.sandbox.fs.set_file_permissions(full_path, permissions)
|
||||
|
||||
message = f"File '{file_path}' created successfully."
|
||||
|
@ -219,7 +219,7 @@ class SandboxFilesTool(SandboxToolsBase):
|
|||
|
||||
# Perform replacement
|
||||
new_content = content.replace(old_str, new_str)
|
||||
self.sandbox.fs.upload_file(full_path, new_content.encode())
|
||||
self.sandbox.fs.upload_file(new_content.encode(), full_path)
|
||||
|
||||
# Show snippet around the edit
|
||||
replacement_line = content.split(old_str)[0].count('\n')
|
||||
|
@ -294,7 +294,7 @@ class SandboxFilesTool(SandboxToolsBase):
|
|||
if not self._file_exists(full_path):
|
||||
return self.fail_response(f"File '{file_path}' does not exist. Use create_file to create a new file.")
|
||||
|
||||
self.sandbox.fs.upload_file(full_path, file_contents.encode())
|
||||
self.sandbox.fs.upload_file(file_contents.encode(), full_path)
|
||||
self.sandbox.fs.set_file_permissions(full_path, permissions)
|
||||
|
||||
message = f"File '{file_path}' completely rewritten successfully."
|
||||
|
|
|
@ -353,8 +353,8 @@ class SandboxWebSearchTool(SandboxToolsBase):
|
|||
logging.info(f"Saving content to file: {results_file_path}, size: {len(json_content)} bytes")
|
||||
|
||||
self.sandbox.fs.upload_file(
|
||||
json_content.encode(),
|
||||
results_file_path,
|
||||
json_content.encode()
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
@ -21,7 +21,7 @@ exa-py>=1.9.1
|
|||
e2b-code-interpreter>=1.2.0
|
||||
certifi==2024.2.2
|
||||
python-ripgrep==0.0.6
|
||||
daytona_sdk==0.14.0
|
||||
daytona-sdk==0.20.2
|
||||
boto3>=1.34.0
|
||||
openai>=1.72.0
|
||||
nest-asyncio>=1.6.0
|
||||
|
|
|
@ -15,6 +15,7 @@ from services import redis
|
|||
from dramatiq.brokers.rabbitmq import RabbitmqBroker
|
||||
import os
|
||||
from services.langfuse import langfuse
|
||||
from utils.retry import retry
|
||||
|
||||
rabbitmq_host = os.getenv('RABBITMQ_HOST', 'rabbitmq')
|
||||
rabbitmq_port = int(os.getenv('RABBITMQ_PORT', 5672))
|
||||
|
@ -28,18 +29,12 @@ instance_id = "single"
|
|||
async def initialize():
|
||||
"""Initialize the agent API with resources from the main API."""
|
||||
global db, instance_id, _initialized
|
||||
if _initialized:
|
||||
try: await redis.client.ping()
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis connection failed, re-initializing: {e}")
|
||||
await redis.initialize_async(force=True)
|
||||
return
|
||||
|
||||
# Use provided instance_id or generate a new one
|
||||
if not instance_id:
|
||||
# Generate instance ID
|
||||
instance_id = str(uuid.uuid4())[:8]
|
||||
await redis.initialize_async()
|
||||
await retry(lambda: redis.initialize_async())
|
||||
await db.initialize()
|
||||
|
||||
_initialized = True
|
||||
|
@ -68,6 +63,25 @@ async def run_agent_background(
|
|||
logger.critical(f"Failed to initialize Redis connection: {e}")
|
||||
raise e
|
||||
|
||||
# Idempotency check: prevent duplicate runs
|
||||
run_lock_key = f"agent_run_lock:{agent_run_id}"
|
||||
|
||||
# Try to acquire a lock for this agent run
|
||||
lock_acquired = await redis.set(run_lock_key, instance_id, nx=True, ex=redis.REDIS_KEY_TTL)
|
||||
|
||||
if not lock_acquired:
|
||||
# Check if the run is already being handled by another instance
|
||||
existing_instance = await redis.get(run_lock_key)
|
||||
if existing_instance:
|
||||
logger.info(f"Agent run {agent_run_id} is already being processed by instance {existing_instance.decode() if isinstance(existing_instance, bytes) else existing_instance}. Skipping duplicate execution.")
|
||||
return
|
||||
else:
|
||||
# Lock exists but no value, try to acquire again
|
||||
lock_acquired = await redis.set(run_lock_key, instance_id, nx=True, ex=redis.REDIS_KEY_TTL)
|
||||
if not lock_acquired:
|
||||
logger.info(f"Agent run {agent_run_id} is already being processed by another instance. Skipping duplicate execution.")
|
||||
return
|
||||
|
||||
sentry.sentry.set_tag("thread_id", thread_id)
|
||||
|
||||
logger.info(f"Starting background agent run: {agent_run_id} for thread: {thread_id} (Instance: {instance_id})")
|
||||
|
@ -117,7 +131,12 @@ async def run_agent_background(
|
|||
try:
|
||||
# Setup Pub/Sub listener for control signals
|
||||
pubsub = await redis.create_pubsub()
|
||||
await pubsub.subscribe(instance_control_channel, global_control_channel)
|
||||
try:
|
||||
await retry(lambda: pubsub.subscribe(instance_control_channel, global_control_channel))
|
||||
except Exception as e:
|
||||
logger.error(f"Redis failed to subscribe to control channels: {e}", exc_info=True)
|
||||
raise e
|
||||
|
||||
logger.debug(f"Subscribed to control channels: {instance_control_channel}, {global_control_channel}")
|
||||
stop_checker = asyncio.create_task(check_for_stop_signal())
|
||||
|
||||
|
@ -249,6 +268,9 @@ async def run_agent_background(
|
|||
# Remove the instance-specific active run key
|
||||
await _cleanup_redis_instance_key(agent_run_id)
|
||||
|
||||
# Clean up the run lock
|
||||
await _cleanup_redis_run_lock(agent_run_id)
|
||||
|
||||
# Wait for all pending redis operations to complete, with timeout
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*pending_redis_operations), timeout=30.0)
|
||||
|
@ -270,6 +292,16 @@ async def _cleanup_redis_instance_key(agent_run_id: str):
|
|||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up Redis key {key}: {str(e)}")
|
||||
|
||||
async def _cleanup_redis_run_lock(agent_run_id: str):
|
||||
"""Clean up the run lock Redis key for an agent run."""
|
||||
run_lock_key = f"agent_run_lock:{agent_run_id}"
|
||||
logger.debug(f"Cleaning up Redis run lock key: {run_lock_key}")
|
||||
try:
|
||||
await redis.delete(run_lock_key)
|
||||
logger.debug(f"Successfully cleaned up Redis run lock key: {run_lock_key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up Redis run lock key {run_lock_key}: {str(e)}")
|
||||
|
||||
# TTL for Redis response lists (24 hours)
|
||||
REDIS_RESPONSE_LIST_TTL = 3600 * 24
|
||||
|
||||
|
|
|
@ -166,7 +166,7 @@ async def create_file(
|
|||
content = await file.read()
|
||||
|
||||
# Create file using raw binary content
|
||||
sandbox.fs.upload_file(path, content)
|
||||
sandbox.fs.upload_file(content, path)
|
||||
logger.info(f"File created at {path} in sandbox {sandbox_id}")
|
||||
|
||||
return {"status": "success", "created": True, "path": path}
|
||||
|
|
|
@ -113,7 +113,8 @@ def create_sandbox(password: str, project_id: str = None):
|
|||
"memory": 4,
|
||||
"disk": 5,
|
||||
},
|
||||
auto_stop_interval=24 * 60
|
||||
auto_stop_interval=15,
|
||||
auto_archive_interval=24 * 60,
|
||||
)
|
||||
|
||||
# Create the sandbox
|
||||
|
|
|
@ -4,6 +4,7 @@ from dotenv import load_dotenv
|
|||
import asyncio
|
||||
from utils.logger import logger
|
||||
from typing import List, Any
|
||||
from utils.retry import retry
|
||||
|
||||
# Redis client
|
||||
client: redis.Redis | None = None
|
||||
|
@ -22,12 +23,12 @@ def initialize():
|
|||
load_dotenv()
|
||||
|
||||
# Get Redis configuration
|
||||
redis_host = os.getenv('REDIS_HOST', 'redis')
|
||||
redis_port = int(os.getenv('REDIS_PORT', 6379))
|
||||
redis_password = os.getenv('REDIS_PASSWORD', '')
|
||||
redis_host = os.getenv("REDIS_HOST", "redis")
|
||||
redis_port = int(os.getenv("REDIS_PORT", 6379))
|
||||
redis_password = os.getenv("REDIS_PASSWORD", "")
|
||||
# Convert string 'True'/'False' to boolean
|
||||
redis_ssl_str = os.getenv('REDIS_SSL', 'False')
|
||||
redis_ssl = redis_ssl_str.lower() == 'true'
|
||||
redis_ssl_str = os.getenv("REDIS_SSL", "False")
|
||||
redis_ssl = redis_ssl_str.lower() == "true"
|
||||
|
||||
logger.info(f"Initializing Redis connection to {redis_host}:{redis_port}")
|
||||
|
||||
|
@ -41,37 +42,30 @@ def initialize():
|
|||
socket_timeout=5.0,
|
||||
socket_connect_timeout=5.0,
|
||||
retry_on_timeout=True,
|
||||
health_check_interval=30
|
||||
health_check_interval=30,
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
async def initialize_async(force: bool = False):
|
||||
async def initialize_async():
|
||||
"""Initialize Redis connection asynchronously."""
|
||||
global client, _initialized
|
||||
|
||||
async with _init_lock:
|
||||
if _initialized and force:
|
||||
logger.info("Redis connection already initialized, closing and re-initializing")
|
||||
_initialized = False
|
||||
try:
|
||||
await close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to close Redis connection, proceeding with re-initialization anyway: {e}")
|
||||
|
||||
if not _initialized:
|
||||
logger.info("Initializing Redis connection")
|
||||
initialize()
|
||||
|
||||
try:
|
||||
await client.ping()
|
||||
logger.info("Successfully connected to Redis")
|
||||
_initialized = True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis: {e}")
|
||||
client = None
|
||||
raise
|
||||
try:
|
||||
await client.ping()
|
||||
logger.info("Successfully connected to Redis")
|
||||
_initialized = True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis: {e}")
|
||||
client = None
|
||||
_initialized = False
|
||||
raise
|
||||
|
||||
return client
|
||||
|
||||
|
@ -91,15 +85,15 @@ async def get_client():
|
|||
"""Get the Redis client, initializing if necessary."""
|
||||
global client, _initialized
|
||||
if client is None or not _initialized:
|
||||
await initialize_async()
|
||||
await retry(lambda: initialize_async())
|
||||
return client
|
||||
|
||||
|
||||
# Basic Redis operations
|
||||
async def set(key: str, value: str, ex: int = None):
|
||||
async def set(key: str, value: str, ex: int = None, nx: bool = False):
|
||||
"""Set a Redis key."""
|
||||
redis_client = await get_client()
|
||||
return await redis_client.set(key, value, ex=ex)
|
||||
return await redis_client.set(key, value, ex=ex, nx=nx)
|
||||
|
||||
|
||||
async def get(key: str, default: str = None):
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
import asyncio
|
||||
from typing import TypeVar, Callable, Awaitable, Optional
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
async def retry(
|
||||
fn: Callable[[], Awaitable[T]],
|
||||
max_attempts: int = 3,
|
||||
delay_seconds: int = 1,
|
||||
) -> T:
|
||||
"""
|
||||
Retry an async function with exponential backoff.
|
||||
|
||||
Args:
|
||||
fn: The async function to retry
|
||||
max_attempts: Maximum number of attempts
|
||||
delay_seconds: Delay between attempts in seconds
|
||||
|
||||
Returns:
|
||||
The result of the function call
|
||||
|
||||
Raises:
|
||||
The last exception if all attempts fail
|
||||
|
||||
Example:
|
||||
```python
|
||||
async def fetch_data():
|
||||
# Some operation that might fail
|
||||
return await api_call()
|
||||
|
||||
try:
|
||||
result = await retry(fetch_data, max_attempts=3, delay_seconds=2)
|
||||
print(f"Success: {result}")
|
||||
except Exception as e:
|
||||
print(f"Failed after all retries: {e}")
|
||||
```
|
||||
"""
|
||||
if max_attempts <= 0:
|
||||
raise ValueError("max_attempts must be greater than zero")
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
return await fn()
|
||||
except Exception as error:
|
||||
last_error = error
|
||||
|
||||
if attempt == max_attempts:
|
||||
break
|
||||
|
||||
await asyncio.sleep(delay_seconds)
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
raise RuntimeError("Unexpected: last_error is None")
|
|
@ -18,6 +18,7 @@ import { Check, Clock } from 'lucide-react';
|
|||
import { BillingError } from '@/lib/api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { agentKeys } from '@/hooks/react-query/agents/keys';
|
||||
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
|
||||
|
||||
interface AgentBuilderChatProps {
|
||||
agentId: string;
|
||||
|
@ -177,7 +178,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
|
|||
|
||||
const handleStreamError = useCallback((errorMessage: string) => {
|
||||
if (!errorMessage.toLowerCase().includes('not found') &&
|
||||
!errorMessage.toLowerCase().includes('agent run is not running')) {
|
||||
!errorMessage.toLowerCase().includes('agent run is not running')) {
|
||||
toast.error(`Stream Error: ${errorMessage}`);
|
||||
}
|
||||
}, []);
|
||||
|
@ -236,7 +237,8 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
|
|||
agentFormData.append('target_agent_id', agentId);
|
||||
|
||||
files.forEach((file) => {
|
||||
agentFormData.append('files', file, file.name);
|
||||
const normalizedName = normalizeFilenameToNFC(file.name);
|
||||
agentFormData.append('files', file, normalizedName);
|
||||
});
|
||||
|
||||
if (options?.model_name) agentFormData.append('model_name', options.model_name);
|
||||
|
@ -363,7 +365,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
|
|||
}, [stopStreaming, agentRunId, stopAgentMutation]);
|
||||
|
||||
|
||||
const handleOpenFileViewer = useCallback(() => {}, []);
|
||||
const handleOpenFileViewer = useCallback(() => { }, []);
|
||||
|
||||
|
||||
return (
|
||||
|
@ -375,7 +377,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
|
|||
streamingTextContent={streamingTextContent}
|
||||
streamingToolCall={streamingToolCall}
|
||||
agentStatus={agentStatus}
|
||||
handleToolClick={() => {}}
|
||||
handleToolClick={() => { }}
|
||||
handleOpenFileViewer={handleOpenFileViewer}
|
||||
streamHookStatus={streamHookStatus}
|
||||
agentName="Agent Builder"
|
||||
|
@ -395,19 +397,19 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
|
|||
|
||||
<div className="flex-shrink-0 md:pb-4 md:px-12 px-4">
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={threadId ? handleSubmitMessage : handleSubmitFirstMessage}
|
||||
loading={isSubmitting}
|
||||
placeholder="Tell me how you'd like to configure your agent..."
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
disabled={isSubmitting}
|
||||
isAgentRunning={agentStatus === 'running' || agentStatus === 'connecting'}
|
||||
onStopAgent={handleStopAgent}
|
||||
agentName="Agent Builder"
|
||||
hideAttachments={true}
|
||||
bgColor='bg-muted-foreground/10'
|
||||
/>
|
||||
ref={chatInputRef}
|
||||
onSubmit={threadId ? handleSubmitMessage : handleSubmitFirstMessage}
|
||||
loading={isSubmitting}
|
||||
placeholder="Tell me how you'd like to configure your agent..."
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
disabled={isSubmitting}
|
||||
isAgentRunning={agentStatus === 'running' || agentStatus === 'connecting'}
|
||||
onStopAgent={handleStopAgent}
|
||||
agentName="Agent Builder"
|
||||
hideAttachments={true}
|
||||
bgColor='bg-muted-foreground/10'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -14,6 +14,7 @@ import { useAgentStream } from '@/hooks/useAgentStream';
|
|||
import { useAddUserMessageMutation } from '@/hooks/react-query/threads/use-messages';
|
||||
import { useStartAgentMutation, useStopAgentMutation } from '@/hooks/react-query/threads/use-agent-run';
|
||||
import { BillingError } from '@/lib/api';
|
||||
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
|
||||
|
||||
interface Agent {
|
||||
agent_id: string;
|
||||
|
@ -106,7 +107,7 @@ export const AgentPreview = ({ agent }: AgentPreviewProps) => {
|
|||
const handleStreamError = useCallback((errorMessage: string) => {
|
||||
console.error(`[PREVIEW] Stream error: ${errorMessage}`);
|
||||
if (!errorMessage.toLowerCase().includes('not found') &&
|
||||
!errorMessage.toLowerCase().includes('agent run is not running')) {
|
||||
!errorMessage.toLowerCase().includes('agent run is not running')) {
|
||||
toast.error(`Stream Error: ${errorMessage}`);
|
||||
}
|
||||
}, []);
|
||||
|
@ -182,7 +183,8 @@ export const AgentPreview = ({ agent }: AgentPreviewProps) => {
|
|||
formData.append('agent_id', agent.agent_id);
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append('files', file, file.name);
|
||||
const normalizedName = normalizeFilenameToNFC(file.name);
|
||||
formData.append('files', file, normalizedName);
|
||||
});
|
||||
|
||||
if (options?.model_name) formData.append('model_name', options.model_name);
|
||||
|
@ -347,7 +349,7 @@ export const AgentPreview = ({ agent }: AgentPreviewProps) => {
|
|||
streamingToolCall={streamingToolCall}
|
||||
agentStatus={agentStatus}
|
||||
handleToolClick={handleToolClick}
|
||||
handleOpenFileViewer={() => {}}
|
||||
handleOpenFileViewer={() => { }}
|
||||
streamHookStatus={streamHookStatus}
|
||||
isPreviewMode={true}
|
||||
agentName={agent.name}
|
||||
|
|
|
@ -30,6 +30,7 @@ import { cn } from '@/lib/utils';
|
|||
import { useModal } from '@/hooks/use-modal-store';
|
||||
import { Examples } from './suggestions/examples';
|
||||
import { useThreadQuery } from '@/hooks/react-query/threads/use-threads';
|
||||
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
|
||||
|
||||
const PENDING_PROMPT_KEY = 'pendingAgentPrompt';
|
||||
|
||||
|
@ -110,7 +111,8 @@ export function DashboardContent() {
|
|||
}
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append('files', file, file.name);
|
||||
const normalizedName = normalizeFilenameToNFC(file.name);
|
||||
formData.append('files', file, normalizedName);
|
||||
});
|
||||
|
||||
if (options?.model_name) formData.append('model_name', options.model_name);
|
||||
|
|
|
@ -252,12 +252,12 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
|
||||
<CardContent className={`w-full p-1.5 pb-2 ${bgColor} rounded-2xl border`}>
|
||||
<AttachmentGroup
|
||||
files={uploadedFiles || []}
|
||||
sandboxId={sandboxId}
|
||||
onRemove={removeUploadedFile}
|
||||
layout="inline"
|
||||
maxHeight="216px"
|
||||
showPreviews={true}
|
||||
files={uploadedFiles || []}
|
||||
sandboxId={sandboxId}
|
||||
onRemove={removeUploadedFile}
|
||||
layout="inline"
|
||||
maxHeight="216px"
|
||||
showPreviews={true}
|
||||
/>
|
||||
<MessageInput
|
||||
ref={textareaRef}
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { UploadedFile } from './chat-input';
|
||||
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
||||
|
@ -32,17 +33,23 @@ const handleLocalFiles = (
|
|||
|
||||
setPendingFiles((prevFiles) => [...prevFiles, ...filteredFiles]);
|
||||
|
||||
const newUploadedFiles: UploadedFile[] = filteredFiles.map((file) => ({
|
||||
name: file.name,
|
||||
path: `/workspace/${file.name}`,
|
||||
size: file.size,
|
||||
type: file.type || 'application/octet-stream',
|
||||
localUrl: URL.createObjectURL(file)
|
||||
}));
|
||||
const newUploadedFiles: UploadedFile[] = filteredFiles.map((file) => {
|
||||
// Normalize filename to NFC
|
||||
const normalizedName = normalizeFilenameToNFC(file.name);
|
||||
|
||||
return {
|
||||
name: normalizedName,
|
||||
path: `/workspace/${normalizedName}`,
|
||||
size: file.size,
|
||||
type: file.type || 'application/octet-stream',
|
||||
localUrl: URL.createObjectURL(file)
|
||||
};
|
||||
});
|
||||
|
||||
setUploadedFiles((prev) => [...prev, ...newUploadedFiles]);
|
||||
filteredFiles.forEach((file) => {
|
||||
toast.success(`File attached: ${file.name}`);
|
||||
const normalizedName = normalizeFilenameToNFC(file.name);
|
||||
toast.success(`File attached: ${normalizedName}`);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -65,7 +72,9 @@ const uploadFiles = async (
|
|||
continue;
|
||||
}
|
||||
|
||||
const uploadPath = `/workspace/${file.name}`;
|
||||
// Normalize filename to NFC
|
||||
const normalizedName = normalizeFilenameToNFC(file.name);
|
||||
const uploadPath = `/workspace/${normalizedName}`;
|
||||
|
||||
// Check if this filename already exists in chat messages
|
||||
const isFileInChat = messages.some(message => {
|
||||
|
@ -74,7 +83,9 @@ const uploadFiles = async (
|
|||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// If the filename was normalized, append with the normalized name in the field name
|
||||
// The server will use the path parameter for the actual filename
|
||||
formData.append('file', file, normalizedName);
|
||||
formData.append('path', uploadPath);
|
||||
|
||||
const supabase = createClient();
|
||||
|
@ -116,13 +127,13 @@ const uploadFiles = async (
|
|||
}
|
||||
|
||||
newUploadedFiles.push({
|
||||
name: file.name,
|
||||
name: normalizedName,
|
||||
path: uploadPath,
|
||||
size: file.size,
|
||||
type: file.type || 'application/octet-stream',
|
||||
});
|
||||
|
||||
toast.success(`File uploaded: ${file.name}`);
|
||||
toast.success(`File uploaded: ${normalizedName}`);
|
||||
}
|
||||
|
||||
setUploadedFiles((prev) => [...prev, ...newUploadedFiles]);
|
||||
|
|
|
@ -112,8 +112,9 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-auto gap-4 justify-between">
|
||||
<div className="flex gap-2 items-center px-2">
|
||||
<div className="relative flex flex-col w-full h-auto gap-4 justify-between">
|
||||
|
||||
<div className="flex flex-col gap-2 items-center px-2">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
|
@ -129,6 +130,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between mt-1 ml-3 mb-1 pr-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{!hideAttachments && (
|
||||
|
@ -155,11 +157,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className='text-sm text-amber-500 hidden sm:block'>Upgrade for full performance</p>
|
||||
<div className='sm:hidden absolute bottom-0 left-0 right-0 flex justify-center'>
|
||||
<p className='text-xs text-amber-500 px-2 py-1'>
|
||||
Upgrade for better performance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>The free tier is severely limited by inferior models; upgrade to experience the true full Suna experience.</p>
|
||||
|
@ -205,6 +203,13 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{subscriptionStatus === 'no_subscription' && !isLocalMode() &&
|
||||
<div className='sm:hidden absolute -bottom-8 left-0 right-0 flex justify-center'>
|
||||
<p className='text-xs text-amber-500 px-2 py-1'>
|
||||
Upgrade for better performance
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -51,6 +51,7 @@ import {
|
|||
FileCache
|
||||
} from '@/hooks/react-query/files';
|
||||
import JSZip from 'jszip';
|
||||
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
|
||||
|
||||
// Define API_URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
@ -1121,6 +1122,8 @@ export function FileViewerModal({
|
|||
}
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// Process uploaded file - Define after helpers
|
||||
const processUpload = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
@ -1130,9 +1133,15 @@ export function FileViewerModal({
|
|||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
// Normalize filename to NFC
|
||||
const normalizedName = normalizeFilenameToNFC(file.name);
|
||||
const uploadPath = `${currentPath}/${normalizedName}`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', `${currentPath}/${file.name}`);
|
||||
// If the filename was normalized, append with the normalized name in the field name
|
||||
// The server will use the path parameter for the actual filename
|
||||
formData.append('file', file, normalizedName);
|
||||
formData.append('path', uploadPath);
|
||||
|
||||
const supabase = createClient();
|
||||
const {
|
||||
|
@ -1162,7 +1171,7 @@ export function FileViewerModal({
|
|||
// Reload the file list using React Query
|
||||
await refetchFiles();
|
||||
|
||||
toast.success(`Uploaded: ${file.name}`);
|
||||
toast.success(`Uploaded: ${normalizedName}`);
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
toast.error(
|
||||
|
|
|
@ -3,7 +3,6 @@ import { useAuth } from '@/components/AuthProvider';
|
|||
import { fileQueryKeys } from './use-file-queries';
|
||||
import { FileCache } from '@/hooks/use-cached-file';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Import the normalizePath function from use-file-queries
|
||||
function normalizePath(path: string): string {
|
||||
if (!path) return '/';
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Normalize filename to NFC (Normalized Form Composed) to ensure consistent
|
||||
* Unicode representation across different systems, especially macOS which
|
||||
* can use NFD (Normalized Form Decomposed).
|
||||
*
|
||||
* @param filename The filename to normalize
|
||||
* @returns The filename normalized to NFC form
|
||||
*/
|
||||
export const normalizeFilenameToNFC = (filename: string): string => {
|
||||
try {
|
||||
// Normalize to NFC (Normalized Form Composed)
|
||||
return filename.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize filename to NFC:', filename, error);
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize file path to NFC (Normalized Form Composed) to ensure consistent
|
||||
* Unicode representation across different systems.
|
||||
*
|
||||
* @param path The file path to normalize
|
||||
* @returns The path with all components normalized to NFC form
|
||||
*/
|
||||
export const normalizePathToNFC = (path: string): string => {
|
||||
try {
|
||||
// Normalize to NFC (Normalized Form Composed)
|
||||
return path.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize path to NFC:', path, error);
|
||||
return path;
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue