diff --git a/.env.example b/.env.example index 9ccb1901..fc51767b 100644 --- a/.env.example +++ b/.env.example @@ -349,6 +349,12 @@ AION_SKILL_VIEW_ENFORCE_PROFILE=1 # o un path assoluto scrivibile — **non** lasciare `/app/data` se l’API non gira nel container. AION_DATA_DIR=data AION_UPLOAD_MAX_BYTES=52428800 +# Legacy Word (.doc Word 97–2003): convert to .docx on upload via LibreOffice (soffice). +# Docker: libreoffice-writer-nogui in Dockerfile.backend. Dev Mac: brew install --cask libreoffice +AION_OFFICE_AUTO_CONVERT_LEGACY_WORD=1 +# Path to soffice if not on PATH (macOS: /Applications/LibreOffice.app/Contents/MacOS/soffice) +# AION_SOFFICE_PATH= +# AION_OFFICE_CONVERT_TIMEOUT_SEC=90 # 1 = embed session images as Haystack ImageContent on the user turn (vision-capable LLM). # 0 = legacy: only the text list from _format_attachments_block (default). AION_CHAT_MULTIMODAL_ATTACHMENTS=1 @@ -529,6 +535,13 @@ AION_WEB_FETCH_TIMEOUT_SEC=25 AION_WEB_FETCH_MAX_BYTES=1500000 # Estrazione pagina (trafilatura/bs4) prima dell'offload — default 24k inline; con offload vedi sotto AION_WEB_FETCH_MAX_CHARS=24000 +# Geocoding MCP (OpenStreetMap Nominatim) — geocode_place / reverse_geocode +# AION_GEOCODING_NOMINATIM_URL=https://nominatim.openstreetmap.org +# AION_GEOCODING_USER_AGENT=AION-Agent/1.0 (your-contact@example.com) +# AION_GEOCODING_MIN_INTERVAL_SEC=1.1 +# PDF evidence crop (MCP pdf_evidence_crop on ocr server) — Word report screenshots +# AION_PDF_EVIDENCE_DPI=200 +# AION_PDF_EVIDENCE_MAX_WHITE_RATIO=0.90 # Con AION_TOOL_OFFLOAD_ENABLED=1: cap estrazione pagina su disco (default ~min(bytes/2, 250k)) # AION_WEB_FETCH_OFFLOAD_MAX_CHARS=200000 # Formato risultati tool web nel contesto LLM: toon (default, -30/60% token) | json diff --git a/chat-ui/components/chat/ChatWorkspace.tsx b/chat-ui/components/chat/ChatWorkspace.tsx index e467fd9e..30de4d57 100644 --- a/chat-ui/components/chat/ChatWorkspace.tsx +++ b/chat-ui/components/chat/ChatWorkspace.tsx @@ -17,6 +17,7 @@ import { ShimmerText } from "@/components/chat/ShimmerText"; import Link from "next/link"; import { AgentModeSelectChip } from "@/components/chat/AgentModeSelectChip"; import { ChatEmptyState } from "@/components/chat/ChatEmptyState"; +import { CircularUploadProgress } from "@/components/chat/CircularUploadProgress"; import { ComposerOptionRow } from "@/components/chat/ComposerOptionRow"; import { ChatDragDrop } from "@/components/chat/ChatDragDrop"; import { mergeAttachmentRefs } from "@/lib/attachments"; @@ -38,7 +39,6 @@ import { waitForChatPrepare, type ChatPrepareMcpError, sessionDownloadUrl, - uploadSessionFiles, listSessionUploads, fetchConversationHistory, fetchStreamStatus, @@ -60,6 +60,7 @@ import { type SessionChart, } from "@/lib/api/aion"; import { useStoredToken, useStoredUserId } from "@/lib/auth/use-stored-auth"; +import { usePendingSessionUploads } from "@/hooks/use-pending-session-uploads"; import { useT } from "@/lib/i18n/use-t"; import { extractStreamingPlanMarkdown, @@ -413,6 +414,19 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve const sidebarOpen = useSidebarOpen(); const [conversationId, setConversationId] = useState(initialConversationId); + const { + items: pendingUploadItems, + queueFiles: queuePendingUploads, + removeItem: removePendingUpload, + retryItem: retryPendingUpload, + clearAll: clearPendingUploads, + isUploading: pendingUploadsInProgress, + hasUploadErrors: pendingUploadsFailed, + completedAttachments: pendingUploadedAttachments, + } = usePendingSessionUploads(conversationId, userId, token); + + const sendBlockedByUploads = pendingUploadsInProgress || pendingUploadsFailed; + const [dockTab, setDockTab] = useState("none"); const [lastActiveTab, setLastActiveTab] = useState("plan"); @@ -1278,16 +1292,9 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve minHeight: COMPOSER_TEXTAREA_MIN, maxHeight: composerTextMax, }); - const [pendingFiles, setPendingFiles] = useState([]); - const handleFilesDropped = useCallback((files: File[]) => { - setPendingFiles((prev) => { - const filtered = files.filter( - (sf) => !prev.some((pf) => pf.name === sf.name && pf.size === sf.size) - ); - return [...prev, ...filtered]; - }); - }, []); + queuePendingUploads(files); + }, [queuePendingUploads]); const [streamEpoch, setStreamEpoch] = useState(0); const abortRef = useRef(null); @@ -1831,18 +1838,20 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve opts?.deepResearchModeOverride !== undefined ? opts.deepResearchModeOverride : effectiveAgentMode === "deep_research"; + if (pendingUploadsInProgress || pendingUploadsFailed) { + return; + } + let uidMsg = crypto.randomUUID(); let aid = crypto.randomUUID(); setActiveMessageId(aid); - const hasPendingFiles = pendingFiles.length > 0; - const uploads = await uploadSessionFiles(conversationId, userId, pendingFiles, token); - setPendingFiles([]); + const uploads = [...pendingUploadedAttachments]; + const hasNewUploads = uploads.length > 0; + clearPendingUploads(); void fetchSessionFiles(); - // Fetch existing session uploads only when new files were uploaded, - // so previous uploads aren't incorrectly attached to the current message. - const existing = hasPendingFiles ? await listSessionUploads(conversationId, userId, token) : []; - const attachments = hasPendingFiles ? mergeAttachmentRefs(uploads, existing) : []; + const existing = hasNewUploads ? await listSessionUploads(conversationId, userId, token) : []; + const attachments = hasNewUploads ? mergeAttachmentRefs(uploads, existing) : []; const userArtifacts: ChatHistoryArtifact[] = uploads.map((a, i) => ({ id: `att-${i}-${Date.now()}`, @@ -2245,7 +2254,10 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve token, activeProfileSlug, effectiveEffort, - pendingFiles, + pendingUploadsInProgress, + pendingUploadsFailed, + pendingUploadedAttachments, + clearPendingUploads, thinkingEnabled, markStreamConversation, transcriptStreaming, @@ -3834,24 +3846,58 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve ) ) : null} - {pendingFiles.length > 0 && ( -
- {pendingFiles.map((f) => ( - - {f.name} - - - ))} + + {item.file.name} + {item.status === "error" ? ( + + ) : null} + + + ); + })}
)} {isProjectRequiredButMissing && ( @@ -3914,7 +3960,7 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve handleAgentModeChange(agentMode === "plan" ? "normal" : "plan"); } else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - void send(); + if (!sendBlockedByUploads) void send(); } }} placeholder={isProjectRequiredButMissing ? t("chat.project_required.textarea_placeholder") : t("chat.composer_placeholder")} @@ -3930,12 +3976,7 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve className="hidden" onChange={(e) => { const selected = Array.from(e.target.files || []); - setPendingFiles((prev) => { - const filtered = selected.filter( - (sf) => !prev.some((pf) => pf.name === sf.name && pf.size === sf.size) - ); - return [...prev, ...filtered]; - }); + queuePendingUploads(selected); e.target.value = ""; }} /> @@ -4492,7 +4533,14 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve