Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
134 changes: 91 additions & 43 deletions chat-ui/components/chat/ChatWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -38,7 +39,6 @@ import {
waitForChatPrepare,
type ChatPrepareMcpError,
sessionDownloadUrl,
uploadSessionFiles,
listSessionUploads,
fetchConversationHistory,
fetchStreamStatus,
Expand All @@ -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,
Expand Down Expand Up @@ -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<DockTab>("none");
const [lastActiveTab, setLastActiveTab] = useState<DockTab>("plan");

Expand Down Expand Up @@ -1278,16 +1292,9 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve
minHeight: COMPOSER_TEXTAREA_MIN,
maxHeight: composerTextMax,
});
const [pendingFiles, setPendingFiles] = useState<File[]>([]);

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<AbortController | null>(null);
Expand Down Expand Up @@ -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()}`,
Expand Down Expand Up @@ -2245,7 +2254,10 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve
token,
activeProfileSlug,
effectiveEffort,
pendingFiles,
pendingUploadsInProgress,
pendingUploadsFailed,
pendingUploadedAttachments,
clearPendingUploads,
thinkingEnabled,
markStreamConversation,
transcriptStreaming,
Expand Down Expand Up @@ -3834,24 +3846,58 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve
</div>
)
) : null}
{pendingFiles.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{pendingFiles.map((f) => (
<span
key={f.name}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/60 px-3 py-1.5 text-[0.857em] font-medium text-foreground backdrop-blur-sm"
>
{f.name}
<button
type="button"
className="focus-ring rounded-full px-0.5 text-muted-foreground hover:text-destructive transition-colors"
aria-label={t("chat.remove_file", { name: f.name })}
onClick={() => setPendingFiles((prev) => prev.filter((x) => x.name !== f.name))}
{pendingUploadItems.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2" aria-live="polite">
{pendingUploadItems.map((item) => {
const ringStatus =
item.status === "done"
? "done"
: item.status === "error"
? "error"
: "uploading";
const statusLabel =
item.status === "done"
? t("chat.upload.done")
: item.status === "error"
? t("chat.upload.failed")
: t("chat.upload.uploading", { progress: item.progress });
return (
<span
key={item.id}
className={cn(
"inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5 text-[0.857em] font-medium backdrop-blur-sm",
item.status === "error"
? "border-destructive/40 bg-destructive/10 text-destructive"
: "border-border bg-muted/60 text-foreground",
)}
title={statusLabel}
>
×
</button>
</span>
))}
<CircularUploadProgress
value={item.progress}
status={ringStatus}
size={18}
/>
<span className="truncate max-w-[14rem]">{item.file.name}</span>
{item.status === "error" ? (
<button
type="button"
className="focus-ring rounded-full px-1 text-[0.786em] font-semibold hover:underline"
onClick={() => retryPendingUpload(item.id)}
>
{t("chat.upload.retry")}
</button>
) : null}
<button
type="button"
className="focus-ring rounded-full px-0.5 text-muted-foreground hover:text-destructive transition-colors"
aria-label={t("chat.remove_file", { name: item.file.name })}
onClick={() => removePendingUpload(item.id)}
>
×
</button>
</span>
);
})}
</div>
)}
{isProjectRequiredButMissing && (
Expand Down Expand Up @@ -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")}
Expand All @@ -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 = "";
}}
/>
Expand Down Expand Up @@ -4492,7 +4533,14 @@ export function ChatWorkspace({ conversationId: initialConversationId }: { conve
<button
type="button"
onClick={() => void send()}
disabled={!input.trim() || isProjectRequiredButMissing}
disabled={
!input.trim() ||
isProjectRequiredButMissing ||
sendBlockedByUploads
}
title={
sendBlockedByUploads ? t("chat.upload.send_blocked") : undefined
}
className="focus-ring inline-flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground transition-all duration-200 hover:scale-105 hover:bg-primary/95 disabled:pointer-events-none disabled:opacity-30"
>
<Send size={13} aria-hidden />
Expand Down
84 changes: 84 additions & 0 deletions chat-ui/components/chat/CircularUploadProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"use client";

import { AlertCircle, Check } from "lucide-react";

import { cn } from "@/lib/cn";

export type CircularUploadStatus = "uploading" | "done" | "error";

export function CircularUploadProgress({
value,
status,
size = 20,
className,
}: {
value: number;
status: CircularUploadStatus;
size?: number;
className?: string;
}) {
const stroke = 2;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const clamped = Math.max(0, Math.min(100, value));
const offset = circumference - (clamped / 100) * circumference;

if (status === "error") {
return (
<span
className={cn("inline-flex shrink-0 items-center justify-center text-destructive", className)}
style={{ width: size, height: size }}
aria-hidden
>
<AlertCircle size={size - 4} />
</span>
);
}

if (status === "done") {
return (
<span
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
className,
)}
style={{ width: size, height: size }}
aria-hidden
>
<Check size={size - 6} strokeWidth={2.5} />
</span>
);
}

return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={cn("shrink-0 -rotate-90", className)}
aria-hidden
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
className="text-muted-foreground/25"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
className="text-primary transition-[stroke-dashoffset] duration-150"
strokeDasharray={circumference}
strokeDashoffset={offset}
/>
</svg>
);
}
Loading
Loading