Merge pull request #719 from kubet/fix/filename-conversion

fix: filename conversion
This commit is contained in:
kubet 2025-06-11 20:22:32 +02:00 committed by GitHub
commit e93bdab2dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 122 additions and 63 deletions

View File

@ -18,6 +18,7 @@ import { Check, Clock } from 'lucide-react';
import { BillingError } from '@/lib/api'; import { BillingError } from '@/lib/api';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { agentKeys } from '@/hooks/react-query/agents/keys'; import { agentKeys } from '@/hooks/react-query/agents/keys';
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
interface AgentBuilderChatProps { interface AgentBuilderChatProps {
agentId: string; agentId: string;
@ -27,8 +28,8 @@ interface AgentBuilderChatProps {
currentStyle: { avatar: string; color: string }; currentStyle: { avatar: string; color: string };
} }
export const AgentBuilderChat = React.memo(function AgentBuilderChat({ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
agentId, agentId,
formData, formData,
handleFieldChange, handleFieldChange,
handleStyleChange, handleStyleChange,
@ -42,7 +43,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandles>(null); const chatInputRef = useRef<ChatInputHandles>(null);
const previousMessageCountRef = useRef(0); const previousMessageCountRef = useRef(0);
@ -114,7 +115,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
setThreadId(thread_id); setThreadId(thread_id);
} }
} }
hasInitiallyLoadedRef.current = true; hasInitiallyLoadedRef.current = true;
} else if (chatHistoryQuery.status === 'error') { } else if (chatHistoryQuery.status === 'error') {
console.error('[AgentBuilderChat] Error loading chat history:', chatHistoryQuery.error); console.error('[AgentBuilderChat] Error loading chat history:', chatHistoryQuery.error);
@ -125,14 +126,14 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
const handleNewMessageFromStream = useCallback((message: UnifiedMessage) => { const handleNewMessageFromStream = useCallback((message: UnifiedMessage) => {
setMessages((prev) => { setMessages((prev) => {
if (!prev) prev = []; if (!prev) prev = [];
if (message.type === 'user') { if (message.type === 'user') {
const optimisticIndex = prev.findIndex(m => const optimisticIndex = prev.findIndex(m =>
m.message_id.startsWith('temp-user-') && m.message_id.startsWith('temp-user-') &&
m.content === message.content && m.content === message.content &&
m.type === 'user' m.type === 'user'
); );
if (optimisticIndex !== -1) { if (optimisticIndex !== -1) {
console.log(`[AGENT BUILDER] Replacing optimistic message with real message`); console.log(`[AGENT BUILDER] Replacing optimistic message with real message`);
const newMessages = [...prev]; const newMessages = [...prev];
@ -176,8 +177,8 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
}, []); }, []);
const handleStreamError = useCallback((errorMessage: string) => { const handleStreamError = useCallback((errorMessage: string) => {
if (!errorMessage.toLowerCase().includes('not found') && 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}`); toast.error(`Stream Error: ${errorMessage}`);
} }
}, []); }, []);
@ -229,14 +230,15 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
try { try {
const files = chatInputRef.current?.getPendingFiles() || []; const files = chatInputRef.current?.getPendingFiles() || [];
const agentFormData = new FormData(); const agentFormData = new FormData();
agentFormData.append('prompt', message); agentFormData.append('prompt', message);
agentFormData.append('is_agent_builder', String(true)); agentFormData.append('is_agent_builder', String(true));
agentFormData.append('target_agent_id', agentId); agentFormData.append('target_agent_id', agentId);
files.forEach((file) => { 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); if (options?.model_name) agentFormData.append('model_name', options.model_name);
@ -253,7 +255,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
console.log('[AGENT BUILDER] Setting agent run ID:', result.agent_run_id); console.log('[AGENT BUILDER] Setting agent run ID:', result.agent_run_id);
setAgentRunId(result.agent_run_id); setAgentRunId(result.agent_run_id);
} }
const userMessage: UnifiedMessage = { const userMessage: UnifiedMessage = {
message_id: `user-${Date.now()}`, message_id: `user-${Date.now()}`,
thread_id: result.thread_id, thread_id: result.thread_id,
@ -336,8 +338,8 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
setAgentRunId(agentResult.agent_run_id); setAgentRunId(agentResult.agent_run_id);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Operation failed'); toast.error(err instanceof Error ? err.message : 'Operation failed');
setMessages((prev) => prev.map((m) => setMessages((prev) => prev.map((m) =>
m.message_id === optimisticUserMessage.message_id m.message_id === optimisticUserMessage.message_id
? { ...m, message_id: `user-error-${Date.now()}` } ? { ...m, message_id: `user-error-${Date.now()}` }
: m : m
)); ));
@ -363,7 +365,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
}, [stopStreaming, agentRunId, stopAgentMutation]); }, [stopStreaming, agentRunId, stopAgentMutation]);
const handleOpenFileViewer = useCallback(() => {}, []); const handleOpenFileViewer = useCallback(() => { }, []);
return ( return (
@ -375,7 +377,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
streamingTextContent={streamingTextContent} streamingTextContent={streamingTextContent}
streamingToolCall={streamingToolCall} streamingToolCall={streamingToolCall}
agentStatus={agentStatus} agentStatus={agentStatus}
handleToolClick={() => {}} handleToolClick={() => { }}
handleOpenFileViewer={handleOpenFileViewer} handleOpenFileViewer={handleOpenFileViewer}
streamHookStatus={streamHookStatus} streamHookStatus={streamHookStatus}
agentName="Agent Builder" 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"> <div className="flex-shrink-0 md:pb-4 md:px-12 px-4">
<ChatInput <ChatInput
ref={chatInputRef} ref={chatInputRef}
onSubmit={threadId ? handleSubmitMessage : handleSubmitFirstMessage} onSubmit={threadId ? handleSubmitMessage : handleSubmitFirstMessage}
loading={isSubmitting} loading={isSubmitting}
placeholder="Tell me how you'd like to configure your agent..." placeholder="Tell me how you'd like to configure your agent..."
value={inputValue} value={inputValue}
onChange={setInputValue} onChange={setInputValue}
disabled={isSubmitting} disabled={isSubmitting}
isAgentRunning={agentStatus === 'running' || agentStatus === 'connecting'} isAgentRunning={agentStatus === 'running' || agentStatus === 'connecting'}
onStopAgent={handleStopAgent} onStopAgent={handleStopAgent}
agentName="Agent Builder" agentName="Agent Builder"
hideAttachments={true} hideAttachments={true}
bgColor='bg-muted-foreground/10' bgColor='bg-muted-foreground/10'
/> />
</div> </div>
</div> </div>
); );

View File

@ -3,9 +3,9 @@ import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getAgentAvatar } from '../_utils/get-agent-style'; import { getAgentAvatar } from '../_utils/get-agent-style';
import { import {
ChatInput, ChatInput,
ChatInputHandles ChatInputHandles
} from '@/components/thread/chat-input/chat-input'; } from '@/components/thread/chat-input/chat-input';
import { ThreadContent } from '@/components/thread/content/ThreadContent'; import { ThreadContent } from '@/components/thread/content/ThreadContent';
import { UnifiedMessage } from '@/components/thread/types'; import { UnifiedMessage } from '@/components/thread/types';
@ -14,6 +14,7 @@ import { useAgentStream } from '@/hooks/useAgentStream';
import { useAddUserMessageMutation } from '@/hooks/react-query/threads/use-messages'; import { useAddUserMessageMutation } from '@/hooks/react-query/threads/use-messages';
import { useStartAgentMutation, useStopAgentMutation } from '@/hooks/react-query/threads/use-agent-run'; import { useStartAgentMutation, useStopAgentMutation } from '@/hooks/react-query/threads/use-agent-run';
import { BillingError } from '@/lib/api'; import { BillingError } from '@/lib/api';
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
interface Agent { interface Agent {
agent_id: string; agent_id: string;
@ -39,7 +40,7 @@ export const AgentPreview = ({ agent }: AgentPreviewProps) => {
const [agentStatus, setAgentStatus] = useState<'idle' | 'running' | 'connecting' | 'error'>('idle'); const [agentStatus, setAgentStatus] = useState<'idle' | 'running' | 'connecting' | 'error'>('idle');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandles>(null); const chatInputRef = useRef<ChatInputHandles>(null);
@ -71,7 +72,7 @@ export const AgentPreview = ({ agent }: AgentPreviewProps) => {
const handleNewMessageFromStream = useCallback((message: UnifiedMessage) => { const handleNewMessageFromStream = useCallback((message: UnifiedMessage) => {
console.log(`[PREVIEW STREAM] Received message: ID=${message.message_id}, Type=${message.type}`); console.log(`[PREVIEW STREAM] Received message: ID=${message.message_id}, Type=${message.type}`);
setMessages((prev) => { setMessages((prev) => {
const messageExists = prev.some((m) => m.message_id === message.message_id); const messageExists = prev.some((m) => m.message_id === message.message_id);
if (messageExists) { if (messageExists) {
@ -105,8 +106,8 @@ export const AgentPreview = ({ agent }: AgentPreviewProps) => {
const handleStreamError = useCallback((errorMessage: string) => { const handleStreamError = useCallback((errorMessage: string) => {
console.error(`[PREVIEW] Stream error: ${errorMessage}`); console.error(`[PREVIEW] Stream error: ${errorMessage}`);
if (!errorMessage.toLowerCase().includes('not found') && 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}`); toast.error(`Stream Error: ${errorMessage}`);
} }
}, []); }, []);
@ -176,13 +177,14 @@ export const AgentPreview = ({ agent }: AgentPreviewProps) => {
try { try {
const files = chatInputRef.current?.getPendingFiles() || []; const files = chatInputRef.current?.getPendingFiles() || [];
const formData = new FormData(); const formData = new FormData();
formData.append('prompt', message); formData.append('prompt', message);
formData.append('agent_id', agent.agent_id); formData.append('agent_id', agent.agent_id);
files.forEach((file, index) => { 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); if (options?.model_name) formData.append('model_name', options.model_name);
@ -328,7 +330,7 @@ export const AgentPreview = ({ agent }: AgentPreviewProps) => {
return ( return (
<div className="h-full flex flex-col bg-muted dark:bg-muted/30"> <div className="h-full flex flex-col bg-muted dark:bg-muted/30">
<div className="flex-shrink-0 flex items-center gap-3 p-8"> <div className="flex-shrink-0 flex items-center gap-3 p-8">
<div <div
className="h-10 w-10 flex items-center justify-center rounded-lg text-lg" className="h-10 w-10 flex items-center justify-center rounded-lg text-lg"
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
> >
@ -347,7 +349,7 @@ export const AgentPreview = ({ agent }: AgentPreviewProps) => {
streamingToolCall={streamingToolCall} streamingToolCall={streamingToolCall}
agentStatus={agentStatus} agentStatus={agentStatus}
handleToolClick={handleToolClick} handleToolClick={handleToolClick}
handleOpenFileViewer={() => {}} handleOpenFileViewer={() => { }}
streamHookStatus={streamHookStatus} streamHookStatus={streamHookStatus}
isPreviewMode={true} isPreviewMode={true}
agentName={agent.name} agentName={agent.name}

View File

@ -30,6 +30,7 @@ import { cn } from '@/lib/utils';
import { useModal } from '@/hooks/use-modal-store'; import { useModal } from '@/hooks/use-modal-store';
import { Examples } from './suggestions/examples'; import { Examples } from './suggestions/examples';
import { useThreadQuery } from '@/hooks/react-query/threads/use-threads'; import { useThreadQuery } from '@/hooks/react-query/threads/use-threads';
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
const PENDING_PROMPT_KEY = 'pendingAgentPrompt'; const PENDING_PROMPT_KEY = 'pendingAgentPrompt';
@ -110,7 +111,8 @@ export function DashboardContent() {
} }
files.forEach((file, index) => { 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); if (options?.model_name) formData.append('model_name', options.model_name);
@ -194,7 +196,7 @@ export function DashboardContent() {
<h1 className="tracking-tight text-4xl text-muted-foreground leading-tight"> <h1 className="tracking-tight text-4xl text-muted-foreground leading-tight">
Hey, I am Hey, I am
</h1> </h1>
<AgentSelector <AgentSelector
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
onAgentSelect={setSelectedAgentId} onAgentSelect={setSelectedAgentId}
variant="heading" variant="heading"
@ -204,7 +206,7 @@ export function DashboardContent() {
What would you like to do today? What would you like to do today?
</p> </p>
</div> </div>
<div className={cn( <div className={cn(
"w-full mb-2", "w-full mb-2",
"max-w-full", "max-w-full",
@ -220,7 +222,7 @@ export function DashboardContent() {
hideAttachments={false} hideAttachments={false}
/> />
</div> </div>
<Examples onSelectPrompt={setInputValue} /> <Examples onSelectPrompt={setInputValue} />
</div> </div>

View File

@ -14,6 +14,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { UploadedFile } from './chat-input'; import { UploadedFile } from './chat-input';
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
@ -32,17 +33,23 @@ const handleLocalFiles = (
setPendingFiles((prevFiles) => [...prevFiles, ...filteredFiles]); setPendingFiles((prevFiles) => [...prevFiles, ...filteredFiles]);
const newUploadedFiles: UploadedFile[] = filteredFiles.map((file) => ({ const newUploadedFiles: UploadedFile[] = filteredFiles.map((file) => {
name: file.name, // Normalize filename to NFC
path: `/workspace/${file.name}`, const normalizedName = normalizeFilenameToNFC(file.name);
size: file.size,
type: file.type || 'application/octet-stream', return {
localUrl: URL.createObjectURL(file) name: normalizedName,
})); path: `/workspace/${normalizedName}`,
size: file.size,
type: file.type || 'application/octet-stream',
localUrl: URL.createObjectURL(file)
};
});
setUploadedFiles((prev) => [...prev, ...newUploadedFiles]); setUploadedFiles((prev) => [...prev, ...newUploadedFiles]);
filteredFiles.forEach((file) => { 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; 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 // Check if this filename already exists in chat messages
const isFileInChat = messages.some(message => { const isFileInChat = messages.some(message => {
@ -74,7 +83,9 @@ const uploadFiles = async (
}); });
const formData = new FormData(); 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); formData.append('path', uploadPath);
const supabase = createClient(); const supabase = createClient();
@ -116,13 +127,13 @@ const uploadFiles = async (
} }
newUploadedFiles.push({ newUploadedFiles.push({
name: file.name, name: normalizedName,
path: uploadPath, path: uploadPath,
size: file.size, size: file.size,
type: file.type || 'application/octet-stream', type: file.type || 'application/octet-stream',
}); });
toast.success(`File uploaded: ${file.name}`); toast.success(`File uploaded: ${normalizedName}`);
} }
setUploadedFiles((prev) => [...prev, ...newUploadedFiles]); setUploadedFiles((prev) => [...prev, ...newUploadedFiles]);

View File

@ -51,6 +51,7 @@ import {
FileCache FileCache
} from '@/hooks/react-query/files'; } from '@/hooks/react-query/files';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
// Define API_URL // Define API_URL
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
@ -1121,6 +1122,8 @@ export function FileViewerModal({
} }
}, []); }, []);
// Process uploaded file - Define after helpers // Process uploaded file - Define after helpers
const processUpload = useCallback( const processUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => { async (event: React.ChangeEvent<HTMLInputElement>) => {
@ -1130,9 +1133,15 @@ export function FileViewerModal({
setIsUploading(true); setIsUploading(true);
try { try {
// Normalize filename to NFC
const normalizedName = normalizeFilenameToNFC(file.name);
const uploadPath = `${currentPath}/${normalizedName}`;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); // If the filename was normalized, append with the normalized name in the field name
formData.append('path', `${currentPath}/${file.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 supabase = createClient();
const { const {
@ -1162,7 +1171,7 @@ export function FileViewerModal({
// Reload the file list using React Query // Reload the file list using React Query
await refetchFiles(); await refetchFiles();
toast.success(`Uploaded: ${file.name}`); toast.success(`Uploaded: ${normalizedName}`);
} catch (error) { } catch (error) {
console.error('Upload failed:', error); console.error('Upload failed:', error);
toast.error( toast.error(

View File

@ -3,7 +3,6 @@ import { useAuth } from '@/components/AuthProvider';
import { fileQueryKeys } from './use-file-queries'; import { fileQueryKeys } from './use-file-queries';
import { FileCache } from '@/hooks/use-cached-file'; import { FileCache } from '@/hooks/use-cached-file';
import { toast } from 'sonner'; import { toast } from 'sonner';
// Import the normalizePath function from use-file-queries // Import the normalizePath function from use-file-queries
function normalizePath(path: string): string { function normalizePath(path: string): string {
if (!path) return '/'; if (!path) return '/';

View File

@ -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;
}
};