From f8b032b6574b5b29e250d58441a4d3cd4c8dc56c Mon Sep 17 00:00:00 2001 From: lgh-solace Date: Sun, 21 Dec 2025 16:25:19 -0500 Subject: [PATCH 1/9] chore: refactoring chat provider --- .../components/projects/KnowledgeSection.tsx | 2 +- .../projects/ProjectFilesManager.tsx | 2 +- .../frontend/src/lib/contexts/ChatContext.ts | 11 +- client/webui/frontend/src/lib/hooks/index.ts | 4 + .../src/lib/hooks/useArtifactOperations.ts | 293 +++++++ .../src/lib/hooks/useArtifactPreview.ts | 241 ++++++ .../frontend/src/lib/hooks/useFeedback.ts | 65 ++ .../frontend/src/lib/hooks/useSidePanel.ts | 60 ++ .../src/lib/providers/ChatProvider.tsx | 813 ++++-------------- client/webui/frontend/src/lib/types/fe.ts | 7 - .../webui/frontend/src/lib/types/storage.ts | 12 +- client/webui/frontend/src/lib/utils/file.ts | 110 +++ .../{file-validation.ts => fileValidation.ts} | 4 +- client/webui/frontend/src/lib/utils/index.ts | 3 + .../frontend/src/lib/utils/taskMigration.ts | 104 +++ .../src/stories/mocks/MockChatProvider.tsx | 6 - 16 files changed, 1086 insertions(+), 651 deletions(-) create mode 100644 client/webui/frontend/src/lib/hooks/useArtifactOperations.ts create mode 100644 client/webui/frontend/src/lib/hooks/useArtifactPreview.ts create mode 100644 client/webui/frontend/src/lib/hooks/useFeedback.ts create mode 100644 client/webui/frontend/src/lib/hooks/useSidePanel.ts create mode 100644 client/webui/frontend/src/lib/utils/file.ts rename client/webui/frontend/src/lib/utils/{file-validation.ts => fileValidation.ts} (98%) create mode 100644 client/webui/frontend/src/lib/utils/taskMigration.ts diff --git a/client/webui/frontend/src/lib/components/projects/KnowledgeSection.tsx b/client/webui/frontend/src/lib/components/projects/KnowledgeSection.tsx index 524734b74..674d0a67e 100644 --- a/client/webui/frontend/src/lib/components/projects/KnowledgeSection.tsx +++ b/client/webui/frontend/src/lib/components/projects/KnowledgeSection.tsx @@ -8,7 +8,7 @@ import { useProjectArtifacts } from "@/lib/hooks/useProjectArtifacts"; import { useProjectContext } from "@/lib/providers"; import { useDownload } from "@/lib/hooks/useDownload"; import { useConfigContext } from "@/lib/hooks"; -import { validateFileSizes } from "@/lib/utils/file-validation"; +import { validateFileSizes } from "@/lib/utils/fileValidation"; import type { Project } from "@/lib/types/projects"; import type { ArtifactInfo } from "@/lib/types"; import { DocumentListItem } from "./DocumentListItem"; diff --git a/client/webui/frontend/src/lib/components/projects/ProjectFilesManager.tsx b/client/webui/frontend/src/lib/components/projects/ProjectFilesManager.tsx index 183fdce8b..44716ce64 100644 --- a/client/webui/frontend/src/lib/components/projects/ProjectFilesManager.tsx +++ b/client/webui/frontend/src/lib/components/projects/ProjectFilesManager.tsx @@ -3,7 +3,7 @@ import { Loader2, FileText, AlertTriangle, Plus } from "lucide-react"; import { useProjectArtifacts } from "@/lib/hooks/useProjectArtifacts"; import { useConfigContext } from "@/lib/hooks"; -import { validateFileSizes } from "@/lib/utils/file-validation"; +import { validateFileSizes } from "@/lib/utils/fileValidation"; import type { Project } from "@/lib/types/projects"; import { Button } from "@/lib/components/ui"; import { MessageBanner } from "@/lib/components/common"; diff --git a/client/webui/frontend/src/lib/contexts/ChatContext.ts b/client/webui/frontend/src/lib/contexts/ChatContext.ts index 087cbfbf5..23d9a1cc7 100644 --- a/client/webui/frontend/src/lib/contexts/ChatContext.ts +++ b/client/webui/frontend/src/lib/contexts/ChatContext.ts @@ -1,6 +1,6 @@ import React, { createContext, type FormEvent } from "react"; -import type { AgentCardInfo, ArtifactInfo, ArtifactRenderingState, BackgroundTaskNotification, BackgroundTaskState, FileAttachment, MessageFE, Notification, Session } from "@/lib/types"; +import type { AgentCardInfo, ArtifactInfo, BackgroundTaskNotification, BackgroundTaskState, FileAttachment, MessageFE, Notification, Session } from "@/lib/types"; /** Pending prompt data for starting a new chat with a prompt template */ export interface PendingPromptData { @@ -50,8 +50,6 @@ export interface ChatState { currentPreviewedVersionNumber: number | null; previewFileContent: FileAttachment | null; submittedFeedback: Record; - // Artifact Rendering State - artifactRenderingState: ArtifactRenderingState; // Pending prompt for starting new chat pendingPrompt: PendingPromptData | null; // Background Task Monitoring State @@ -101,13 +99,8 @@ export interface ChatActions { markArtifactAsDisplayed: (filename: string, displayed: boolean) => void; downloadAndResolveArtifact: (filename: string) => Promise; - /** Artifact Rendering Actions */ - toggleArtifactExpanded: (filename: string) => void; - isArtifactExpanded: (filename: string) => boolean; - setArtifactRenderingState: React.Dispatch>; - /* Session Management Actions */ - updateSessionName: (sessionId: string, newName: string, showNotification?: boolean) => Promise; + updateSessionName: (sessionId: string, newName: string) => Promise; deleteSession: (sessionId: string) => Promise; handleFeedbackSubmit: (taskId: string, feedbackType: "up" | "down", feedbackText: string) => Promise; diff --git a/client/webui/frontend/src/lib/hooks/index.ts b/client/webui/frontend/src/lib/hooks/index.ts index 8671a8f01..0c6bb6f8b 100644 --- a/client/webui/frontend/src/lib/hooks/index.ts +++ b/client/webui/frontend/src/lib/hooks/index.ts @@ -1,6 +1,9 @@ export * from "./useAgentCards"; +export * from "./useArtifactOperations"; +export * from "./useArtifactPreview"; export * from "./useArtifactRendering"; export * from "./useArtifacts"; +export * from "./useSidePanel"; export * from "./useAudioSettings"; export * from "./useAuthContext"; export * from "./useBackgroundTaskMonitor"; @@ -21,6 +24,7 @@ export * from "./useProjectSessions"; export * from "./useAgentSelection"; export * from "./useNavigationBlocker"; export * from "./useErrorDialog"; +export * from "./useFeedback"; export * from "./useSessionStorage"; export * from "./useMap"; export * from "./useLocalStorage"; diff --git a/client/webui/frontend/src/lib/hooks/useArtifactOperations.ts b/client/webui/frontend/src/lib/hooks/useArtifactOperations.ts new file mode 100644 index 000000000..bc1d5d800 --- /dev/null +++ b/client/webui/frontend/src/lib/hooks/useArtifactOperations.ts @@ -0,0 +1,293 @@ +import { useState, useCallback, useRef } from "react"; +import { api } from "@/lib/api"; +import { createFileSizeErrorMessage, blobToBase64, getErrorMessage } from "@/lib/utils"; +import type { ArtifactInfo, FileAttachment } from "@/lib/types"; + +interface UseArtifactOperationsOptions { + sessionId: string; + artifacts: ArtifactInfo[]; + setArtifacts: React.Dispatch>; + artifactsRefetch: () => Promise; + onNotification: (message: string, type?: "success" | "info" | "warning") => void; + onError: (title: string, error: string) => void; + previewArtifact: { filename: string } | null; + closeArtifactPreview: () => void; +} + +interface UseArtifactOperationsReturn { + // Upload + uploadArtifactFile: (file: File, overrideSessionId?: string, description?: string, silent?: boolean) => Promise<{ uri: string; sessionId: string } | { error: string } | null>; + + // Delete - Single + isDeleteModalOpen: boolean; + artifactToDelete: ArtifactInfo | null; + openDeleteModal: (artifact: ArtifactInfo) => void; + closeDeleteModal: () => void; + confirmDelete: () => Promise; + + // Delete - Batch + isArtifactEditMode: boolean; + setIsArtifactEditMode: React.Dispatch>; + selectedArtifactFilenames: Set; + setSelectedArtifactFilenames: React.Dispatch>>; + isBatchDeleteModalOpen: boolean; + setIsBatchDeleteModalOpen: React.Dispatch>; + handleDeleteSelectedArtifacts: () => void; + confirmBatchDeleteArtifacts: () => Promise; + + // Download + downloadAndResolveArtifact: (filename: string) => Promise; +} + +/** + * Utility function to create file attachment from artifact info + */ +const getFileAttachment = (artifactInfos: ArtifactInfo[], filename: string, mimeType: string, content: string): FileAttachment => { + const artifactInfo = artifactInfos.find(a => a.filename === filename); + return { + name: filename, + mime_type: mimeType, + content: content, + last_modified: artifactInfo?.last_modified || new Date().toISOString(), + }; +}; + +/** + * Custom hook to manage artifact CRUD operations + * Handles upload, download, delete (single and batch), and modal state + */ +export const useArtifactOperations = ({ sessionId, artifacts, setArtifacts, artifactsRefetch, onNotification, onError, previewArtifact, closeArtifactPreview }: UseArtifactOperationsOptions): UseArtifactOperationsReturn => { + // Delete Modal State + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [artifactToDelete, setArtifactToDelete] = useState(null); + const [isBatchDeleteModalOpen, setIsBatchDeleteModalOpen] = useState(false); + + // Edit Mode State + const [isArtifactEditMode, setIsArtifactEditMode] = useState(false); + const [selectedArtifactFilenames, setSelectedArtifactFilenames] = useState>(new Set()); + + // Track in-flight artifact downloads to prevent duplicates + const artifactDownloadInProgressRef = useRef>(new Set()); + + /** + * Upload an artifact file to the backend + */ + const uploadArtifactFile = useCallback( + async (file: File, overrideSessionId?: string, description?: string, silent: boolean = false): Promise<{ uri: string; sessionId: string } | { error: string } | null> => { + const effectiveSessionId = overrideSessionId || sessionId; + const formData = new FormData(); + formData.append("upload_file", file); + formData.append("filename", file.name); + formData.append("sessionId", effectiveSessionId || ""); + + if (description) { + const metadata = { description }; + formData.append("metadata_json", JSON.stringify(metadata)); + } + + try { + const response = await api.webui.post("/api/v1/artifacts/upload", formData, { fullResponse: true }); + + if (response.status === 413) { + const errorData = await response.json().catch(() => ({ message: `Failed to upload ${file.name}.` })); + const actualSize = errorData.actual_size_bytes; + const maxSize = errorData.max_size_bytes; + const errorMessage = actualSize && maxSize ? createFileSizeErrorMessage(file.name, actualSize, maxSize) : errorData.message || `File "${file.name}" exceeds the maximum allowed size.`; + onError("File Upload Failed", errorMessage); + return { error: errorMessage }; + } + + if (!response.ok) { + throw new Error( + await response + .json() + .then((d: { message?: string }) => d.message) + .catch(() => `Failed to upload ${file.name}.`) + ); + } + + const result = await response.json(); + if (!silent) { + onNotification(`File "${file.name}" uploaded.`, "success"); + } + await artifactsRefetch(); + return result.uri && result.sessionId ? { uri: result.uri, sessionId: result.sessionId } : null; + } catch (error) { + const errorMessage = getErrorMessage(error, `Failed to upload "${file.name}".`); + onError("File Upload Failed", errorMessage); + return { error: errorMessage }; + } + }, + [sessionId, onNotification, artifactsRefetch, onError] + ); + + /** + * Internal function to delete an artifact + */ + const deleteArtifactInternal = useCallback( + async (filename: string) => { + try { + await api.webui.delete(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}`); + onNotification(`File "${filename}" deleted.`, "success"); + artifactsRefetch(); + } catch (error) { + onError("File Deletion Failed", getErrorMessage(error, `Failed to delete ${filename}.`)); + } + }, + [sessionId, onNotification, artifactsRefetch, onError] + ); + + /** + * Open delete confirmation modal for a single artifact + */ + const openDeleteModal = useCallback((artifact: ArtifactInfo) => { + setArtifactToDelete(artifact); + setIsDeleteModalOpen(true); + }, []); + + /** + * Close delete confirmation modal + */ + const closeDeleteModal = useCallback(() => { + setArtifactToDelete(null); + setIsDeleteModalOpen(false); + }, []); + + /** + * Confirm and execute single artifact deletion + */ + const confirmDelete = useCallback(async () => { + if (artifactToDelete) { + // Check if the artifact being deleted is currently being previewed + const isCurrentlyPreviewed = previewArtifact?.filename === artifactToDelete.filename; + + await deleteArtifactInternal(artifactToDelete.filename); + + // If the deleted artifact was being previewed, close the preview + if (isCurrentlyPreviewed) { + closeArtifactPreview(); + } + } + closeDeleteModal(); + }, [artifactToDelete, deleteArtifactInternal, closeDeleteModal, previewArtifact, closeArtifactPreview]); + + /** + * Open batch delete modal + */ + const handleDeleteSelectedArtifacts = useCallback(() => { + if (selectedArtifactFilenames.size === 0) { + return; + } + setIsBatchDeleteModalOpen(true); + }, [selectedArtifactFilenames]); + + /** + * Confirm and execute batch artifact deletion + */ + const confirmBatchDeleteArtifacts = useCallback(async () => { + setIsBatchDeleteModalOpen(false); + const filenamesToDelete = Array.from(selectedArtifactFilenames); + let successCount = 0; + let errorCount = 0; + + for (const filename of filenamesToDelete) { + try { + await api.webui.delete(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}`); + successCount++; + } catch (error: unknown) { + console.error(error); + errorCount++; + } + } + + if (successCount > 0) onNotification(`${successCount} files(s) deleted.`, "success"); + if (errorCount > 0) { + onError("File Deletion Failed", `${errorCount} file(s) failed to delete.`); + } + + artifactsRefetch(); + setSelectedArtifactFilenames(new Set()); + setIsArtifactEditMode(false); + }, [selectedArtifactFilenames, onNotification, artifactsRefetch, sessionId, onError]); + + /** + * Download and resolve artifact with embeds + */ + const downloadAndResolveArtifact = useCallback( + async (filename: string): Promise => { + // Prevent duplicate downloads for the same file + if (artifactDownloadInProgressRef.current.has(filename)) { + console.log(`[useArtifactOperations] Skipping duplicate download for ${filename} - already in progress`); + return null; + } + + // Mark this file as being downloaded + artifactDownloadInProgressRef.current.add(filename); + + try { + // Find the artifact in state + const artifact = artifacts.find(art => art.filename === filename); + if (!artifact) { + console.error(`Artifact ${filename} not found in state`); + return null; + } + + // Fetch the latest version with embeds resolved + const availableVersions: number[] = await api.webui.get(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}/versions`); + if (!availableVersions || availableVersions.length === 0) { + throw new Error("No versions available"); + } + + const latestVersion = Math.max(...availableVersions); + const contentResponse = await api.webui.get(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}/versions/${latestVersion}`, { fullResponse: true }); + if (!contentResponse.ok) { + throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); + } + + const blob = await contentResponse.blob(); + const base64Content = await blobToBase64(blob); + const fileData = getFileAttachment(artifacts, filename, artifact.mime_type || "application/octet-stream", base64Content); + + // Clear the accumulated content and flags after successful download + setArtifacts(prevArtifacts => { + return prevArtifacts.map(art => + art.filename === filename + ? { + ...art, + accumulatedContent: undefined, + needsEmbedResolution: false, + } + : art + ); + }); + + return fileData; + } catch (error) { + onError("File Download Failed", getErrorMessage(error, `Failed to download ${filename}.`)); + return null; + } finally { + // Remove from in-progress set immediately when done + artifactDownloadInProgressRef.current.delete(filename); + } + }, + [sessionId, artifacts, setArtifacts, onError] + ); + + return { + uploadArtifactFile, + isDeleteModalOpen, + artifactToDelete, + openDeleteModal, + closeDeleteModal, + confirmDelete, + isArtifactEditMode, + setIsArtifactEditMode, + selectedArtifactFilenames, + setSelectedArtifactFilenames, + isBatchDeleteModalOpen, + setIsBatchDeleteModalOpen, + handleDeleteSelectedArtifacts, + confirmBatchDeleteArtifacts, + downloadAndResolveArtifact, + }; +}; diff --git a/client/webui/frontend/src/lib/hooks/useArtifactPreview.ts b/client/webui/frontend/src/lib/hooks/useArtifactPreview.ts new file mode 100644 index 000000000..751c03c7c --- /dev/null +++ b/client/webui/frontend/src/lib/hooks/useArtifactPreview.ts @@ -0,0 +1,241 @@ +import { useState, useCallback, useRef, useMemo } from "react"; +import type { ArtifactInfo, FileAttachment } from "@/lib/types"; +import { getArtifactContent, getArtifactUrl, getErrorMessage } from "@/lib/utils"; +import { api } from "@/lib/api"; + +// Types +export interface ArtifactPreviewState { + filename: string | null; + availableVersions: number[] | null; + currentVersion: number | null; + content: FileAttachment | null; +} + +interface UseArtifactPreviewOptions { + sessionId: string; + projectId?: string; + artifacts: ArtifactInfo[]; + onError?: (title: string, error: string) => void; +} + +interface UseArtifactPreviewReturn { + // State + preview: ArtifactPreviewState; + previewArtifact: ArtifactInfo | null; + isLoading: boolean; + + // Actions + openPreview: (filename: string) => Promise; + navigateToVersion: (filename: string, version: number) => Promise; + closePreview: () => void; + setPreviewByArtifact: (artifact: ArtifactInfo | null) => void; +} + +/** + * Custom hook to manage artifact preview functionality + * Handles opening artifacts, navigating versions, and managing preview state + */ +export const useArtifactPreview = ({ sessionId, projectId, artifacts, onError }: UseArtifactPreviewOptions): UseArtifactPreviewReturn => { + // State + const [preview, setPreview] = useState({ + filename: null, + availableVersions: null, + currentVersion: null, + content: null, + }); + + const [isLoading, setIsLoading] = useState(false); + + // Track in-flight fetches to prevent duplicates + const fetchInProgressRef = useRef>(new Set()); + + // Derive preview artifact from artifacts array + const previewArtifact = useMemo(() => { + if (!preview.filename) return null; + return artifacts.find(a => a.filename === preview.filename) || null; + }, [artifacts, preview.filename]); + + /** + * Helper to get file attachment data + */ + const getFileAttachment = useCallback( + (filename: string, mimeType: string, content: string): FileAttachment => { + const artifactInfo = artifacts.find(a => a.filename === filename); + return { + name: filename, + mime_type: mimeType, + content: content, + last_modified: artifactInfo?.last_modified || new Date().toISOString(), + }; + }, + [artifacts] + ); + + /** + * Open an artifact for preview, loading the latest version + */ + const openPreview = useCallback( + async (filename: string): Promise => { + // Prevent duplicate fetches + if (fetchInProgressRef.current.has(filename)) { + return null; + } + + fetchInProgressRef.current.add(filename); + setIsLoading(true); + + // Clear state if opening a different file + if (preview.filename !== filename) { + setPreview({ + filename, + availableVersions: null, + currentVersion: null, + content: null, + }); + } + + try { + // Fetch available versions + const versionsUrl = getArtifactUrl({ + filename, + sessionId, + projectId, + }); + const availableVersions: number[] = await api.webui.get(versionsUrl); + + if (!availableVersions || availableVersions.length === 0) { + throw new Error("No versions available"); + } + + const sortedVersions = availableVersions.sort((a, b) => a - b); + const latestVersion = Math.max(...availableVersions); + + // Fetch content for latest version + const { content, mimeType } = await getArtifactContent({ + filename, + sessionId, + projectId, + version: latestVersion, + }); + + const fileData = getFileAttachment(filename, mimeType, content); + + // Update all preview state atomically + setPreview({ + filename, + availableVersions: sortedVersions, + currentVersion: latestVersion, + content: fileData, + }); + + return fileData; + } catch (error) { + const errorMessage = getErrorMessage(error, "Failed to load artifact preview."); + onError?.("Artifact Preview Failed", errorMessage); + return null; + } finally { + setIsLoading(false); + fetchInProgressRef.current.delete(filename); + } + }, + [sessionId, projectId, preview.filename, getFileAttachment, onError] + ); + + /** + * Navigate to a specific version of the currently previewed artifact + */ + const navigateToVersion = useCallback( + async (filename: string, targetVersion: number): Promise => { + // Verify versions are loaded + if (!preview.availableVersions || preview.availableVersions.length === 0) { + return null; + } + + // Verify target version exists + if (!preview.availableVersions.includes(targetVersion)) { + console.warn(`Requested version ${targetVersion} not available for ${filename}`); + return null; + } + + setIsLoading(true); + + // Clear content while navigating + setPreview(prev => ({ + ...prev, + content: null, + })); + + try { + // Fetch content for target version + const { content, mimeType } = await getArtifactContent({ + filename, + sessionId, + projectId, + version: targetVersion, + }); + + const fileData = getFileAttachment(filename, mimeType, content); + + // Update version and content + setPreview(prev => ({ + ...prev, + currentVersion: targetVersion, + content: fileData, + })); + + return fileData; + } catch (error) { + const errorMessage = getErrorMessage(error, "Failed to fetch artifact version."); + onError?.("Artifact Version Preview Failed", errorMessage); + return null; + } finally { + setIsLoading(false); + } + }, + [sessionId, projectId, preview.availableVersions, getFileAttachment, onError] + ); + + /** + * Close the preview, clearing all state + */ + const closePreview = useCallback(() => { + setPreview({ + filename: null, + availableVersions: null, + currentVersion: null, + content: null, + }); + }, []); + + /** + * Set preview by artifact object (for compatibility with existing code) + */ + const setPreviewByArtifact = useCallback( + (artifact: ArtifactInfo | null) => { + if (artifact) { + // Only reset if different file + if (preview.filename !== artifact.filename) { + setPreview({ + filename: artifact.filename, + availableVersions: null, + currentVersion: null, + content: null, + }); + } + } else { + closePreview(); + } + }, + [preview.filename, closePreview] + ); + + return { + preview, + previewArtifact, + isLoading, + openPreview, + navigateToVersion, + closePreview, + setPreviewByArtifact, + }; +}; diff --git a/client/webui/frontend/src/lib/hooks/useFeedback.ts b/client/webui/frontend/src/lib/hooks/useFeedback.ts new file mode 100644 index 000000000..2411dda38 --- /dev/null +++ b/client/webui/frontend/src/lib/hooks/useFeedback.ts @@ -0,0 +1,65 @@ +import { useState, useCallback } from "react"; +import { api } from "@/lib/api"; + +export interface FeedbackData { + type: "up" | "down"; + text: string; +} + +interface UseFeedbackOptions { + sessionId: string; + onError?: (title: string, error: string) => void; +} + +interface UseFeedbackReturn { + submittedFeedback: Record; + submitFeedback: (taskId: string, feedbackType: "up" | "down", feedbackText: string) => Promise; + setSubmittedFeedback: React.Dispatch>>; +} + +/** + * Custom hook to manage user feedback for chat tasks + * Handles submission and tracking of thumbs up/down feedback + */ +export const useFeedback = ({ sessionId, onError }: UseFeedbackOptions): UseFeedbackReturn => { + const [submittedFeedback, setSubmittedFeedback] = useState>({}); + + /** + * Submit feedback for a specific task + */ + const submitFeedback = useCallback( + async (taskId: string, feedbackType: "up" | "down", feedbackText: string) => { + if (!sessionId) { + console.error("Cannot submit feedback without a session ID."); + onError?.("Feedback Failed", "No active session found."); + return; + } + + try { + await api.webui.post("/api/v1/feedback", { + taskId, + sessionId, + feedbackType, + feedbackText, + }); + + // Update local state on success + setSubmittedFeedback(prev => ({ + ...prev, + [taskId]: { type: feedbackType, text: feedbackText }, + })); + } catch (error) { + console.error("Failed to submit feedback:", error); + onError?.("Feedback Submission Failed", "Failed to submit feedback. Please try again."); + throw error; + } + }, + [sessionId, onError] + ); + + return { + submittedFeedback, + submitFeedback, + setSubmittedFeedback, + }; +}; diff --git a/client/webui/frontend/src/lib/hooks/useSidePanel.ts b/client/webui/frontend/src/lib/hooks/useSidePanel.ts new file mode 100644 index 000000000..cf3bf2c7b --- /dev/null +++ b/client/webui/frontend/src/lib/hooks/useSidePanel.ts @@ -0,0 +1,60 @@ +import { useState, useCallback, type Dispatch, type SetStateAction } from "react"; + +export interface SidePanelState { + isCollapsed: boolean; + activeTab: "files" | "workflow"; + taskId: string | null; +} + +interface UseSidePanelOptions { + defaultTab?: "files" | "workflow"; + defaultCollapsed?: boolean; +} + +interface UseSidePanelReturn { + isCollapsed: boolean; + activeTab: "files" | "workflow"; + taskId: string | null; + setCollapsed: Dispatch>; + setActiveTab: Dispatch>; + setTaskId: Dispatch>; + openTab: (tab: "files" | "workflow") => void; +} + +/** + * Custom hook to manage side panel UI state + * Handles panel collapse/expand, active tab selection, and task ID for workflow visualization + */ +export const useSidePanel = ({ defaultTab = "files", defaultCollapsed = true }: UseSidePanelOptions = {}): UseSidePanelReturn => { + const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); + const [activeTab, setActiveTab] = useState<"files" | "workflow">(defaultTab); + const [taskId, setTaskId] = useState(null); + + /** + * Open the side panel to a specific tab + * Also dispatches a custom event for components that need to know + */ + const openTab = useCallback((tab: "files" | "workflow") => { + setIsCollapsed(false); + setActiveTab(tab); + + // Dispatch event for other components (e.g., ChatInputArea) + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("expand-side-panel", { + detail: { tab }, + }) + ); + } + }, []); + + return { + isCollapsed, + activeTab, + taskId, + setCollapsed: setIsCollapsed, + setActiveTab, + setTaskId, + openTab, + }; +}; diff --git a/client/webui/frontend/src/lib/providers/ChatProvider.tsx b/client/webui/frontend/src/lib/providers/ChatProvider.tsx index 5b4aae1df..feae42879 100644 --- a/client/webui/frontend/src/lib/providers/ChatProvider.tsx +++ b/client/webui/frontend/src/lib/providers/ChatProvider.tsx @@ -1,17 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { useState, useCallback, useEffect, useRef, useMemo, type FormEvent, type ReactNode } from "react"; +import React, { useState, useCallback, useEffect, useRef, type FormEvent, type ReactNode } from "react"; import { v4 } from "uuid"; -import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor } from "@/lib/hooks"; -import { useProjectContext, registerProjectDeletedCallback } from "@/lib/providers"; - -import { getAccessToken, getErrorMessage } from "@/lib/utils/api"; -import { createFileSizeErrorMessage } from "@/lib/utils/file-validation"; import { api } from "@/lib/api"; import { ChatContext, type ChatContextValue, type PendingPromptData } from "@/lib/contexts"; +import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor, useArtifactPreview, useSidePanel, useFeedback, useArtifactOperations } from "@/lib/hooks"; +import { useProjectContext, registerProjectDeletedCallback } from "@/lib/providers"; +import { fileToBase64, getAccessToken, getErrorMessage, INLINE_FILE_SIZE_LIMIT_BYTES } from "@/lib/utils"; +import { migrateTask, CURRENT_SCHEMA_VERSION } from "@/lib/utils/taskMigration"; + import type { - ArtifactInfo, - ArtifactRenderingState, CancelTaskRequest, DataPart, FileAttachment, @@ -31,48 +29,55 @@ import type { ArtifactPart, AgentCardInfo, Project, + StoredTaskData, } from "@/lib/types"; -// Type for tasks loaded from the API -interface TaskFromAPI { - taskId: string; - messageBubbles: string; // JSON string - taskMetadata: string | null; // JSON string - createdTime: number; - userMessage?: string; -} - -// Schema version for data migration purposes -const CURRENT_SCHEMA_VERSION = 1; - -// Migration function: V0 -> V1 (adds schema_version to tasks without one) -const migrateV0ToV1 = (task: any): any => { +// Helper function to create an artifact part +const getArtifactPart = (sessionId: string, filename: string) => { return { - ...task, - taskMetadata: { - ...task.taskMetadata, - schema_version: 1, + kind: "artifact", + status: "completed", + name: filename, + file: { + name: filename, + uri: `artifact://${sessionId}/${filename}`, }, }; }; -// Migration registry: maps version numbers to migration functions - -const MIGRATIONS: Record any> = { - 0: migrateV0ToV1, - // Uncomment when future branch merges: - // 1: migrateV1ToV2, +// Helper function to extract artifact markers and create artifact parts +const extractArtifactMarkers = (text: string, sessionId: string, addedArtifacts: Set, processedParts: any[]) => { + // Extract artifact_return markers + const returnRegex = /«artifact_return:([^»]+)»/g; + let match; + while ((match = returnRegex.exec(text)) !== null) { + const artifactFilename = match[1]; + if (!addedArtifacts.has(artifactFilename)) { + addedArtifacts.add(artifactFilename); + processedParts.push(getArtifactPart(sessionId, artifactFilename)); + } + } + + // Extract artifact: markers + const artifactRegex = /«artifact:([^»]+)»/g; + while ((match = artifactRegex.exec(text)) !== null) { + const artifactFilename = match[1]; + if (!addedArtifacts.has(artifactFilename)) { + addedArtifacts.add(artifactFilename); + processedParts.push(getArtifactPart(sessionId, artifactFilename)); + } + } }; -const INLINE_FILE_SIZE_LIMIT_BYTES = 1 * 1024 * 1024; // 1 MB +// Helper function to remove artifact and status update markers from text strings +const removeArtifactMarkers = (textContent: string): string => { + if (!textContent) return textContent; -const fileToBase64 = (file: File): Promise => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve((reader.result as string).split(",")[1]); - reader.onerror = error => reject(error); - }); + return textContent + .replace(/«artifact_return:[^»]+»/g, "") + .replace(/«artifact:[^»]+»/g, "") + .replace(/«status_update:[^»]+»\n?/g, ""); +}; interface ChatProviderProps { children: ReactNode; @@ -95,10 +100,6 @@ export const ChatProvider: React.FC = ({ children }) => { const savingTasksRef = useRef>(new Set()); - // Track in-flight artifact preview fetches to prevent duplicates - const artifactFetchInProgressRef = useRef>(new Set()); - const artifactDownloadInProgressRef = useRef>(new Set()); - // Track isCancelling in ref to access in async callbacks const isCancellingRef = useRef(isCancelling); useEffect(() => { @@ -110,8 +111,6 @@ export const ChatProvider: React.FC = ({ children }) => { useEffect(() => { currentSessionIdRef.current = sessionId; }, [sessionId]); - - const [taskIdInSidePanel, setTaskIdInSidePanel] = useState(null); const cancelTimeoutRef = useRef(null); const isFinalizing = useRef(false); const latestStatusText = useRef(null); @@ -119,44 +118,50 @@ export const ChatProvider: React.FC = ({ children }) => { const backgroundTasksRef = useRef([]); const messagesRef = useRef([]); - // Agents State + // Agents const { agents, agentNameMap: agentNameDisplayNameMap, error: agentsError, isLoading: agentsLoading, refetch: agentsRefetch } = useAgentCards(); - // Chat Side Panel State + // Artfiacts const { artifacts, isLoading: artifactsLoading, refetch: artifactsRefetch, setArtifacts } = useArtifacts(sessionId); - // Side Panel Control State - const [isSidePanelCollapsed, setIsSidePanelCollapsed] = useState(true); - const [activeSidePanelTab, setActiveSidePanelTab] = useState<"files" | "workflow">("files"); - - // Delete Modal State - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [artifactToDelete, setArtifactToDelete] = useState(null); - - // Chat Side Panel Edit Mode State - const [isArtifactEditMode, setIsArtifactEditMode] = useState(false); - const [selectedArtifactFilenames, setSelectedArtifactFilenames] = useState>(new Set()); - const [isBatchDeleteModalOpen, setIsBatchDeleteModalOpen] = useState(false); - - // Preview State - const [previewArtifactFilename, setPreviewArtifactFilename] = useState(null); - const [previewedArtifactAvailableVersions, setPreviewedArtifactAvailableVersions] = useState(null); - const [currentPreviewedVersionNumber, setCurrentPreviewedVersionNumber] = useState(null); - const [previewFileContent, setPreviewFileContent] = useState(null); - - // Derive previewArtifact from artifacts array to ensure it's always up-to-date - const previewArtifact = useMemo(() => { - if (!previewArtifactFilename) return null; - return artifacts.find(a => a.filename === previewArtifactFilename) || null; - }, [artifacts, previewArtifactFilename]); - - // Artifact Rendering State - const [artifactRenderingState, setArtifactRenderingState] = useState({ - expandedArtifacts: new Set(), + // Side Panel + const { + isCollapsed: isSidePanelCollapsed, + activeTab: activeSidePanelTab, + taskId: taskIdInSidePanel, + setCollapsed: setIsSidePanelCollapsed, + setActiveTab: setActiveSidePanelTab, + setTaskId: setTaskIdInSidePanel, + openTab: openSidePanelTab, + } = useSidePanel({ + defaultTab: "files", + defaultCollapsed: true, }); - // Feedback State - const [submittedFeedback, setSubmittedFeedback] = useState>({}); + // Artifact Preview + const { + preview: artifactPreview, + previewArtifact, + openPreview: openArtifactForPreview, + navigateToVersion: navigateArtifactVersion, + closePreview: closeArtifactPreview, + setPreviewByArtifact: setPreviewArtifact, + } = useArtifactPreview({ + sessionId, + projectId: activeProject?.id, + artifacts, + onError: (title, error) => setError({ title, error }), + }); + + // Feedback + const { + submittedFeedback, + submitFeedback: handleFeedbackSubmit, + setSubmittedFeedback, + } = useFeedback({ + sessionId, + onError: (title, error) => setError({ title, error }), + }); // Pending prompt state for starting new chat with a prompt template const [pendingPrompt, setPendingPrompt] = useState(null); @@ -304,133 +309,66 @@ export const ChatProvider: React.FC = ({ children }) => { [sessionId, persistenceEnabled] ); - // Helper function to extract artifact markers and create artifact parts - const extractArtifactMarkers = useCallback((text: string, sessionId: string, addedArtifacts: Set, processedParts: any[]) => { - const ARTIFACT_RETURN_REGEX = /«artifact_return:([^»]+)»/g; - const ARTIFACT_REGEX = /«artifact:([^»]+)»/g; - - const createArtifactPart = (filename: string) => ({ - kind: "artifact", - status: "completed", - name: filename, - file: { - name: filename, - uri: `artifact://${sessionId}/${filename}`, - }, - }); - - // Extract artifact_return markers - let match; - while ((match = ARTIFACT_RETURN_REGEX.exec(text)) !== null) { - const artifactFilename = match[1]; - if (!addedArtifacts.has(artifactFilename)) { - addedArtifacts.add(artifactFilename); - processedParts.push(createArtifactPart(artifactFilename)); - } - } - - // Extract artifact: markers - while ((match = ARTIFACT_REGEX.exec(text)) !== null) { - const artifactFilename = match[1]; - if (!addedArtifacts.has(artifactFilename)) { - addedArtifacts.add(artifactFilename); - processedParts.push(createArtifactPart(artifactFilename)); - } - } - }, []); - // Helper function to deserialize task data to MessageFE objects - const deserializeTaskToMessages = useCallback( - (task: { taskId: string; messageBubbles: any[]; taskMetadata?: any; createdTime: number }, sessionId: string): MessageFE[] => { - return task.messageBubbles.map(bubble => { - // Process parts to handle markers and reconstruct artifact parts if needed - const processedParts: any[] = []; - const originalParts = bubble.parts || [{ kind: "text", text: bubble.text || "" }]; - - // Track artifact names we've already added to avoid duplicates - const addedArtifacts = new Set(); - - // First, check the bubble.text field for artifact markers (TaskLoggerService saves markers there) - // This handles the case where backend saves text with markers but parts without artifacts - if (bubble.text) { - extractArtifactMarkers(bubble.text, sessionId, addedArtifacts, processedParts); - } - - for (const part of originalParts) { - if (part.kind === "text" && part.text) { - let textContent = part.text; - - // Extract artifact markers and convert them to artifact parts - extractArtifactMarkers(textContent, sessionId, addedArtifacts, processedParts); + const deserializeTaskToMessages = useCallback((task: { taskId: string; messageBubbles: any[]; taskMetadata?: any; createdTime: number }, sessionId: string): MessageFE[] => { + return task.messageBubbles.map(bubble => { + // Process parts to handle markers and reconstruct artifact parts if needed + const processedParts: any[] = []; + const originalParts = bubble.parts || [{ kind: "text", text: bubble.text || "" }]; + + // Track artifact names we've already added to avoid duplicates + const addedArtifacts = new Set(); + + // First, check the bubble.text field for artifact markers (TaskLoggerService saves markers there) + // This handles the case where backend saves text with markers but parts without artifacts + if (bubble.text) { + extractArtifactMarkers(bubble.text, sessionId, addedArtifacts, processedParts); + } - // Remove artifact markers from text content - textContent = textContent.replace(/«artifact_return:[^»]+»/g, ""); - textContent = textContent.replace(/«artifact:[^»]+»/g, ""); + for (const part of originalParts) { + if (part.kind === "text" && part.text) { + let textContent = part.text; - // Remove status update markers - textContent = textContent.replace(/«status_update:[^»]+»\n?/g, ""); + // Extract artifact markers and convert them to artifact parts + extractArtifactMarkers(textContent, sessionId, addedArtifacts, processedParts); + // Remove artifact markers from text content + textContent = removeArtifactMarkers(textContent); - // Add text part if there's content - if (textContent.trim()) { - processedParts.push({ kind: "text", text: textContent }); - } - } else if (part.kind === "artifact") { - // Only add artifact part if not already added (from markers) - const artifactName = part.name; - if (artifactName && !addedArtifacts.has(artifactName)) { - addedArtifacts.add(artifactName); - processedParts.push(part); - } - // Skip duplicate artifacts - } else { - // Keep other non-text parts as-is + // Add text part if there's content + if (textContent.trim()) { + processedParts.push({ kind: "text", text: textContent }); + } + } else if (part.kind === "artifact") { + // Only add artifact part if not already added (from markers) + const artifactName = part.name; + if (artifactName && !addedArtifacts.has(artifactName)) { + addedArtifacts.add(artifactName); processedParts.push(part); } + // Skip duplicate artifacts + } else { + // Keep other non-text parts as-is + processedParts.push(part); } - - return { - taskId: task.taskId, - role: bubble.type === "user" ? "user" : "agent", - parts: processedParts, - isUser: bubble.type === "user", - isComplete: true, - files: bubble.files, - uploadedFiles: bubble.uploadedFiles, - artifactNotification: bubble.artifactNotification, - isError: bubble.isError, - metadata: { - messageId: bubble.id, - sessionId: sessionId, - lastProcessedEventSequence: 0, - }, - }; - }); - }, - [extractArtifactMarkers] - ); - - // Helper function to apply migrations to a task - const migrateTask = useCallback((task: any): any => { - const version = task.taskMetadata?.schema_version || 0; - - if (version >= CURRENT_SCHEMA_VERSION) { - // Already at current version - return task; - } - - // Apply migrations sequentially - let migratedTask = task; - for (let v = version; v < CURRENT_SCHEMA_VERSION; v++) { - const migrationFunc = MIGRATIONS[v]; - if (migrationFunc) { - migratedTask = migrationFunc(migratedTask); - console.log(`Migrated task ${task.taskId} from v${v} to v${v + 1}`); - } else { - console.warn(`No migration function found for version ${v}`); } - } - return migratedTask; + return { + taskId: task.taskId, + role: bubble.type === "user" ? "user" : "agent", + parts: processedParts, + isUser: bubble.type === "user", + isComplete: true, + files: bubble.files, + uploadedFiles: bubble.uploadedFiles, + artifactNotification: bubble.artifactNotification, + isError: bubble.isError, + metadata: { + messageId: bubble.id, + sessionId: sessionId, + lastProcessedEventSequence: 0, + }, + }; + }); }, []); // Helper function to load session tasks and reconstruct messages @@ -446,11 +384,15 @@ export const ChatProvider: React.FC = ({ children }) => { // Parse JSON strings from backend const tasks = data.tasks || []; - const parsedTasks = tasks.map((task: TaskFromAPI) => ({ - ...task, - messageBubbles: JSON.parse(task.messageBubbles), - taskMetadata: task.taskMetadata ? JSON.parse(task.taskMetadata) : null, - })); + const parsedTasks = tasks.map((task: StoredTaskData) => { + return { + taskId: task.taskId, + createdTime: task.createdTime, + userMessage: task.userMessage, + messageBubbles: JSON.parse(task.messageBubbles), + taskMetadata: task.taskMetadata ? JSON.parse(task.taskMetadata) : null, + }; + }); // Apply migrations to each task const migratedTasks = parsedTasks.map(migrateTask); @@ -498,294 +440,42 @@ export const ChatProvider: React.FC = ({ children }) => { setTaskIdInSidePanel(mostRecentTask.taskId); } }, - [deserializeTaskToMessages, migrateTask] + [deserializeTaskToMessages, setSubmittedFeedback, setTaskIdInSidePanel] ); - const uploadArtifactFile = useCallback( - async (file: File, overrideSessionId?: string, description?: string, silent: boolean = false): Promise<{ uri: string; sessionId: string } | { error: string } | null> => { - const effectiveSessionId = overrideSessionId || sessionId; - const formData = new FormData(); - formData.append("upload_file", file); - formData.append("filename", file.name); - // Send sessionId as form field (can be empty string for new sessions) - formData.append("sessionId", effectiveSessionId || ""); - - // Add description as metadata if provided - if (description) { - const metadata = { description }; - formData.append("metadata_json", JSON.stringify(metadata)); - } - - try { - const response = await api.webui.post("/api/v1/artifacts/upload", formData, { fullResponse: true }); - - if (response.status === 413) { - const errorData = await response.json().catch(() => ({ message: `Failed to upload ${file.name}.` })); - const actualSize = errorData.actual_size_bytes; - const maxSize = errorData.max_size_bytes; - const errorMessage = actualSize && maxSize ? createFileSizeErrorMessage(file.name, actualSize, maxSize) : errorData.message || `File "${file.name}" exceeds the maximum allowed size.`; - setError({ title: "File Upload Failed", error: errorMessage }); - return { error: errorMessage }; - } - - if (!response.ok) { - throw new Error( - await response - .json() - .then((d: { message?: string }) => d.message) - .catch(() => `Failed to upload ${file.name}.`) - ); - } - - const result = await response.json(); - if (!silent) { - addNotification(`File "${file.name}" uploaded.`, "success"); - } - await artifactsRefetch(); - return result.uri && result.sessionId ? { uri: result.uri, sessionId: result.sessionId } : null; - } catch (error) { - const errorMessage = getErrorMessage(error, `Failed to upload "${file.name}".`); - setError({ title: "File Upload Failed", error: errorMessage }); - return { error: errorMessage }; - } - }, - [sessionId, addNotification, artifactsRefetch, setError] - ); + // Artifact Operations (using custom hook) + const { + uploadArtifactFile, + isDeleteModalOpen, + artifactToDelete, + openDeleteModal, + closeDeleteModal, + confirmDelete, + isArtifactEditMode, + setIsArtifactEditMode, + selectedArtifactFilenames, + setSelectedArtifactFilenames, + isBatchDeleteModalOpen, + setIsBatchDeleteModalOpen, + handleDeleteSelectedArtifacts, + confirmBatchDeleteArtifacts, + downloadAndResolveArtifact, + } = useArtifactOperations({ + sessionId, + artifacts, + setArtifacts, + artifactsRefetch, + onNotification: addNotification, + onError: (title, error) => setError({ title, error }), + previewArtifact, + closeArtifactPreview, + }); // Session State const [sessionName, setSessionName] = useState(null); const [sessionToDelete, setSessionToDelete] = useState(null); const [isLoadingSession, setIsLoadingSession] = useState(false); - const deleteArtifactInternal = useCallback( - async (filename: string) => { - try { - await api.webui.delete(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}`); - addNotification(`File "${filename}" deleted.`, "success"); - artifactsRefetch(); - } catch (error) { - setError({ title: "File Deletion Failed", error: getErrorMessage(error, `Failed to delete ${filename}.`) }); - } - }, - [sessionId, addNotification, artifactsRefetch, setError] - ); - - const openDeleteModal = useCallback((artifact: ArtifactInfo) => { - setArtifactToDelete(artifact); - setIsDeleteModalOpen(true); - }, []); - - const closeDeleteModal = useCallback(() => { - setArtifactToDelete(null); - setIsDeleteModalOpen(false); - }, []); - - // Wrapper function to set preview artifact by filename - // IMPORTANT: Must be defined before confirmDelete to avoid circular dependency - const setPreviewArtifact = useCallback((artifact: ArtifactInfo | null) => { - setPreviewArtifactFilename(artifact?.filename || null); - }, []); - - const confirmDelete = useCallback(async () => { - if (artifactToDelete) { - // Check if the artifact being deleted is currently being previewed - const isCurrentlyPreviewed = previewArtifact?.filename === artifactToDelete.filename; - - await deleteArtifactInternal(artifactToDelete.filename); - - // If the deleted artifact was being previewed, go back to file list - if (isCurrentlyPreviewed) { - setPreviewArtifact(null); - } - } - closeDeleteModal(); - }, [artifactToDelete, deleteArtifactInternal, closeDeleteModal, previewArtifact, setPreviewArtifact]); - - const handleDeleteSelectedArtifacts = useCallback(() => { - if (selectedArtifactFilenames.size === 0) { - return; - } - setIsBatchDeleteModalOpen(true); - }, [selectedArtifactFilenames]); - - const confirmBatchDeleteArtifacts = useCallback(async () => { - setIsBatchDeleteModalOpen(false); - const filenamesToDelete = Array.from(selectedArtifactFilenames); - let successCount = 0; - let errorCount = 0; - for (const filename of filenamesToDelete) { - try { - await api.webui.delete(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}`); - successCount++; - } catch (error: unknown) { - console.error(error); - errorCount++; - } - } - if (successCount > 0) addNotification(`${successCount} files(s) deleted.`, "success"); - if (errorCount > 0) { - setError({ title: "File Deletion Failed", error: `${errorCount} file(s) failed to delete.` }); - } - artifactsRefetch(); - setSelectedArtifactFilenames(new Set()); - setIsArtifactEditMode(false); - }, [selectedArtifactFilenames, addNotification, artifactsRefetch, sessionId, setError]); - - const openArtifactForPreview = useCallback( - async (artifactFilename: string): Promise => { - // Prevent duplicate fetches for the same file - if (artifactFetchInProgressRef.current.has(artifactFilename)) { - return null; - } - - // Mark this file as being fetched - artifactFetchInProgressRef.current.add(artifactFilename); - - // Only clear state if this is a different file from what we're currently previewing - // This prevents clearing state during duplicate fetch attempts - if (previewArtifactFilename !== artifactFilename) { - setPreviewedArtifactAvailableVersions(null); - setCurrentPreviewedVersionNumber(null); - setPreviewFileContent(null); - } - try { - // Determine the correct URL based on context - let versionsUrl: string; - if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") { - versionsUrl = `/api/v1/artifacts/${sessionId}/${encodeURIComponent(artifactFilename)}/versions`; - } else if (activeProject?.id) { - versionsUrl = `/api/v1/artifacts/null/${encodeURIComponent(artifactFilename)}/versions?project_id=${activeProject.id}`; - } else { - throw new Error("No valid context for artifact preview"); - } - - const availableVersions: number[] = await api.webui.get(versionsUrl); - if (!availableVersions || availableVersions.length === 0) throw new Error("No versions available"); - setPreviewedArtifactAvailableVersions(availableVersions.sort((a, b) => a - b)); - const latestVersion = Math.max(...availableVersions); - setCurrentPreviewedVersionNumber(latestVersion); - let contentUrl: string; - if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") { - contentUrl = `/api/v1/artifacts/${sessionId}/${encodeURIComponent(artifactFilename)}/versions/${latestVersion}`; - } else if (activeProject?.id) { - contentUrl = `/api/v1/artifacts/null/${encodeURIComponent(artifactFilename)}/versions/${latestVersion}?project_id=${activeProject.id}`; - } else { - throw new Error("No valid context for artifact content"); - } - - const contentResponse = await api.webui.get(contentUrl, { fullResponse: true }); - if (!contentResponse.ok) { - throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); - } - - // Get MIME type from response headers - this is the correct MIME type for this specific version - const contentType = contentResponse.headers.get("Content-Type") || "application/octet-stream"; - // Strip charset and other parameters from Content-Type - const mimeType = contentType.split(";")[0].trim(); - - const blob = await contentResponse.blob(); - const base64Content = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - const artifactInfo = artifacts.find(art => art.filename === artifactFilename); - const fileData: FileAttachment = { - name: artifactFilename, - // Use MIME type from response headers (version-specific), not from artifact list (latest version) - mime_type: mimeType, - content: base64Content, - last_modified: artifactInfo?.last_modified || new Date().toISOString(), - }; - setPreviewFileContent(fileData); - return fileData; - } catch (error) { - setError({ title: "Artifact Preview Failed", error: getErrorMessage(error, "Failed to load artifact preview.") }); - return null; - } finally { - // Remove from in-progress set immediately when done - artifactFetchInProgressRef.current.delete(artifactFilename); - } - }, - [sessionId, activeProject?.id, artifacts, previewArtifactFilename, setError] - ); - - const navigateArtifactVersion = useCallback( - async (artifactFilename: string, targetVersion: number): Promise => { - // If versions aren't loaded yet, this is likely a timing issue where this was called - // before openArtifactForPreview completed. Just silently return - the artifact will - // show the latest version when loaded, which is acceptable behavior. - if (!previewedArtifactAvailableVersions || previewedArtifactAvailableVersions.length === 0) { - return null; - } - - // Now check if the specific version exists - if (!previewedArtifactAvailableVersions.includes(targetVersion)) { - console.warn(`Requested version ${targetVersion} not available for ${artifactFilename}`); - return null; - } - setPreviewFileContent(null); - try { - // Determine the correct URL based on context - let contentUrl: string; - if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") { - contentUrl = `/api/v1/artifacts/${sessionId}/${encodeURIComponent(artifactFilename)}/versions/${targetVersion}`; - } else if (activeProject?.id) { - contentUrl = `/api/v1/artifacts/null/${encodeURIComponent(artifactFilename)}/versions/${targetVersion}?project_id=${activeProject.id}`; - } else { - throw new Error("No valid context for artifact navigation"); - } - - const contentResponse = await api.webui.get(contentUrl, { fullResponse: true }); - if (!contentResponse.ok) { - throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); - } - - // Get MIME type from response headers - this is the correct MIME type for this specific version - const contentType = contentResponse.headers.get("Content-Type") || "application/octet-stream"; - // Strip charset and other parameters from Content-Type - const mimeType = contentType.split(";")[0].trim(); - - const blob = await contentResponse.blob(); - const base64Content = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - const artifactInfo = artifacts.find(art => art.filename === artifactFilename); - const fileData: FileAttachment = { - name: artifactFilename, - // Use MIME type from response headers (version-specific), not from artifact list (latest version) - mime_type: mimeType, - content: base64Content, - last_modified: artifactInfo?.last_modified || new Date().toISOString(), - }; - setCurrentPreviewedVersionNumber(targetVersion); - setPreviewFileContent(fileData); - return fileData; - } catch (error) { - setError({ title: "Artifact Version Preview Failed", error: getErrorMessage(error, "Failed to fetch artifact version.") }); - return null; - } - }, - [artifacts, previewedArtifactAvailableVersions, sessionId, activeProject?.id, setError] - ); - - const openSidePanelTab = useCallback((tab: "files" | "workflow") => { - setIsSidePanelCollapsed(false); - setActiveSidePanelTab(tab); - - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("expand-side-panel", { - detail: { tab }, - }) - ); - } - }, []); - const closeCurrentEventSource = useCallback(() => { if (cancelTimeoutRef.current) { clearTimeout(cancelTimeoutRef.current); @@ -800,77 +490,6 @@ export const ChatProvider: React.FC = ({ children }) => { isFinalizing.current = false; }, []); - // Download and resolve artifact with embeds - const downloadAndResolveArtifact = useCallback( - async (filename: string): Promise => { - // Prevent duplicate downloads for the same file - if (artifactDownloadInProgressRef.current.has(filename)) { - console.log(`[ChatProvider] Skipping duplicate download for ${filename} - already in progress`); - return null; - } - - // Mark this file as being downloaded - artifactDownloadInProgressRef.current.add(filename); - - try { - // Find the artifact in state - const artifact = artifacts.find(art => art.filename === filename); - if (!artifact) { - console.error(`Artifact ${filename} not found in state`); - return null; - } - - // Fetch the latest version with embeds resolved - const availableVersions: number[] = await api.webui.get(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}/versions`); - if (!availableVersions || availableVersions.length === 0) { - throw new Error("No versions available"); - } - - const latestVersion = Math.max(...availableVersions); - const contentResponse = await api.webui.get(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}/versions/${latestVersion}`, { fullResponse: true }); - if (!contentResponse.ok) { - throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); - } - const blob = await contentResponse.blob(); - const base64Content = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - - const fileData: FileAttachment = { - name: filename, - mime_type: artifact.mime_type || "application/octet-stream", - content: base64Content, - last_modified: artifact.last_modified || new Date().toISOString(), - }; - - // Clear the accumulated content and flags after successful download - setArtifacts(prevArtifacts => { - return prevArtifacts.map(art => - art.filename === filename - ? { - ...art, - accumulatedContent: undefined, - needsEmbedResolution: false, - } - : art - ); - }); - - return fileData; - } catch (error) { - setError({ title: "File Download Failed", error: getErrorMessage(error, `Failed to download ${filename}.`) }); - return null; - } finally { - // Remove from in-progress set immediately when done - artifactDownloadInProgressRef.current.delete(filename); - } - }, - [sessionId, artifacts, setArtifacts, setError] - ); - const handleSseMessage = useCallback( (event: MessageEvent) => { sseEventSequenceRef.current += 1; @@ -1464,7 +1083,7 @@ export const ChatProvider: React.FC = ({ children }) => { // Note: No session events dispatched here since no session exists yet. // Session creation event will be dispatched when first message creates the actual session. }, - [isResponding, currentTaskId, selectedAgentName, isCancelling, closeCurrentEventSource, activeProject, setActiveProject, setPreviewArtifact, isTaskRunningInBackground] + [closeCurrentEventSource, isResponding, currentTaskId, selectedAgentName, isCancelling, activeProject, setTaskIdInSidePanel, setPreviewArtifact, isTaskRunningInBackground, setActiveProject] ); // Start a new chat session with a prompt template pre-filled @@ -1606,22 +1225,23 @@ export const ChatProvider: React.FC = ({ children }) => { } }, [ + backgroundTasks, closeCurrentEventSource, isResponding, currentTaskId, selectedAgentName, isCancelling, + sessionId, + isTaskRunningInBackground, + setTaskIdInSidePanel, + setPreviewArtifact, loadSessionTasks, activeProject, projects, setActiveProject, - setPreviewArtifact, - setError, - backgroundTasks, checkTaskStatus, - sessionId, unregisterBackgroundTask, - isTaskRunningInBackground, + setError, ] ); @@ -1669,31 +1289,6 @@ export const ChatProvider: React.FC = ({ children }) => { [addNotification, handleNewSession, sessionId, setError] ); - // Artifact Rendering Actions - const toggleArtifactExpanded = useCallback((filename: string) => { - setArtifactRenderingState(prevState => { - const newExpandedArtifacts = new Set(prevState.expandedArtifacts); - - if (newExpandedArtifacts.has(filename)) { - newExpandedArtifacts.delete(filename); - } else { - newExpandedArtifacts.add(filename); - } - - return { - ...prevState, - expandedArtifacts: newExpandedArtifacts, - }; - }); - }, []); - - const isArtifactExpanded = useCallback( - (filename: string) => { - return artifactRenderingState.expandedArtifacts.has(filename); - }, - [artifactRenderingState.expandedArtifacts] - ); - // Artifact Display and Cache Management const markArtifactAsDisplayed = useCallback((filename: string, displayed: boolean) => { setArtifacts(prevArtifacts => { @@ -1758,31 +1353,6 @@ export const ChatProvider: React.FC = ({ children }) => { } }, [isResponding, isCancelling, currentTaskId, addNotification, setError, closeCurrentEventSource]); - const handleFeedbackSubmit = useCallback( - async (taskId: string, feedbackType: "up" | "down", feedbackText: string) => { - if (!sessionId) { - console.error("Cannot submit feedback without a session ID."); - return; - } - try { - await api.webui.post("/api/v1/feedback", { - taskId, - sessionId, - feedbackType, - feedbackText, - }); - setSubmittedFeedback(prev => ({ - ...prev, - [taskId]: { type: feedbackType, text: feedbackText }, - })); - } catch (error) { - console.error("Failed to submit feedback:", error); - throw error; - } - }, - [sessionId] - ); - const handleSseOpen = useCallback(() => { /* console.log for SSE open */ }, []); @@ -2099,20 +1669,21 @@ export const ChatProvider: React.FC = ({ children }) => { } }, [ - sessionId, isResponding, isCancelling, selectedAgentName, closeCurrentEventSource, + sessionId, + backgroundTasksEnabled, + activeProject?.id, + setTaskIdInSidePanel, uploadArtifactFile, - updateSessionName, - saveTaskToBackend, - serializeMessageBubble, - activeProject, cleanupUploadedFiles, setError, backgroundTasksDefaultTimeoutMs, - backgroundTasksEnabled, + updateSessionName, + saveTaskToBackend, + serializeMessageBubble, registerBackgroundTask, ] ); @@ -2401,22 +1972,16 @@ export const ChatProvider: React.FC = ({ children }) => { confirmBatchDeleteArtifacts, isBatchDeleteModalOpen, setIsBatchDeleteModalOpen, - previewedArtifactAvailableVersions, - currentPreviewedVersionNumber, - previewFileContent, + previewedArtifactAvailableVersions: artifactPreview.availableVersions, + currentPreviewedVersionNumber: artifactPreview.currentVersion, + previewFileContent: artifactPreview.content, openArtifactForPreview, navigateArtifactVersion, previewArtifact, - setPreviewArtifact, // Now uses the wrapper function that sets filename + setPreviewArtifact, updateSessionName, deleteSession, - /** Artifact Rendering Actions */ - toggleArtifactExpanded, - isArtifactExpanded, - setArtifactRenderingState, - artifactRenderingState, - /** Artifact Display and Cache Management */ markArtifactAsDisplayed, downloadAndResolveArtifact, diff --git a/client/webui/frontend/src/lib/types/fe.ts b/client/webui/frontend/src/lib/types/fe.ts index 4337df263..36ea023ca 100644 --- a/client/webui/frontend/src/lib/types/fe.ts +++ b/client/webui/frontend/src/lib/types/fe.ts @@ -115,13 +115,6 @@ export interface ArtifactPart { export type PartFE = Part | ArtifactPart; -/** - * State for managing artifact rendering preferences and expanded state - */ -export interface ArtifactRenderingState { - expandedArtifacts: Set; -} - /** * Represents a single message in the chat conversation. */ diff --git a/client/webui/frontend/src/lib/types/storage.ts b/client/webui/frontend/src/lib/types/storage.ts index 8058dc3fd..428f321d4 100644 --- a/client/webui/frontend/src/lib/types/storage.ts +++ b/client/webui/frontend/src/lib/types/storage.ts @@ -36,8 +36,16 @@ export interface TaskMetadata { */ export interface StoredTaskData { taskId: string; - messageBubbles: MessageBubble[]; - taskMetadata?: TaskMetadata | null; + messageBubbles: string; // JSON string + taskMetadata?: string | null; // JSON string createdTime: number; userMessage?: string; } + +/** + * Parsed task structure (after JSON parsing but before migration) + */ +export interface TaskData extends Omit { + messageBubbles: MessageBubble[]; + taskMetadata: TaskMetadata | null; +} diff --git a/client/webui/frontend/src/lib/utils/file.ts b/client/webui/frontend/src/lib/utils/file.ts new file mode 100644 index 000000000..5bcdb278f --- /dev/null +++ b/client/webui/frontend/src/lib/utils/file.ts @@ -0,0 +1,110 @@ +import { api } from "../api"; + +/** + * Converts a File object to a Base64-encoded string. + * @param file - The file to convert + * @returns A promise that resolves to the Base64 string + */ +export const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve((reader.result as string).split(",")[1]); + reader.onerror = error => reject(error); + reader.readAsDataURL(file); + }); + +/** + * Converts a Blob object to a Base64-encoded string. + * @param blob + * @returns A promise that resolves to the Base64 string + */ +export const blobToBase64 = (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +}; + +/** + * Generates an artifact URL for fetching either a list of versions or a specific version. + * + * @param options - Configuration options for building the artifact URL + * @param options.filename - The name of the artifact file + * @param options.sessionId - Optional session ID for session-scoped artifacts + * @param options.projectId - Optional project ID for project-scoped artifacts (used when no session) + * @param options.version - Optional version number. If omitted, returns URL for listing all versions + * @returns The constructed artifact URL + * @throws {Error} When neither sessionId nor projectId is provided + * + * @example + * // Get a specific version with session + * getArtifactUrl({ filename: 'data.json', sessionId: 'abc123', version: 1 }) + * // Returns: '/api/v1/artifacts/abc123/data.json/versions/1' + * + * @example + * // List all versions with session + * getArtifactUrl({ filename: 'data.json', sessionId: 'abc123' }) + * // Returns: '/api/v1/artifacts/abc123/data.json/versions' + * + * @example + * // Get a specific version with project ID + * getArtifactUrl({ filename: 'data.json', projectId: 'proj456', version: 2 }) + * // Returns: '/api/v1/artifacts/null/data.json/versions/2?project_id=proj456' + */ +export const getArtifactUrl = ({ filename, sessionId, projectId, version }: { filename: string; sessionId?: string; projectId?: string; version?: number }): string => { + const isValidSession = sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined"; + const encodedFilename = encodeURIComponent(filename); + + // Build the base path based on whether we have a valid session + const basePath = isValidSession ? `/api/v1/artifacts/${sessionId}/${encodedFilename}/versions` : `/api/v1/artifacts/null/${encodedFilename}/versions`; + + // If version is provided, append it to get a specific version + // Otherwise, return the base path to list all versions + const versionPath = version !== undefined ? `/${version}` : ""; + const url = `${basePath}${versionPath}`; + + // Add projectId query param if needed (when no valid session) + if (!isValidSession && projectId) { + return `${url}?project_id=${projectId}`; + } + + // Validate that at least one context (sessionId or projectId) is provided + if (!isValidSession && !projectId) { + throw new Error("No valid context for artifact: either sessionId or projectId must be provided"); + } + + return url; +}; + +/** + * Retrieves the content and MIME type of a specific artifact version. + * @param options - Configuration options for fetching the artifact content + * @param options.filename - The name of the artifact file + * @param options.sessionId - Optional session ID for session-scoped artifacts + * @param options.projectId - Optional project ID for project-scoped artifacts (used when no session) + * @param options.version - Optional version number. If omitted, fetches the latest version + * @returns A promise that resolves to an object containing the content as a base64 string and the MIME type + * @throws {Error} When the fetch operation fails + */ +export const getArtifactContent = async ({ filename, sessionId, projectId, version }: { filename: string; sessionId?: string; projectId?: string; version?: number }): Promise<{ content: string; mimeType: string }> => { + const contentUrl = getArtifactUrl({ + filename, + sessionId, + projectId, + version, + }); + + const contentResponse = await api.webui.get(contentUrl, { fullResponse: true }); + if (!contentResponse.ok) { + throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); + } + + const contentType = contentResponse.headers.get("Content-Type") || "application/octet-stream"; + const mimeType = contentType.split(";")[0].trim(); + const blob = await contentResponse.blob(); + const content = await blobToBase64(blob); + + return { content, mimeType }; +}; diff --git a/client/webui/frontend/src/lib/utils/file-validation.ts b/client/webui/frontend/src/lib/utils/fileValidation.ts similarity index 98% rename from client/webui/frontend/src/lib/utils/file-validation.ts rename to client/webui/frontend/src/lib/utils/fileValidation.ts index 541242ff3..3784f7c69 100644 --- a/client/webui/frontend/src/lib/utils/file-validation.ts +++ b/client/webui/frontend/src/lib/utils/fileValidation.ts @@ -1,7 +1,9 @@ /** - * File validation utilities for consistent file size validation across the application. + * File validation utilities for consistent file size validation. */ +export const INLINE_FILE_SIZE_LIMIT_BYTES = 1 * 1024 * 1024; // 1 MB + export interface FileSizeValidationResult { valid: boolean; error?: string; diff --git a/client/webui/frontend/src/lib/utils/index.ts b/client/webui/frontend/src/lib/utils/index.ts index 8b12fc469..251ad48e1 100644 --- a/client/webui/frontend/src/lib/utils/index.ts +++ b/client/webui/frontend/src/lib/utils/index.ts @@ -1,8 +1,11 @@ export * from "./api"; export * from "./cnTailwind"; export * from "./download"; +export * from "./file"; +export * from "./fileValidation"; export * from "./format"; export * from "./promptUtils"; +export * from "./taskMigration"; export * from "./textPreprocessor"; export * from "./themeHtmlStyles"; export * from "./guard"; diff --git a/client/webui/frontend/src/lib/utils/taskMigration.ts b/client/webui/frontend/src/lib/utils/taskMigration.ts new file mode 100644 index 000000000..2899bd3ca --- /dev/null +++ b/client/webui/frontend/src/lib/utils/taskMigration.ts @@ -0,0 +1,104 @@ +/** + * Task schema migration utilities + * Handles versioned migrations for task data structures + */ + +import type { TaskData } from "@/lib/types/storage"; + +export const CURRENT_SCHEMA_VERSION = 1; + +// Migration function type +type MigrationFunction = (task: TaskData) => TaskData; + +/** + * Migration V0 -> V1: Add schema_version field to tasks + */ +const migrateV0ToV1: MigrationFunction = task => { + return { + ...task, + taskMetadata: { + ...task.taskMetadata, + schema_version: 1, + }, + }; +}; + +/** + * Future migration: V1 -> V2 + * Uncomment and implement when needed + */ +// const migrateV1ToV2: MigrationFunction = (task) => { +// return { +// ...task, +// taskMetadata: { +// ...task.taskMetadata, +// schema_version: 2, +// // Add new fields here +// }, +// }; +// }; + +/** + * Registry of migration functions + * Key = source version, Value = migration function to next version + */ +const MIGRATIONS: Record = { + 0: migrateV0ToV1, + // 1: migrateV1ToV2, +}; + +/** + * Apply all necessary migrations to bring a task to the current schema version + * @param task - The task to migrate + * @returns The migrated task at the current schema version + */ +export const migrateTask = (task: TaskData): TaskData => { + const version = task.taskMetadata?.schema_version ?? 0; + + // Already at current version + if (version >= CURRENT_SCHEMA_VERSION) { + return task; + } + + // Apply migrations sequentially + let migratedTask = task; + for (let v = version; v < CURRENT_SCHEMA_VERSION; v++) { + const migrationFunc = MIGRATIONS[v]; + + if (migrationFunc) { + migratedTask = migrationFunc(migratedTask); + console.log(`[Migration] Migrated task ${task.taskId} from v${v} to v${v + 1}`); + } else { + console.warn(`[Migration] No migration function found for version ${v}`); + } + } + + return migratedTask; +}; + +/** + * Migrate an array of tasks + * @param tasks - Array of tasks to migrate + * @returns Array of migrated tasks + */ +export const migrateTasks = (tasks: TaskData[]): TaskData[] => { + return tasks.map(migrateTask); +}; + +/** + * Check if a task needs migration + * @param task - The task to check + * @returns true if migration is needed + */ +export const needsMigration = (task: TaskData): boolean => { + const version = task.taskMetadata?.schema_version ?? 0; + return version < CURRENT_SCHEMA_VERSION; +}; + +/** + * Get the current schema version + * @returns The current schema version number + */ +export const getCurrentSchemaVersion = (): number => { + return CURRENT_SCHEMA_VERSION; +}; diff --git a/client/webui/frontend/src/stories/mocks/MockChatProvider.tsx b/client/webui/frontend/src/stories/mocks/MockChatProvider.tsx index 3cbd5ee5b..79de17741 100644 --- a/client/webui/frontend/src/stories/mocks/MockChatProvider.tsx +++ b/client/webui/frontend/src/stories/mocks/MockChatProvider.tsx @@ -62,9 +62,6 @@ const defaultMockChatContext: DefaultMockContextType = { isBatchDeleteModalOpen: false, configCollectFeedback: false, - // Artifact rendering - artifactRenderingState: { expandedArtifacts: new Set() }, - // Background task monitoring backgroundTasks: [], backgroundNotifications: [], @@ -100,9 +97,6 @@ const defaultMockChatContext: DefaultMockContextType = { updateSessionName: async () => {}, deleteSession: async () => {}, handleFeedbackSubmit: async () => {}, - toggleArtifactExpanded: () => {}, - isArtifactExpanded: () => false, - setArtifactRenderingState: () => {}, markArtifactAsDisplayed: () => {}, downloadAndResolveArtifact: async () => null, agentsRefetch: async () => {}, From 88ad96bea7bd5d9c1f01aaf7f2f1e6e360f1cb9e Mon Sep 17 00:00:00 2001 From: lgh-solace Date: Mon, 22 Dec 2025 08:24:55 -0500 Subject: [PATCH 2/9] chore: removing extraneous changes --- .../components/projects/KnowledgeSection.tsx | 2 +- .../projects/ProjectFilesManager.tsx | 2 +- .../src/lib/providers/ChatProvider.tsx | 28 +++++++++---------- .../{fileValidation.ts => file-validation.ts} | 4 +-- client/webui/frontend/src/lib/utils/file.ts | 20 ------------- client/webui/frontend/src/lib/utils/index.ts | 2 +- 6 files changed, 18 insertions(+), 40 deletions(-) rename client/webui/frontend/src/lib/utils/{fileValidation.ts => file-validation.ts} (98%) diff --git a/client/webui/frontend/src/lib/components/projects/KnowledgeSection.tsx b/client/webui/frontend/src/lib/components/projects/KnowledgeSection.tsx index 674d0a67e..524734b74 100644 --- a/client/webui/frontend/src/lib/components/projects/KnowledgeSection.tsx +++ b/client/webui/frontend/src/lib/components/projects/KnowledgeSection.tsx @@ -8,7 +8,7 @@ import { useProjectArtifacts } from "@/lib/hooks/useProjectArtifacts"; import { useProjectContext } from "@/lib/providers"; import { useDownload } from "@/lib/hooks/useDownload"; import { useConfigContext } from "@/lib/hooks"; -import { validateFileSizes } from "@/lib/utils/fileValidation"; +import { validateFileSizes } from "@/lib/utils/file-validation"; import type { Project } from "@/lib/types/projects"; import type { ArtifactInfo } from "@/lib/types"; import { DocumentListItem } from "./DocumentListItem"; diff --git a/client/webui/frontend/src/lib/components/projects/ProjectFilesManager.tsx b/client/webui/frontend/src/lib/components/projects/ProjectFilesManager.tsx index 44716ce64..183fdce8b 100644 --- a/client/webui/frontend/src/lib/components/projects/ProjectFilesManager.tsx +++ b/client/webui/frontend/src/lib/components/projects/ProjectFilesManager.tsx @@ -3,7 +3,7 @@ import { Loader2, FileText, AlertTriangle, Plus } from "lucide-react"; import { useProjectArtifacts } from "@/lib/hooks/useProjectArtifacts"; import { useConfigContext } from "@/lib/hooks"; -import { validateFileSizes } from "@/lib/utils/fileValidation"; +import { validateFileSizes } from "@/lib/utils/file-validation"; import type { Project } from "@/lib/types/projects"; import { Button } from "@/lib/components/ui"; import { MessageBanner } from "@/lib/components/common"; diff --git a/client/webui/frontend/src/lib/providers/ChatProvider.tsx b/client/webui/frontend/src/lib/providers/ChatProvider.tsx index feae42879..3bc81229d 100644 --- a/client/webui/frontend/src/lib/providers/ChatProvider.tsx +++ b/client/webui/frontend/src/lib/providers/ChatProvider.tsx @@ -6,7 +6,7 @@ import { api } from "@/lib/api"; import { ChatContext, type ChatContextValue, type PendingPromptData } from "@/lib/contexts"; import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor, useArtifactPreview, useSidePanel, useFeedback, useArtifactOperations } from "@/lib/hooks"; import { useProjectContext, registerProjectDeletedCallback } from "@/lib/providers"; -import { fileToBase64, getAccessToken, getErrorMessage, INLINE_FILE_SIZE_LIMIT_BYTES } from "@/lib/utils"; +import { fileToBase64, getAccessToken, getErrorMessage } from "@/lib/utils"; import { migrateTask, CURRENT_SCHEMA_VERSION } from "@/lib/utils/taskMigration"; import type { @@ -32,9 +32,12 @@ import type { StoredTaskData, } from "@/lib/types"; -// Helper function to create an artifact part -const getArtifactPart = (sessionId: string, filename: string) => { - return { +// Helper function to extract artifact markers and create artifact parts +const extractArtifactMarkers = (text: string, sessionId: string, addedArtifacts: Set, processedParts: any[]) => { + const ARTIFACT_RETURN_REGEX = /«artifact_return:([^»]+)»/g; + const ARTIFACT_REGEX = /«artifact:([^»]+)»/g; + + const createArtifactPart = (filename: string) => ({ kind: "artifact", status: "completed", name: filename, @@ -42,29 +45,24 @@ const getArtifactPart = (sessionId: string, filename: string) => { name: filename, uri: `artifact://${sessionId}/${filename}`, }, - }; -}; + }); -// Helper function to extract artifact markers and create artifact parts -const extractArtifactMarkers = (text: string, sessionId: string, addedArtifacts: Set, processedParts: any[]) => { // Extract artifact_return markers - const returnRegex = /«artifact_return:([^»]+)»/g; let match; - while ((match = returnRegex.exec(text)) !== null) { + while ((match = ARTIFACT_RETURN_REGEX.exec(text)) !== null) { const artifactFilename = match[1]; if (!addedArtifacts.has(artifactFilename)) { addedArtifacts.add(artifactFilename); - processedParts.push(getArtifactPart(sessionId, artifactFilename)); + processedParts.push(createArtifactPart(artifactFilename)); } } // Extract artifact: markers - const artifactRegex = /«artifact:([^»]+)»/g; - while ((match = artifactRegex.exec(text)) !== null) { + while ((match = ARTIFACT_REGEX.exec(text)) !== null) { const artifactFilename = match[1]; if (!addedArtifacts.has(artifactFilename)) { addedArtifacts.add(artifactFilename); - processedParts.push(getArtifactPart(sessionId, artifactFilename)); + processedParts.push(createArtifactPart(artifactFilename)); } } }; @@ -79,6 +77,8 @@ const removeArtifactMarkers = (textContent: string): string => { .replace(/«status_update:[^»]+»\n?/g, ""); }; +const INLINE_FILE_SIZE_LIMIT_BYTES = 1 * 1024 * 1024; // 1 MB + interface ChatProviderProps { children: ReactNode; } diff --git a/client/webui/frontend/src/lib/utils/fileValidation.ts b/client/webui/frontend/src/lib/utils/file-validation.ts similarity index 98% rename from client/webui/frontend/src/lib/utils/fileValidation.ts rename to client/webui/frontend/src/lib/utils/file-validation.ts index 3784f7c69..541242ff3 100644 --- a/client/webui/frontend/src/lib/utils/fileValidation.ts +++ b/client/webui/frontend/src/lib/utils/file-validation.ts @@ -1,9 +1,7 @@ /** - * File validation utilities for consistent file size validation. + * File validation utilities for consistent file size validation across the application. */ -export const INLINE_FILE_SIZE_LIMIT_BYTES = 1 * 1024 * 1024; // 1 MB - export interface FileSizeValidationResult { valid: boolean; error?: string; diff --git a/client/webui/frontend/src/lib/utils/file.ts b/client/webui/frontend/src/lib/utils/file.ts index 5bcdb278f..8005d78e7 100644 --- a/client/webui/frontend/src/lib/utils/file.ts +++ b/client/webui/frontend/src/lib/utils/file.ts @@ -37,31 +37,12 @@ export const blobToBase64 = (blob: Blob): Promise => { * @param options.version - Optional version number. If omitted, returns URL for listing all versions * @returns The constructed artifact URL * @throws {Error} When neither sessionId nor projectId is provided - * - * @example - * // Get a specific version with session - * getArtifactUrl({ filename: 'data.json', sessionId: 'abc123', version: 1 }) - * // Returns: '/api/v1/artifacts/abc123/data.json/versions/1' - * - * @example - * // List all versions with session - * getArtifactUrl({ filename: 'data.json', sessionId: 'abc123' }) - * // Returns: '/api/v1/artifacts/abc123/data.json/versions' - * - * @example - * // Get a specific version with project ID - * getArtifactUrl({ filename: 'data.json', projectId: 'proj456', version: 2 }) - * // Returns: '/api/v1/artifacts/null/data.json/versions/2?project_id=proj456' */ export const getArtifactUrl = ({ filename, sessionId, projectId, version }: { filename: string; sessionId?: string; projectId?: string; version?: number }): string => { const isValidSession = sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined"; const encodedFilename = encodeURIComponent(filename); - // Build the base path based on whether we have a valid session const basePath = isValidSession ? `/api/v1/artifacts/${sessionId}/${encodedFilename}/versions` : `/api/v1/artifacts/null/${encodedFilename}/versions`; - - // If version is provided, append it to get a specific version - // Otherwise, return the base path to list all versions const versionPath = version !== undefined ? `/${version}` : ""; const url = `${basePath}${versionPath}`; @@ -70,7 +51,6 @@ export const getArtifactUrl = ({ filename, sessionId, projectId, version }: { fi return `${url}?project_id=${projectId}`; } - // Validate that at least one context (sessionId or projectId) is provided if (!isValidSession && !projectId) { throw new Error("No valid context for artifact: either sessionId or projectId must be provided"); } diff --git a/client/webui/frontend/src/lib/utils/index.ts b/client/webui/frontend/src/lib/utils/index.ts index 251ad48e1..104d137cb 100644 --- a/client/webui/frontend/src/lib/utils/index.ts +++ b/client/webui/frontend/src/lib/utils/index.ts @@ -2,7 +2,7 @@ export * from "./api"; export * from "./cnTailwind"; export * from "./download"; export * from "./file"; -export * from "./fileValidation"; +export * from "./file-validation"; export * from "./format"; export * from "./promptUtils"; export * from "./taskMigration"; From 3a494bf3ea391c48d94fd7e0e618aa7b4338b1dd Mon Sep 17 00:00:00 2001 From: lgh-solace Date: Mon, 22 Dec 2025 08:42:15 -0500 Subject: [PATCH 3/9] chore: updating type name --- client/webui/frontend/src/lib/types/storage.ts | 2 +- .../webui/frontend/src/lib/utils/taskMigration.ts | 15 +++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/client/webui/frontend/src/lib/types/storage.ts b/client/webui/frontend/src/lib/types/storage.ts index 428f321d4..0f03de669 100644 --- a/client/webui/frontend/src/lib/types/storage.ts +++ b/client/webui/frontend/src/lib/types/storage.ts @@ -45,7 +45,7 @@ export interface StoredTaskData { /** * Parsed task structure (after JSON parsing but before migration) */ -export interface TaskData extends Omit { +export interface ParsedTaskData extends Omit { messageBubbles: MessageBubble[]; taskMetadata: TaskMetadata | null; } diff --git a/client/webui/frontend/src/lib/utils/taskMigration.ts b/client/webui/frontend/src/lib/utils/taskMigration.ts index 2899bd3ca..54e0f2316 100644 --- a/client/webui/frontend/src/lib/utils/taskMigration.ts +++ b/client/webui/frontend/src/lib/utils/taskMigration.ts @@ -1,14 +1,9 @@ -/** - * Task schema migration utilities - * Handles versioned migrations for task data structures - */ - -import type { TaskData } from "@/lib/types/storage"; +import type { ParsedTaskData } from "@/lib/types/storage"; export const CURRENT_SCHEMA_VERSION = 1; // Migration function type -type MigrationFunction = (task: TaskData) => TaskData; +type MigrationFunction = (task: ParsedTaskData) => ParsedTaskData; /** * Migration V0 -> V1: Add schema_version field to tasks @@ -52,7 +47,7 @@ const MIGRATIONS: Record = { * @param task - The task to migrate * @returns The migrated task at the current schema version */ -export const migrateTask = (task: TaskData): TaskData => { +export const migrateTask = (task: ParsedTaskData): ParsedTaskData => { const version = task.taskMetadata?.schema_version ?? 0; // Already at current version @@ -81,7 +76,7 @@ export const migrateTask = (task: TaskData): TaskData => { * @param tasks - Array of tasks to migrate * @returns Array of migrated tasks */ -export const migrateTasks = (tasks: TaskData[]): TaskData[] => { +export const migrateTasks = (tasks: ParsedTaskData[]): ParsedTaskData[] => { return tasks.map(migrateTask); }; @@ -90,7 +85,7 @@ export const migrateTasks = (tasks: TaskData[]): TaskData[] => { * @param task - The task to check * @returns true if migration is needed */ -export const needsMigration = (task: TaskData): boolean => { +export const needsMigration = (task: ParsedTaskData): boolean => { const version = task.taskMetadata?.schema_version ?? 0; return version < CURRENT_SCHEMA_VERSION; }; From 44a3d85fcf0c3ee24c93e91e04dda650489eabd5 Mon Sep 17 00:00:00 2001 From: lgh-solace Date: Mon, 22 Dec 2025 08:56:11 -0500 Subject: [PATCH 4/9] chore: using extracted taskMigration --- .../src/lib/providers/ChatProvider.tsx | 745 +++++++++++++----- 1 file changed, 549 insertions(+), 196 deletions(-) diff --git a/client/webui/frontend/src/lib/providers/ChatProvider.tsx b/client/webui/frontend/src/lib/providers/ChatProvider.tsx index 3bc81229d..f32273147 100644 --- a/client/webui/frontend/src/lib/providers/ChatProvider.tsx +++ b/client/webui/frontend/src/lib/providers/ChatProvider.tsx @@ -1,15 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { useState, useCallback, useEffect, useRef, type FormEvent, type ReactNode } from "react"; +import React, { useState, useCallback, useEffect, useRef, useMemo, type FormEvent, type ReactNode } from "react"; import { v4 } from "uuid"; -import { api } from "@/lib/api"; -import { ChatContext, type ChatContextValue, type PendingPromptData } from "@/lib/contexts"; -import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor, useArtifactPreview, useSidePanel, useFeedback, useArtifactOperations } from "@/lib/hooks"; +import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor } from "@/lib/hooks"; import { useProjectContext, registerProjectDeletedCallback } from "@/lib/providers"; -import { fileToBase64, getAccessToken, getErrorMessage } from "@/lib/utils"; -import { migrateTask, CURRENT_SCHEMA_VERSION } from "@/lib/utils/taskMigration"; +import { getAccessToken, getErrorMessage } from "@/lib/utils/api"; +import { createFileSizeErrorMessage } from "@/lib/utils/file-validation"; +import { migrateTask, CURRENT_SCHEMA_VERSION } from "@/lib/utils/taskMigration"; +import { api } from "@/lib/api"; +import { ChatContext, type ChatContextValue, type PendingPromptData } from "@/lib/contexts"; import type { + ArtifactInfo, CancelTaskRequest, DataPart, FileAttachment, @@ -29,56 +31,27 @@ import type { ArtifactPart, AgentCardInfo, Project, - StoredTaskData, } from "@/lib/types"; -// Helper function to extract artifact markers and create artifact parts -const extractArtifactMarkers = (text: string, sessionId: string, addedArtifacts: Set, processedParts: any[]) => { - const ARTIFACT_RETURN_REGEX = /«artifact_return:([^»]+)»/g; - const ARTIFACT_REGEX = /«artifact:([^»]+)»/g; - - const createArtifactPart = (filename: string) => ({ - kind: "artifact", - status: "completed", - name: filename, - file: { - name: filename, - uri: `artifact://${sessionId}/${filename}`, - }, - }); - - // Extract artifact_return markers - let match; - while ((match = ARTIFACT_RETURN_REGEX.exec(text)) !== null) { - const artifactFilename = match[1]; - if (!addedArtifacts.has(artifactFilename)) { - addedArtifacts.add(artifactFilename); - processedParts.push(createArtifactPart(artifactFilename)); - } - } - - // Extract artifact: markers - while ((match = ARTIFACT_REGEX.exec(text)) !== null) { - const artifactFilename = match[1]; - if (!addedArtifacts.has(artifactFilename)) { - addedArtifacts.add(artifactFilename); - processedParts.push(createArtifactPart(artifactFilename)); - } - } -}; - -// Helper function to remove artifact and status update markers from text strings -const removeArtifactMarkers = (textContent: string): string => { - if (!textContent) return textContent; - - return textContent - .replace(/«artifact_return:[^»]+»/g, "") - .replace(/«artifact:[^»]+»/g, "") - .replace(/«status_update:[^»]+»\n?/g, ""); -}; +// Type for tasks loaded from the API +interface TaskFromAPI { + taskId: string; + messageBubbles: string; // JSON string + taskMetadata: string | null; // JSON string + createdTime: number; + userMessage?: string; +} const INLINE_FILE_SIZE_LIMIT_BYTES = 1 * 1024 * 1024; // 1 MB +const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve((reader.result as string).split(",")[1]); + reader.onerror = error => reject(error); + }); + interface ChatProviderProps { children: ReactNode; } @@ -100,6 +73,10 @@ export const ChatProvider: React.FC = ({ children }) => { const savingTasksRef = useRef>(new Set()); + // Track in-flight artifact preview fetches to prevent duplicates + const artifactFetchInProgressRef = useRef>(new Set()); + const artifactDownloadInProgressRef = useRef>(new Set()); + // Track isCancelling in ref to access in async callbacks const isCancellingRef = useRef(isCancelling); useEffect(() => { @@ -111,6 +88,8 @@ export const ChatProvider: React.FC = ({ children }) => { useEffect(() => { currentSessionIdRef.current = sessionId; }, [sessionId]); + + const [taskIdInSidePanel, setTaskIdInSidePanel] = useState(null); const cancelTimeoutRef = useRef(null); const isFinalizing = useRef(false); const latestStatusText = useRef(null); @@ -118,50 +97,39 @@ export const ChatProvider: React.FC = ({ children }) => { const backgroundTasksRef = useRef([]); const messagesRef = useRef([]); - // Agents + // Agents State const { agents, agentNameMap: agentNameDisplayNameMap, error: agentsError, isLoading: agentsLoading, refetch: agentsRefetch } = useAgentCards(); - // Artfiacts + // Chat Side Panel State const { artifacts, isLoading: artifactsLoading, refetch: artifactsRefetch, setArtifacts } = useArtifacts(sessionId); - // Side Panel - const { - isCollapsed: isSidePanelCollapsed, - activeTab: activeSidePanelTab, - taskId: taskIdInSidePanel, - setCollapsed: setIsSidePanelCollapsed, - setActiveTab: setActiveSidePanelTab, - setTaskId: setTaskIdInSidePanel, - openTab: openSidePanelTab, - } = useSidePanel({ - defaultTab: "files", - defaultCollapsed: true, - }); + // Side Panel Control State + const [isSidePanelCollapsed, setIsSidePanelCollapsed] = useState(true); + const [activeSidePanelTab, setActiveSidePanelTab] = useState<"files" | "workflow">("files"); - // Artifact Preview - const { - preview: artifactPreview, - previewArtifact, - openPreview: openArtifactForPreview, - navigateToVersion: navigateArtifactVersion, - closePreview: closeArtifactPreview, - setPreviewByArtifact: setPreviewArtifact, - } = useArtifactPreview({ - sessionId, - projectId: activeProject?.id, - artifacts, - onError: (title, error) => setError({ title, error }), - }); + // Delete Modal State + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [artifactToDelete, setArtifactToDelete] = useState(null); - // Feedback - const { - submittedFeedback, - submitFeedback: handleFeedbackSubmit, - setSubmittedFeedback, - } = useFeedback({ - sessionId, - onError: (title, error) => setError({ title, error }), - }); + // Chat Side Panel Edit Mode State + const [isArtifactEditMode, setIsArtifactEditMode] = useState(false); + const [selectedArtifactFilenames, setSelectedArtifactFilenames] = useState>(new Set()); + const [isBatchDeleteModalOpen, setIsBatchDeleteModalOpen] = useState(false); + + // Preview State + const [previewArtifactFilename, setPreviewArtifactFilename] = useState(null); + const [previewedArtifactAvailableVersions, setPreviewedArtifactAvailableVersions] = useState(null); + const [currentPreviewedVersionNumber, setCurrentPreviewedVersionNumber] = useState(null); + const [previewFileContent, setPreviewFileContent] = useState(null); + + // Derive previewArtifact from artifacts array to ensure it's always up-to-date + const previewArtifact = useMemo(() => { + if (!previewArtifactFilename) return null; + return artifacts.find(a => a.filename === previewArtifactFilename) || null; + }, [artifacts, previewArtifactFilename]); + + // Feedback State + const [submittedFeedback, setSubmittedFeedback] = useState>({}); // Pending prompt state for starting new chat with a prompt template const [pendingPrompt, setPendingPrompt] = useState(null); @@ -309,67 +277,110 @@ export const ChatProvider: React.FC = ({ children }) => { [sessionId, persistenceEnabled] ); - // Helper function to deserialize task data to MessageFE objects - const deserializeTaskToMessages = useCallback((task: { taskId: string; messageBubbles: any[]; taskMetadata?: any; createdTime: number }, sessionId: string): MessageFE[] => { - return task.messageBubbles.map(bubble => { - // Process parts to handle markers and reconstruct artifact parts if needed - const processedParts: any[] = []; - const originalParts = bubble.parts || [{ kind: "text", text: bubble.text || "" }]; - - // Track artifact names we've already added to avoid duplicates - const addedArtifacts = new Set(); - - // First, check the bubble.text field for artifact markers (TaskLoggerService saves markers there) - // This handles the case where backend saves text with markers but parts without artifacts - if (bubble.text) { - extractArtifactMarkers(bubble.text, sessionId, addedArtifacts, processedParts); + // Helper function to extract artifact markers and create artifact parts + const extractArtifactMarkers = useCallback((text: string, sessionId: string, addedArtifacts: Set, processedParts: any[]) => { + const ARTIFACT_RETURN_REGEX = /«artifact_return:([^»]+)»/g; + const ARTIFACT_REGEX = /«artifact:([^»]+)»/g; + + const createArtifactPart = (filename: string) => ({ + kind: "artifact", + status: "completed", + name: filename, + file: { + name: filename, + uri: `artifact://${sessionId}/${filename}`, + }, + }); + + // Extract artifact_return markers + let match; + while ((match = ARTIFACT_RETURN_REGEX.exec(text)) !== null) { + const artifactFilename = match[1]; + if (!addedArtifacts.has(artifactFilename)) { + addedArtifacts.add(artifactFilename); + processedParts.push(createArtifactPart(artifactFilename)); } + } - for (const part of originalParts) { - if (part.kind === "text" && part.text) { - let textContent = part.text; + // Extract artifact: markers + while ((match = ARTIFACT_REGEX.exec(text)) !== null) { + const artifactFilename = match[1]; + if (!addedArtifacts.has(artifactFilename)) { + addedArtifacts.add(artifactFilename); + processedParts.push(createArtifactPart(artifactFilename)); + } + } + }, []); - // Extract artifact markers and convert them to artifact parts - extractArtifactMarkers(textContent, sessionId, addedArtifacts, processedParts); - // Remove artifact markers from text content - textContent = removeArtifactMarkers(textContent); + // Helper function to deserialize task data to MessageFE objects + const deserializeTaskToMessages = useCallback( + (task: { taskId: string; messageBubbles: any[]; taskMetadata?: any; createdTime: number }, sessionId: string): MessageFE[] => { + return task.messageBubbles.map(bubble => { + // Process parts to handle markers and reconstruct artifact parts if needed + const processedParts: any[] = []; + const originalParts = bubble.parts || [{ kind: "text", text: bubble.text || "" }]; + + // Track artifact names we've already added to avoid duplicates + const addedArtifacts = new Set(); + + // First, check the bubble.text field for artifact markers (TaskLoggerService saves markers there) + // This handles the case where backend saves text with markers but parts without artifacts + if (bubble.text) { + extractArtifactMarkers(bubble.text, sessionId, addedArtifacts, processedParts); + } - // Add text part if there's content - if (textContent.trim()) { - processedParts.push({ kind: "text", text: textContent }); - } - } else if (part.kind === "artifact") { - // Only add artifact part if not already added (from markers) - const artifactName = part.name; - if (artifactName && !addedArtifacts.has(artifactName)) { - addedArtifacts.add(artifactName); + for (const part of originalParts) { + if (part.kind === "text" && part.text) { + let textContent = part.text; + + // Extract artifact markers and convert them to artifact parts + extractArtifactMarkers(textContent, sessionId, addedArtifacts, processedParts); + + // Remove artifact markers from text content + textContent = textContent.replace(/«artifact_return:[^»]+»/g, ""); + textContent = textContent.replace(/«artifact:[^»]+»/g, ""); + + // Remove status update markers + textContent = textContent.replace(/«status_update:[^»]+»\n?/g, ""); + + // Add text part if there's content + if (textContent.trim()) { + processedParts.push({ kind: "text", text: textContent }); + } + } else if (part.kind === "artifact") { + // Only add artifact part if not already added (from markers) + const artifactName = part.name; + if (artifactName && !addedArtifacts.has(artifactName)) { + addedArtifacts.add(artifactName); + processedParts.push(part); + } + // Skip duplicate artifacts + } else { + // Keep other non-text parts as-is processedParts.push(part); } - // Skip duplicate artifacts - } else { - // Keep other non-text parts as-is - processedParts.push(part); } - } - return { - taskId: task.taskId, - role: bubble.type === "user" ? "user" : "agent", - parts: processedParts, - isUser: bubble.type === "user", - isComplete: true, - files: bubble.files, - uploadedFiles: bubble.uploadedFiles, - artifactNotification: bubble.artifactNotification, - isError: bubble.isError, - metadata: { - messageId: bubble.id, - sessionId: sessionId, - lastProcessedEventSequence: 0, - }, - }; - }); - }, []); + return { + taskId: task.taskId, + role: bubble.type === "user" ? "user" : "agent", + parts: processedParts, + isUser: bubble.type === "user", + isComplete: true, + files: bubble.files, + uploadedFiles: bubble.uploadedFiles, + artifactNotification: bubble.artifactNotification, + isError: bubble.isError, + metadata: { + messageId: bubble.id, + sessionId: sessionId, + lastProcessedEventSequence: 0, + }, + }; + }); + }, + [extractArtifactMarkers] + ); // Helper function to load session tasks and reconstruct messages const loadSessionTasks = useCallback( @@ -384,15 +395,11 @@ export const ChatProvider: React.FC = ({ children }) => { // Parse JSON strings from backend const tasks = data.tasks || []; - const parsedTasks = tasks.map((task: StoredTaskData) => { - return { - taskId: task.taskId, - createdTime: task.createdTime, - userMessage: task.userMessage, - messageBubbles: JSON.parse(task.messageBubbles), - taskMetadata: task.taskMetadata ? JSON.parse(task.taskMetadata) : null, - }; - }); + const parsedTasks = tasks.map((task: TaskFromAPI) => ({ + ...task, + messageBubbles: JSON.parse(task.messageBubbles), + taskMetadata: task.taskMetadata ? JSON.parse(task.taskMetadata) : null, + })); // Apply migrations to each task const migratedTasks = parsedTasks.map(migrateTask); @@ -440,42 +447,294 @@ export const ChatProvider: React.FC = ({ children }) => { setTaskIdInSidePanel(mostRecentTask.taskId); } }, - [deserializeTaskToMessages, setSubmittedFeedback, setTaskIdInSidePanel] + [deserializeTaskToMessages] ); - // Artifact Operations (using custom hook) - const { - uploadArtifactFile, - isDeleteModalOpen, - artifactToDelete, - openDeleteModal, - closeDeleteModal, - confirmDelete, - isArtifactEditMode, - setIsArtifactEditMode, - selectedArtifactFilenames, - setSelectedArtifactFilenames, - isBatchDeleteModalOpen, - setIsBatchDeleteModalOpen, - handleDeleteSelectedArtifacts, - confirmBatchDeleteArtifacts, - downloadAndResolveArtifact, - } = useArtifactOperations({ - sessionId, - artifacts, - setArtifacts, - artifactsRefetch, - onNotification: addNotification, - onError: (title, error) => setError({ title, error }), - previewArtifact, - closeArtifactPreview, - }); + const uploadArtifactFile = useCallback( + async (file: File, overrideSessionId?: string, description?: string, silent: boolean = false): Promise<{ uri: string; sessionId: string } | { error: string } | null> => { + const effectiveSessionId = overrideSessionId || sessionId; + const formData = new FormData(); + formData.append("upload_file", file); + formData.append("filename", file.name); + // Send sessionId as form field (can be empty string for new sessions) + formData.append("sessionId", effectiveSessionId || ""); + + // Add description as metadata if provided + if (description) { + const metadata = { description }; + formData.append("metadata_json", JSON.stringify(metadata)); + } + + try { + const response = await api.webui.post("/api/v1/artifacts/upload", formData, { fullResponse: true }); + + if (response.status === 413) { + const errorData = await response.json().catch(() => ({ message: `Failed to upload ${file.name}.` })); + const actualSize = errorData.actual_size_bytes; + const maxSize = errorData.max_size_bytes; + const errorMessage = actualSize && maxSize ? createFileSizeErrorMessage(file.name, actualSize, maxSize) : errorData.message || `File "${file.name}" exceeds the maximum allowed size.`; + setError({ title: "File Upload Failed", error: errorMessage }); + return { error: errorMessage }; + } + + if (!response.ok) { + throw new Error( + await response + .json() + .then((d: { message?: string }) => d.message) + .catch(() => `Failed to upload ${file.name}.`) + ); + } + + const result = await response.json(); + if (!silent) { + addNotification(`File "${file.name}" uploaded.`, "success"); + } + await artifactsRefetch(); + return result.uri && result.sessionId ? { uri: result.uri, sessionId: result.sessionId } : null; + } catch (error) { + const errorMessage = getErrorMessage(error, `Failed to upload "${file.name}".`); + setError({ title: "File Upload Failed", error: errorMessage }); + return { error: errorMessage }; + } + }, + [sessionId, addNotification, artifactsRefetch, setError] + ); // Session State const [sessionName, setSessionName] = useState(null); const [sessionToDelete, setSessionToDelete] = useState(null); const [isLoadingSession, setIsLoadingSession] = useState(false); + const deleteArtifactInternal = useCallback( + async (filename: string) => { + try { + await api.webui.delete(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}`); + addNotification(`File "${filename}" deleted.`, "success"); + artifactsRefetch(); + } catch (error) { + setError({ title: "File Deletion Failed", error: getErrorMessage(error, `Failed to delete ${filename}.`) }); + } + }, + [sessionId, addNotification, artifactsRefetch, setError] + ); + + const openDeleteModal = useCallback((artifact: ArtifactInfo) => { + setArtifactToDelete(artifact); + setIsDeleteModalOpen(true); + }, []); + + const closeDeleteModal = useCallback(() => { + setArtifactToDelete(null); + setIsDeleteModalOpen(false); + }, []); + + // Wrapper function to set preview artifact by filename + // IMPORTANT: Must be defined before confirmDelete to avoid circular dependency + const setPreviewArtifact = useCallback((artifact: ArtifactInfo | null) => { + setPreviewArtifactFilename(artifact?.filename || null); + }, []); + + const confirmDelete = useCallback(async () => { + if (artifactToDelete) { + // Check if the artifact being deleted is currently being previewed + const isCurrentlyPreviewed = previewArtifact?.filename === artifactToDelete.filename; + + await deleteArtifactInternal(artifactToDelete.filename); + + // If the deleted artifact was being previewed, go back to file list + if (isCurrentlyPreviewed) { + setPreviewArtifact(null); + } + } + closeDeleteModal(); + }, [artifactToDelete, deleteArtifactInternal, closeDeleteModal, previewArtifact, setPreviewArtifact]); + + const handleDeleteSelectedArtifacts = useCallback(() => { + if (selectedArtifactFilenames.size === 0) { + return; + } + setIsBatchDeleteModalOpen(true); + }, [selectedArtifactFilenames]); + + const confirmBatchDeleteArtifacts = useCallback(async () => { + setIsBatchDeleteModalOpen(false); + const filenamesToDelete = Array.from(selectedArtifactFilenames); + let successCount = 0; + let errorCount = 0; + for (const filename of filenamesToDelete) { + try { + await api.webui.delete(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}`); + successCount++; + } catch (error: unknown) { + console.error(error); + errorCount++; + } + } + if (successCount > 0) addNotification(`${successCount} files(s) deleted.`, "success"); + if (errorCount > 0) { + setError({ title: "File Deletion Failed", error: `${errorCount} file(s) failed to delete.` }); + } + artifactsRefetch(); + setSelectedArtifactFilenames(new Set()); + setIsArtifactEditMode(false); + }, [selectedArtifactFilenames, addNotification, artifactsRefetch, sessionId, setError]); + + const openArtifactForPreview = useCallback( + async (artifactFilename: string): Promise => { + // Prevent duplicate fetches for the same file + if (artifactFetchInProgressRef.current.has(artifactFilename)) { + return null; + } + + // Mark this file as being fetched + artifactFetchInProgressRef.current.add(artifactFilename); + + // Only clear state if this is a different file from what we're currently previewing + // This prevents clearing state during duplicate fetch attempts + if (previewArtifactFilename !== artifactFilename) { + setPreviewedArtifactAvailableVersions(null); + setCurrentPreviewedVersionNumber(null); + setPreviewFileContent(null); + } + try { + // Determine the correct URL based on context + let versionsUrl: string; + if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") { + versionsUrl = `/api/v1/artifacts/${sessionId}/${encodeURIComponent(artifactFilename)}/versions`; + } else if (activeProject?.id) { + versionsUrl = `/api/v1/artifacts/null/${encodeURIComponent(artifactFilename)}/versions?project_id=${activeProject.id}`; + } else { + throw new Error("No valid context for artifact preview"); + } + + const availableVersions: number[] = await api.webui.get(versionsUrl); + if (!availableVersions || availableVersions.length === 0) throw new Error("No versions available"); + setPreviewedArtifactAvailableVersions(availableVersions.sort((a, b) => a - b)); + const latestVersion = Math.max(...availableVersions); + setCurrentPreviewedVersionNumber(latestVersion); + let contentUrl: string; + if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") { + contentUrl = `/api/v1/artifacts/${sessionId}/${encodeURIComponent(artifactFilename)}/versions/${latestVersion}`; + } else if (activeProject?.id) { + contentUrl = `/api/v1/artifacts/null/${encodeURIComponent(artifactFilename)}/versions/${latestVersion}?project_id=${activeProject.id}`; + } else { + throw new Error("No valid context for artifact content"); + } + + const contentResponse = await api.webui.get(contentUrl, { fullResponse: true }); + if (!contentResponse.ok) { + throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); + } + + // Get MIME type from response headers - this is the correct MIME type for this specific version + const contentType = contentResponse.headers.get("Content-Type") || "application/octet-stream"; + // Strip charset and other parameters from Content-Type + const mimeType = contentType.split(";")[0].trim(); + + const blob = await contentResponse.blob(); + const base64Content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + const artifactInfo = artifacts.find(art => art.filename === artifactFilename); + const fileData: FileAttachment = { + name: artifactFilename, + // Use MIME type from response headers (version-specific), not from artifact list (latest version) + mime_type: mimeType, + content: base64Content, + last_modified: artifactInfo?.last_modified || new Date().toISOString(), + }; + setPreviewFileContent(fileData); + return fileData; + } catch (error) { + setError({ title: "Artifact Preview Failed", error: getErrorMessage(error, "Failed to load artifact preview.") }); + return null; + } finally { + // Remove from in-progress set immediately when done + artifactFetchInProgressRef.current.delete(artifactFilename); + } + }, + [sessionId, activeProject?.id, artifacts, previewArtifactFilename, setError] + ); + + const navigateArtifactVersion = useCallback( + async (artifactFilename: string, targetVersion: number): Promise => { + // If versions aren't loaded yet, this is likely a timing issue where this was called + // before openArtifactForPreview completed. Just silently return - the artifact will + // show the latest version when loaded, which is acceptable behavior. + if (!previewedArtifactAvailableVersions || previewedArtifactAvailableVersions.length === 0) { + return null; + } + + // Now check if the specific version exists + if (!previewedArtifactAvailableVersions.includes(targetVersion)) { + console.warn(`Requested version ${targetVersion} not available for ${artifactFilename}`); + return null; + } + setPreviewFileContent(null); + try { + // Determine the correct URL based on context + let contentUrl: string; + if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") { + contentUrl = `/api/v1/artifacts/${sessionId}/${encodeURIComponent(artifactFilename)}/versions/${targetVersion}`; + } else if (activeProject?.id) { + contentUrl = `/api/v1/artifacts/null/${encodeURIComponent(artifactFilename)}/versions/${targetVersion}?project_id=${activeProject.id}`; + } else { + throw new Error("No valid context for artifact navigation"); + } + + const contentResponse = await api.webui.get(contentUrl, { fullResponse: true }); + if (!contentResponse.ok) { + throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); + } + + // Get MIME type from response headers - this is the correct MIME type for this specific version + const contentType = contentResponse.headers.get("Content-Type") || "application/octet-stream"; + // Strip charset and other parameters from Content-Type + const mimeType = contentType.split(";")[0].trim(); + + const blob = await contentResponse.blob(); + const base64Content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + const artifactInfo = artifacts.find(art => art.filename === artifactFilename); + const fileData: FileAttachment = { + name: artifactFilename, + // Use MIME type from response headers (version-specific), not from artifact list (latest version) + mime_type: mimeType, + content: base64Content, + last_modified: artifactInfo?.last_modified || new Date().toISOString(), + }; + setCurrentPreviewedVersionNumber(targetVersion); + setPreviewFileContent(fileData); + return fileData; + } catch (error) { + setError({ title: "Artifact Version Preview Failed", error: getErrorMessage(error, "Failed to fetch artifact version.") }); + return null; + } + }, + [artifacts, previewedArtifactAvailableVersions, sessionId, activeProject?.id, setError] + ); + + const openSidePanelTab = useCallback((tab: "files" | "workflow") => { + setIsSidePanelCollapsed(false); + setActiveSidePanelTab(tab); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("expand-side-panel", { + detail: { tab }, + }) + ); + } + }, []); + const closeCurrentEventSource = useCallback(() => { if (cancelTimeoutRef.current) { clearTimeout(cancelTimeoutRef.current); @@ -490,6 +749,77 @@ export const ChatProvider: React.FC = ({ children }) => { isFinalizing.current = false; }, []); + // Download and resolve artifact with embeds + const downloadAndResolveArtifact = useCallback( + async (filename: string): Promise => { + // Prevent duplicate downloads for the same file + if (artifactDownloadInProgressRef.current.has(filename)) { + console.log(`[ChatProvider] Skipping duplicate download for ${filename} - already in progress`); + return null; + } + + // Mark this file as being downloaded + artifactDownloadInProgressRef.current.add(filename); + + try { + // Find the artifact in state + const artifact = artifacts.find(art => art.filename === filename); + if (!artifact) { + console.error(`Artifact ${filename} not found in state`); + return null; + } + + // Fetch the latest version with embeds resolved + const availableVersions: number[] = await api.webui.get(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}/versions`); + if (!availableVersions || availableVersions.length === 0) { + throw new Error("No versions available"); + } + + const latestVersion = Math.max(...availableVersions); + const contentResponse = await api.webui.get(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}/versions/${latestVersion}`, { fullResponse: true }); + if (!contentResponse.ok) { + throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); + } + const blob = await contentResponse.blob(); + const base64Content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + const fileData: FileAttachment = { + name: filename, + mime_type: artifact.mime_type || "application/octet-stream", + content: base64Content, + last_modified: artifact.last_modified || new Date().toISOString(), + }; + + // Clear the accumulated content and flags after successful download + setArtifacts(prevArtifacts => { + return prevArtifacts.map(art => + art.filename === filename + ? { + ...art, + accumulatedContent: undefined, + needsEmbedResolution: false, + } + : art + ); + }); + + return fileData; + } catch (error) { + setError({ title: "File Download Failed", error: getErrorMessage(error, `Failed to download ${filename}.`) }); + return null; + } finally { + // Remove from in-progress set immediately when done + artifactDownloadInProgressRef.current.delete(filename); + } + }, + [sessionId, artifacts, setArtifacts, setError] + ); + const handleSseMessage = useCallback( (event: MessageEvent) => { sseEventSequenceRef.current += 1; @@ -1083,7 +1413,7 @@ export const ChatProvider: React.FC = ({ children }) => { // Note: No session events dispatched here since no session exists yet. // Session creation event will be dispatched when first message creates the actual session. }, - [closeCurrentEventSource, isResponding, currentTaskId, selectedAgentName, isCancelling, activeProject, setTaskIdInSidePanel, setPreviewArtifact, isTaskRunningInBackground, setActiveProject] + [isResponding, currentTaskId, selectedAgentName, isCancelling, closeCurrentEventSource, activeProject, setActiveProject, setPreviewArtifact, isTaskRunningInBackground] ); // Start a new chat session with a prompt template pre-filled @@ -1225,23 +1555,22 @@ export const ChatProvider: React.FC = ({ children }) => { } }, [ - backgroundTasks, closeCurrentEventSource, isResponding, currentTaskId, selectedAgentName, isCancelling, - sessionId, - isTaskRunningInBackground, - setTaskIdInSidePanel, - setPreviewArtifact, loadSessionTasks, activeProject, projects, setActiveProject, + setPreviewArtifact, + setError, + backgroundTasks, checkTaskStatus, + sessionId, unregisterBackgroundTask, - setError, + isTaskRunningInBackground, ] ); @@ -1353,6 +1682,31 @@ export const ChatProvider: React.FC = ({ children }) => { } }, [isResponding, isCancelling, currentTaskId, addNotification, setError, closeCurrentEventSource]); + const handleFeedbackSubmit = useCallback( + async (taskId: string, feedbackType: "up" | "down", feedbackText: string) => { + if (!sessionId) { + console.error("Cannot submit feedback without a session ID."); + return; + } + try { + await api.webui.post("/api/v1/feedback", { + taskId, + sessionId, + feedbackType, + feedbackText, + }); + setSubmittedFeedback(prev => ({ + ...prev, + [taskId]: { type: feedbackType, text: feedbackText }, + })); + } catch (error) { + console.error("Failed to submit feedback:", error); + throw error; + } + }, + [sessionId] + ); + const handleSseOpen = useCallback(() => { /* console.log for SSE open */ }, []); @@ -1669,21 +2023,20 @@ export const ChatProvider: React.FC = ({ children }) => { } }, [ + sessionId, isResponding, isCancelling, selectedAgentName, closeCurrentEventSource, - sessionId, - backgroundTasksEnabled, - activeProject?.id, - setTaskIdInSidePanel, uploadArtifactFile, - cleanupUploadedFiles, - setError, - backgroundTasksDefaultTimeoutMs, updateSessionName, saveTaskToBackend, serializeMessageBubble, + activeProject, + cleanupUploadedFiles, + setError, + backgroundTasksDefaultTimeoutMs, + backgroundTasksEnabled, registerBackgroundTask, ] ); @@ -1972,9 +2325,9 @@ export const ChatProvider: React.FC = ({ children }) => { confirmBatchDeleteArtifacts, isBatchDeleteModalOpen, setIsBatchDeleteModalOpen, - previewedArtifactAvailableVersions: artifactPreview.availableVersions, - currentPreviewedVersionNumber: artifactPreview.currentVersion, - previewFileContent: artifactPreview.content, + previewedArtifactAvailableVersions, + currentPreviewedVersionNumber, + previewFileContent, openArtifactForPreview, navigateArtifactVersion, previewArtifact, From 07aaf55a67cf17667d5c824462f514fec8c45648 Mon Sep 17 00:00:00 2001 From: lgh-solace Date: Mon, 22 Dec 2025 09:16:39 -0500 Subject: [PATCH 5/9] chore: using extracted artifact preview --- .../src/lib/providers/ChatProvider.tsx | 199 ++---------------- 1 file changed, 23 insertions(+), 176 deletions(-) diff --git a/client/webui/frontend/src/lib/providers/ChatProvider.tsx b/client/webui/frontend/src/lib/providers/ChatProvider.tsx index f32273147..bcc745587 100644 --- a/client/webui/frontend/src/lib/providers/ChatProvider.tsx +++ b/client/webui/frontend/src/lib/providers/ChatProvider.tsx @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { useState, useCallback, useEffect, useRef, useMemo, type FormEvent, type ReactNode } from "react"; +import React, { useState, useCallback, useEffect, useRef, type FormEvent, type ReactNode } from "react"; import { v4 } from "uuid"; -import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor } from "@/lib/hooks"; +import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor, useArtifactPreview } from "@/lib/hooks"; import { useProjectContext, registerProjectDeletedCallback } from "@/lib/providers"; import { getAccessToken, getErrorMessage } from "@/lib/utils/api"; @@ -73,8 +73,7 @@ export const ChatProvider: React.FC = ({ children }) => { const savingTasksRef = useRef>(new Set()); - // Track in-flight artifact preview fetches to prevent duplicates - const artifactFetchInProgressRef = useRef>(new Set()); + // Track in-flight artifact download fetches to prevent duplicates const artifactDownloadInProgressRef = useRef>(new Set()); // Track isCancelling in ref to access in async callbacks @@ -103,6 +102,14 @@ export const ChatProvider: React.FC = ({ children }) => { // Chat Side Panel State const { artifacts, isLoading: artifactsLoading, refetch: artifactsRefetch, setArtifacts } = useArtifacts(sessionId); + // Artifact Preview Hook + const { preview, previewArtifact, openPreview, navigateToVersion, closePreview, setPreviewByArtifact } = useArtifactPreview({ + sessionId, + projectId: activeProject?.id, + artifacts, + onError: (title: string, error: string) => setError({ title, error }), + }); + // Side Panel Control State const [isSidePanelCollapsed, setIsSidePanelCollapsed] = useState(true); const [activeSidePanelTab, setActiveSidePanelTab] = useState<"files" | "workflow">("files"); @@ -116,18 +123,6 @@ export const ChatProvider: React.FC = ({ children }) => { const [selectedArtifactFilenames, setSelectedArtifactFilenames] = useState>(new Set()); const [isBatchDeleteModalOpen, setIsBatchDeleteModalOpen] = useState(false); - // Preview State - const [previewArtifactFilename, setPreviewArtifactFilename] = useState(null); - const [previewedArtifactAvailableVersions, setPreviewedArtifactAvailableVersions] = useState(null); - const [currentPreviewedVersionNumber, setCurrentPreviewedVersionNumber] = useState(null); - const [previewFileContent, setPreviewFileContent] = useState(null); - - // Derive previewArtifact from artifacts array to ensure it's always up-to-date - const previewArtifact = useMemo(() => { - if (!previewArtifactFilename) return null; - return artifacts.find(a => a.filename === previewArtifactFilename) || null; - }, [artifacts, previewArtifactFilename]); - // Feedback State const [submittedFeedback, setSubmittedFeedback] = useState>({}); @@ -529,12 +524,6 @@ export const ChatProvider: React.FC = ({ children }) => { setIsDeleteModalOpen(false); }, []); - // Wrapper function to set preview artifact by filename - // IMPORTANT: Must be defined before confirmDelete to avoid circular dependency - const setPreviewArtifact = useCallback((artifact: ArtifactInfo | null) => { - setPreviewArtifactFilename(artifact?.filename || null); - }, []); - const confirmDelete = useCallback(async () => { if (artifactToDelete) { // Check if the artifact being deleted is currently being previewed @@ -544,11 +533,11 @@ export const ChatProvider: React.FC = ({ children }) => { // If the deleted artifact was being previewed, go back to file list if (isCurrentlyPreviewed) { - setPreviewArtifact(null); + closePreview(); } } closeDeleteModal(); - }, [artifactToDelete, deleteArtifactInternal, closeDeleteModal, previewArtifact, setPreviewArtifact]); + }, [artifactToDelete, deleteArtifactInternal, closeDeleteModal, previewArtifact, closePreview]); const handleDeleteSelectedArtifacts = useCallback(() => { if (selectedArtifactFilenames.size === 0) { @@ -580,148 +569,6 @@ export const ChatProvider: React.FC = ({ children }) => { setIsArtifactEditMode(false); }, [selectedArtifactFilenames, addNotification, artifactsRefetch, sessionId, setError]); - const openArtifactForPreview = useCallback( - async (artifactFilename: string): Promise => { - // Prevent duplicate fetches for the same file - if (artifactFetchInProgressRef.current.has(artifactFilename)) { - return null; - } - - // Mark this file as being fetched - artifactFetchInProgressRef.current.add(artifactFilename); - - // Only clear state if this is a different file from what we're currently previewing - // This prevents clearing state during duplicate fetch attempts - if (previewArtifactFilename !== artifactFilename) { - setPreviewedArtifactAvailableVersions(null); - setCurrentPreviewedVersionNumber(null); - setPreviewFileContent(null); - } - try { - // Determine the correct URL based on context - let versionsUrl: string; - if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") { - versionsUrl = `/api/v1/artifacts/${sessionId}/${encodeURIComponent(artifactFilename)}/versions`; - } else if (activeProject?.id) { - versionsUrl = `/api/v1/artifacts/null/${encodeURIComponent(artifactFilename)}/versions?project_id=${activeProject.id}`; - } else { - throw new Error("No valid context for artifact preview"); - } - - const availableVersions: number[] = await api.webui.get(versionsUrl); - if (!availableVersions || availableVersions.length === 0) throw new Error("No versions available"); - setPreviewedArtifactAvailableVersions(availableVersions.sort((a, b) => a - b)); - const latestVersion = Math.max(...availableVersions); - setCurrentPreviewedVersionNumber(latestVersion); - let contentUrl: string; - if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") { - contentUrl = `/api/v1/artifacts/${sessionId}/${encodeURIComponent(artifactFilename)}/versions/${latestVersion}`; - } else if (activeProject?.id) { - contentUrl = `/api/v1/artifacts/null/${encodeURIComponent(artifactFilename)}/versions/${latestVersion}?project_id=${activeProject.id}`; - } else { - throw new Error("No valid context for artifact content"); - } - - const contentResponse = await api.webui.get(contentUrl, { fullResponse: true }); - if (!contentResponse.ok) { - throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); - } - - // Get MIME type from response headers - this is the correct MIME type for this specific version - const contentType = contentResponse.headers.get("Content-Type") || "application/octet-stream"; - // Strip charset and other parameters from Content-Type - const mimeType = contentType.split(";")[0].trim(); - - const blob = await contentResponse.blob(); - const base64Content = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - const artifactInfo = artifacts.find(art => art.filename === artifactFilename); - const fileData: FileAttachment = { - name: artifactFilename, - // Use MIME type from response headers (version-specific), not from artifact list (latest version) - mime_type: mimeType, - content: base64Content, - last_modified: artifactInfo?.last_modified || new Date().toISOString(), - }; - setPreviewFileContent(fileData); - return fileData; - } catch (error) { - setError({ title: "Artifact Preview Failed", error: getErrorMessage(error, "Failed to load artifact preview.") }); - return null; - } finally { - // Remove from in-progress set immediately when done - artifactFetchInProgressRef.current.delete(artifactFilename); - } - }, - [sessionId, activeProject?.id, artifacts, previewArtifactFilename, setError] - ); - - const navigateArtifactVersion = useCallback( - async (artifactFilename: string, targetVersion: number): Promise => { - // If versions aren't loaded yet, this is likely a timing issue where this was called - // before openArtifactForPreview completed. Just silently return - the artifact will - // show the latest version when loaded, which is acceptable behavior. - if (!previewedArtifactAvailableVersions || previewedArtifactAvailableVersions.length === 0) { - return null; - } - - // Now check if the specific version exists - if (!previewedArtifactAvailableVersions.includes(targetVersion)) { - console.warn(`Requested version ${targetVersion} not available for ${artifactFilename}`); - return null; - } - setPreviewFileContent(null); - try { - // Determine the correct URL based on context - let contentUrl: string; - if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") { - contentUrl = `/api/v1/artifacts/${sessionId}/${encodeURIComponent(artifactFilename)}/versions/${targetVersion}`; - } else if (activeProject?.id) { - contentUrl = `/api/v1/artifacts/null/${encodeURIComponent(artifactFilename)}/versions/${targetVersion}?project_id=${activeProject.id}`; - } else { - throw new Error("No valid context for artifact navigation"); - } - - const contentResponse = await api.webui.get(contentUrl, { fullResponse: true }); - if (!contentResponse.ok) { - throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); - } - - // Get MIME type from response headers - this is the correct MIME type for this specific version - const contentType = contentResponse.headers.get("Content-Type") || "application/octet-stream"; - // Strip charset and other parameters from Content-Type - const mimeType = contentType.split(";")[0].trim(); - - const blob = await contentResponse.blob(); - const base64Content = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - const artifactInfo = artifacts.find(art => art.filename === artifactFilename); - const fileData: FileAttachment = { - name: artifactFilename, - // Use MIME type from response headers (version-specific), not from artifact list (latest version) - mime_type: mimeType, - content: base64Content, - last_modified: artifactInfo?.last_modified || new Date().toISOString(), - }; - setCurrentPreviewedVersionNumber(targetVersion); - setPreviewFileContent(fileData); - return fileData; - } catch (error) { - setError({ title: "Artifact Version Preview Failed", error: getErrorMessage(error, "Failed to fetch artifact version.") }); - return null; - } - }, - [artifacts, previewedArtifactAvailableVersions, sessionId, activeProject?.id, setError] - ); - const openSidePanelTab = useCallback((tab: "files" | "workflow") => { setIsSidePanelCollapsed(false); setActiveSidePanelTab(tab); @@ -1399,7 +1246,7 @@ export const ChatProvider: React.FC = ({ children }) => { setIsResponding(false); setCurrentTaskId(null); setTaskIdInSidePanel(null); - setPreviewArtifact(null); + closePreview(); isFinalizing.current = false; latestStatusText.current = null; sseEventSequenceRef.current = 0; @@ -1413,7 +1260,7 @@ export const ChatProvider: React.FC = ({ children }) => { // Note: No session events dispatched here since no session exists yet. // Session creation event will be dispatched when first message creates the actual session. }, - [isResponding, currentTaskId, selectedAgentName, isCancelling, closeCurrentEventSource, activeProject, setActiveProject, setPreviewArtifact, isTaskRunningInBackground] + [isResponding, currentTaskId, selectedAgentName, isCancelling, closeCurrentEventSource, activeProject, setActiveProject, closePreview, isTaskRunningInBackground] ); // Start a new chat session with a prompt template pre-filled @@ -1518,7 +1365,7 @@ export const ChatProvider: React.FC = ({ children }) => { setIsResponding(false); setCurrentTaskId(null); setTaskIdInSidePanel(null); - setPreviewArtifact(null); + closePreview(); isFinalizing.current = false; latestStatusText.current = null; sseEventSequenceRef.current = 0; @@ -1564,7 +1411,7 @@ export const ChatProvider: React.FC = ({ children }) => { activeProject, projects, setActiveProject, - setPreviewArtifact, + closePreview, setError, backgroundTasks, checkTaskStatus, @@ -2325,13 +2172,13 @@ export const ChatProvider: React.FC = ({ children }) => { confirmBatchDeleteArtifacts, isBatchDeleteModalOpen, setIsBatchDeleteModalOpen, - previewedArtifactAvailableVersions, - currentPreviewedVersionNumber, - previewFileContent, - openArtifactForPreview, - navigateArtifactVersion, + previewedArtifactAvailableVersions: preview.availableVersions, + currentPreviewedVersionNumber: preview.currentVersion, + previewFileContent: preview.content, + openArtifactForPreview: openPreview, + navigateArtifactVersion: navigateToVersion, previewArtifact, - setPreviewArtifact, + setPreviewArtifact: setPreviewByArtifact, updateSessionName, deleteSession, From 23183cfee450de3e461c7b7477a824be71bd142c Mon Sep 17 00:00:00 2001 From: lgh-solace Date: Mon, 22 Dec 2025 09:43:28 -0500 Subject: [PATCH 6/9] chore: removing unused hooks --- client/webui/frontend/src/lib/hooks/index.ts | 2 - .../frontend/src/lib/hooks/useFeedback.ts | 65 ------------------- .../frontend/src/lib/hooks/useSidePanel.ts | 60 ----------------- .../src/lib/providers/ChatProvider.tsx | 21 +----- 4 files changed, 3 insertions(+), 145 deletions(-) delete mode 100644 client/webui/frontend/src/lib/hooks/useFeedback.ts delete mode 100644 client/webui/frontend/src/lib/hooks/useSidePanel.ts diff --git a/client/webui/frontend/src/lib/hooks/index.ts b/client/webui/frontend/src/lib/hooks/index.ts index 0c6bb6f8b..1d21ca5db 100644 --- a/client/webui/frontend/src/lib/hooks/index.ts +++ b/client/webui/frontend/src/lib/hooks/index.ts @@ -3,7 +3,6 @@ export * from "./useArtifactOperations"; export * from "./useArtifactPreview"; export * from "./useArtifactRendering"; export * from "./useArtifacts"; -export * from "./useSidePanel"; export * from "./useAudioSettings"; export * from "./useAuthContext"; export * from "./useBackgroundTaskMonitor"; @@ -24,7 +23,6 @@ export * from "./useProjectSessions"; export * from "./useAgentSelection"; export * from "./useNavigationBlocker"; export * from "./useErrorDialog"; -export * from "./useFeedback"; export * from "./useSessionStorage"; export * from "./useMap"; export * from "./useLocalStorage"; diff --git a/client/webui/frontend/src/lib/hooks/useFeedback.ts b/client/webui/frontend/src/lib/hooks/useFeedback.ts deleted file mode 100644 index 2411dda38..000000000 --- a/client/webui/frontend/src/lib/hooks/useFeedback.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useState, useCallback } from "react"; -import { api } from "@/lib/api"; - -export interface FeedbackData { - type: "up" | "down"; - text: string; -} - -interface UseFeedbackOptions { - sessionId: string; - onError?: (title: string, error: string) => void; -} - -interface UseFeedbackReturn { - submittedFeedback: Record; - submitFeedback: (taskId: string, feedbackType: "up" | "down", feedbackText: string) => Promise; - setSubmittedFeedback: React.Dispatch>>; -} - -/** - * Custom hook to manage user feedback for chat tasks - * Handles submission and tracking of thumbs up/down feedback - */ -export const useFeedback = ({ sessionId, onError }: UseFeedbackOptions): UseFeedbackReturn => { - const [submittedFeedback, setSubmittedFeedback] = useState>({}); - - /** - * Submit feedback for a specific task - */ - const submitFeedback = useCallback( - async (taskId: string, feedbackType: "up" | "down", feedbackText: string) => { - if (!sessionId) { - console.error("Cannot submit feedback without a session ID."); - onError?.("Feedback Failed", "No active session found."); - return; - } - - try { - await api.webui.post("/api/v1/feedback", { - taskId, - sessionId, - feedbackType, - feedbackText, - }); - - // Update local state on success - setSubmittedFeedback(prev => ({ - ...prev, - [taskId]: { type: feedbackType, text: feedbackText }, - })); - } catch (error) { - console.error("Failed to submit feedback:", error); - onError?.("Feedback Submission Failed", "Failed to submit feedback. Please try again."); - throw error; - } - }, - [sessionId, onError] - ); - - return { - submittedFeedback, - submitFeedback, - setSubmittedFeedback, - }; -}; diff --git a/client/webui/frontend/src/lib/hooks/useSidePanel.ts b/client/webui/frontend/src/lib/hooks/useSidePanel.ts deleted file mode 100644 index cf3bf2c7b..000000000 --- a/client/webui/frontend/src/lib/hooks/useSidePanel.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useCallback, type Dispatch, type SetStateAction } from "react"; - -export interface SidePanelState { - isCollapsed: boolean; - activeTab: "files" | "workflow"; - taskId: string | null; -} - -interface UseSidePanelOptions { - defaultTab?: "files" | "workflow"; - defaultCollapsed?: boolean; -} - -interface UseSidePanelReturn { - isCollapsed: boolean; - activeTab: "files" | "workflow"; - taskId: string | null; - setCollapsed: Dispatch>; - setActiveTab: Dispatch>; - setTaskId: Dispatch>; - openTab: (tab: "files" | "workflow") => void; -} - -/** - * Custom hook to manage side panel UI state - * Handles panel collapse/expand, active tab selection, and task ID for workflow visualization - */ -export const useSidePanel = ({ defaultTab = "files", defaultCollapsed = true }: UseSidePanelOptions = {}): UseSidePanelReturn => { - const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); - const [activeTab, setActiveTab] = useState<"files" | "workflow">(defaultTab); - const [taskId, setTaskId] = useState(null); - - /** - * Open the side panel to a specific tab - * Also dispatches a custom event for components that need to know - */ - const openTab = useCallback((tab: "files" | "workflow") => { - setIsCollapsed(false); - setActiveTab(tab); - - // Dispatch event for other components (e.g., ChatInputArea) - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("expand-side-panel", { - detail: { tab }, - }) - ); - } - }, []); - - return { - isCollapsed, - activeTab, - taskId, - setCollapsed: setIsCollapsed, - setActiveTab, - setTaskId, - openTab, - }; -}; diff --git a/client/webui/frontend/src/lib/providers/ChatProvider.tsx b/client/webui/frontend/src/lib/providers/ChatProvider.tsx index bcc745587..5b551f590 100644 --- a/client/webui/frontend/src/lib/providers/ChatProvider.tsx +++ b/client/webui/frontend/src/lib/providers/ChatProvider.tsx @@ -31,27 +31,12 @@ import type { ArtifactPart, AgentCardInfo, Project, + StoredTaskData, } from "@/lib/types"; - -// Type for tasks loaded from the API -interface TaskFromAPI { - taskId: string; - messageBubbles: string; // JSON string - taskMetadata: string | null; // JSON string - createdTime: number; - userMessage?: string; -} +import { fileToBase64 } from "../utils"; const INLINE_FILE_SIZE_LIMIT_BYTES = 1 * 1024 * 1024; // 1 MB -const fileToBase64 = (file: File): Promise => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve((reader.result as string).split(",")[1]); - reader.onerror = error => reject(error); - }); - interface ChatProviderProps { children: ReactNode; } @@ -390,7 +375,7 @@ export const ChatProvider: React.FC = ({ children }) => { // Parse JSON strings from backend const tasks = data.tasks || []; - const parsedTasks = tasks.map((task: TaskFromAPI) => ({ + const parsedTasks = tasks.map((task: StoredTaskData) => ({ ...task, messageBubbles: JSON.parse(task.messageBubbles), taskMetadata: task.taskMetadata ? JSON.parse(task.taskMetadata) : null, From a8625a866b18189a4ffc6ffcaaa1e5dcd1f62836 Mon Sep 17 00:00:00 2001 From: lgh-solace Date: Mon, 22 Dec 2025 10:49:46 -0500 Subject: [PATCH 7/9] chore: using artifact operations --- .../src/lib/hooks/useArtifactOperations.ts | 36 +-- .../src/lib/hooks/useArtifactPreview.ts | 12 +- .../src/lib/providers/ChatProvider.tsx | 253 +++--------------- 3 files changed, 64 insertions(+), 237 deletions(-) diff --git a/client/webui/frontend/src/lib/hooks/useArtifactOperations.ts b/client/webui/frontend/src/lib/hooks/useArtifactOperations.ts index bc1d5d800..407ed269a 100644 --- a/client/webui/frontend/src/lib/hooks/useArtifactOperations.ts +++ b/client/webui/frontend/src/lib/hooks/useArtifactOperations.ts @@ -8,10 +8,10 @@ interface UseArtifactOperationsOptions { artifacts: ArtifactInfo[]; setArtifacts: React.Dispatch>; artifactsRefetch: () => Promise; - onNotification: (message: string, type?: "success" | "info" | "warning") => void; - onError: (title: string, error: string) => void; + addNotification: (message: string, type?: "success" | "info" | "warning") => void; + setError: (error: { title: string; error: string }) => void; previewArtifact: { filename: string } | null; - closeArtifactPreview: () => void; + closePreview: () => void; } interface UseArtifactOperationsReturn { @@ -56,7 +56,7 @@ const getFileAttachment = (artifactInfos: ArtifactInfo[], filename: string, mime * Custom hook to manage artifact CRUD operations * Handles upload, download, delete (single and batch), and modal state */ -export const useArtifactOperations = ({ sessionId, artifacts, setArtifacts, artifactsRefetch, onNotification, onError, previewArtifact, closeArtifactPreview }: UseArtifactOperationsOptions): UseArtifactOperationsReturn => { +export const useArtifactOperations = ({ sessionId, artifacts, setArtifacts, artifactsRefetch, addNotification, setError, previewArtifact, closePreview }: UseArtifactOperationsOptions): UseArtifactOperationsReturn => { // Delete Modal State const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [artifactToDelete, setArtifactToDelete] = useState(null); @@ -93,7 +93,7 @@ export const useArtifactOperations = ({ sessionId, artifacts, setArtifacts, arti const actualSize = errorData.actual_size_bytes; const maxSize = errorData.max_size_bytes; const errorMessage = actualSize && maxSize ? createFileSizeErrorMessage(file.name, actualSize, maxSize) : errorData.message || `File "${file.name}" exceeds the maximum allowed size.`; - onError("File Upload Failed", errorMessage); + setError({ title: "File Upload Failed", error: errorMessage }); return { error: errorMessage }; } @@ -108,17 +108,17 @@ export const useArtifactOperations = ({ sessionId, artifacts, setArtifacts, arti const result = await response.json(); if (!silent) { - onNotification(`File "${file.name}" uploaded.`, "success"); + addNotification(`File "${file.name}" uploaded.`, "success"); } await artifactsRefetch(); return result.uri && result.sessionId ? { uri: result.uri, sessionId: result.sessionId } : null; } catch (error) { const errorMessage = getErrorMessage(error, `Failed to upload "${file.name}".`); - onError("File Upload Failed", errorMessage); + setError({ title: "File Upload Failed", error: errorMessage }); return { error: errorMessage }; } }, - [sessionId, onNotification, artifactsRefetch, onError] + [sessionId, addNotification, artifactsRefetch, setError] ); /** @@ -128,13 +128,13 @@ export const useArtifactOperations = ({ sessionId, artifacts, setArtifacts, arti async (filename: string) => { try { await api.webui.delete(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}`); - onNotification(`File "${filename}" deleted.`, "success"); + addNotification(`File "${filename}" deleted.`, "success"); artifactsRefetch(); } catch (error) { - onError("File Deletion Failed", getErrorMessage(error, `Failed to delete ${filename}.`)); + setError({ title: "File Deletion Failed", error: getErrorMessage(error, `Failed to delete ${filename}.`) }); } }, - [sessionId, onNotification, artifactsRefetch, onError] + [sessionId, addNotification, artifactsRefetch, setError] ); /** @@ -165,11 +165,11 @@ export const useArtifactOperations = ({ sessionId, artifacts, setArtifacts, arti // If the deleted artifact was being previewed, close the preview if (isCurrentlyPreviewed) { - closeArtifactPreview(); + closePreview(); } } closeDeleteModal(); - }, [artifactToDelete, deleteArtifactInternal, closeDeleteModal, previewArtifact, closeArtifactPreview]); + }, [artifactToDelete, deleteArtifactInternal, closeDeleteModal, previewArtifact, closePreview]); /** * Open batch delete modal @@ -200,15 +200,15 @@ export const useArtifactOperations = ({ sessionId, artifacts, setArtifacts, arti } } - if (successCount > 0) onNotification(`${successCount} files(s) deleted.`, "success"); + if (successCount > 0) addNotification(`${successCount} files(s) deleted.`, "success"); if (errorCount > 0) { - onError("File Deletion Failed", `${errorCount} file(s) failed to delete.`); + setError({ title: "File Deletion Failed", error: `${errorCount} file(s) failed to delete.` }); } artifactsRefetch(); setSelectedArtifactFilenames(new Set()); setIsArtifactEditMode(false); - }, [selectedArtifactFilenames, onNotification, artifactsRefetch, sessionId, onError]); + }, [selectedArtifactFilenames, addNotification, artifactsRefetch, sessionId, setError]); /** * Download and resolve artifact with embeds @@ -263,14 +263,14 @@ export const useArtifactOperations = ({ sessionId, artifacts, setArtifacts, arti return fileData; } catch (error) { - onError("File Download Failed", getErrorMessage(error, `Failed to download ${filename}.`)); + setError({ title: "File Download Failed", error: getErrorMessage(error, `Failed to download ${filename}.`) }); return null; } finally { // Remove from in-progress set immediately when done artifactDownloadInProgressRef.current.delete(filename); } }, - [sessionId, artifacts, setArtifacts, onError] + [sessionId, artifacts, setArtifacts, setError] ); return { diff --git a/client/webui/frontend/src/lib/hooks/useArtifactPreview.ts b/client/webui/frontend/src/lib/hooks/useArtifactPreview.ts index 751c03c7c..ec5e799e8 100644 --- a/client/webui/frontend/src/lib/hooks/useArtifactPreview.ts +++ b/client/webui/frontend/src/lib/hooks/useArtifactPreview.ts @@ -15,7 +15,7 @@ interface UseArtifactPreviewOptions { sessionId: string; projectId?: string; artifacts: ArtifactInfo[]; - onError?: (title: string, error: string) => void; + setError?: (error: { title: string; error: string }) => void; } interface UseArtifactPreviewReturn { @@ -35,7 +35,7 @@ interface UseArtifactPreviewReturn { * Custom hook to manage artifact preview functionality * Handles opening artifacts, navigating versions, and managing preview state */ -export const useArtifactPreview = ({ sessionId, projectId, artifacts, onError }: UseArtifactPreviewOptions): UseArtifactPreviewReturn => { +export const useArtifactPreview = ({ sessionId, projectId, artifacts, setError }: UseArtifactPreviewOptions): UseArtifactPreviewReturn => { // State const [preview, setPreview] = useState({ filename: null, @@ -131,14 +131,14 @@ export const useArtifactPreview = ({ sessionId, projectId, artifacts, onError }: return fileData; } catch (error) { const errorMessage = getErrorMessage(error, "Failed to load artifact preview."); - onError?.("Artifact Preview Failed", errorMessage); + setError?.({ title: "Artifact Preview Failed", error: errorMessage }); return null; } finally { setIsLoading(false); fetchInProgressRef.current.delete(filename); } }, - [sessionId, projectId, preview.filename, getFileAttachment, onError] + [sessionId, projectId, preview.filename, getFileAttachment, setError] ); /** @@ -186,13 +186,13 @@ export const useArtifactPreview = ({ sessionId, projectId, artifacts, onError }: return fileData; } catch (error) { const errorMessage = getErrorMessage(error, "Failed to fetch artifact version."); - onError?.("Artifact Version Preview Failed", errorMessage); + setError?.({ title: "Artifact Version Preview Failed", error: errorMessage }); return null; } finally { setIsLoading(false); } }, - [sessionId, projectId, preview.availableVersions, getFileAttachment, onError] + [sessionId, projectId, preview.availableVersions, getFileAttachment, setError] ); /** diff --git a/client/webui/frontend/src/lib/providers/ChatProvider.tsx b/client/webui/frontend/src/lib/providers/ChatProvider.tsx index 5b551f590..100868fcd 100644 --- a/client/webui/frontend/src/lib/providers/ChatProvider.tsx +++ b/client/webui/frontend/src/lib/providers/ChatProvider.tsx @@ -2,16 +2,14 @@ import React, { useState, useCallback, useEffect, useRef, type FormEvent, type ReactNode } from "react"; import { v4 } from "uuid"; -import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor, useArtifactPreview } from "@/lib/hooks"; +import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor, useArtifactPreview, useArtifactOperations } from "@/lib/hooks"; import { useProjectContext, registerProjectDeletedCallback } from "@/lib/providers"; import { getAccessToken, getErrorMessage } from "@/lib/utils/api"; -import { createFileSizeErrorMessage } from "@/lib/utils/file-validation"; import { migrateTask, CURRENT_SCHEMA_VERSION } from "@/lib/utils/taskMigration"; import { api } from "@/lib/api"; import { ChatContext, type ChatContextValue, type PendingPromptData } from "@/lib/contexts"; import type { - ArtifactInfo, CancelTaskRequest, DataPart, FileAttachment, @@ -58,9 +56,6 @@ export const ChatProvider: React.FC = ({ children }) => { const savingTasksRef = useRef>(new Set()); - // Track in-flight artifact download fetches to prevent duplicates - const artifactDownloadInProgressRef = useRef>(new Set()); - // Track isCancelling in ref to access in async callbacks const isCancellingRef = useRef(isCancelling); useEffect(() => { @@ -87,27 +82,10 @@ export const ChatProvider: React.FC = ({ children }) => { // Chat Side Panel State const { artifacts, isLoading: artifactsLoading, refetch: artifactsRefetch, setArtifacts } = useArtifacts(sessionId); - // Artifact Preview Hook - const { preview, previewArtifact, openPreview, navigateToVersion, closePreview, setPreviewByArtifact } = useArtifactPreview({ - sessionId, - projectId: activeProject?.id, - artifacts, - onError: (title: string, error: string) => setError({ title, error }), - }); - // Side Panel Control State const [isSidePanelCollapsed, setIsSidePanelCollapsed] = useState(true); const [activeSidePanelTab, setActiveSidePanelTab] = useState<"files" | "workflow">("files"); - // Delete Modal State - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [artifactToDelete, setArtifactToDelete] = useState(null); - - // Chat Side Panel Edit Mode State - const [isArtifactEditMode, setIsArtifactEditMode] = useState(false); - const [selectedArtifactFilenames, setSelectedArtifactFilenames] = useState>(new Set()); - const [isBatchDeleteModalOpen, setIsBatchDeleteModalOpen] = useState(false); - // Feedback State const [submittedFeedback, setSubmittedFeedback] = useState>({}); @@ -134,6 +112,45 @@ export const ChatProvider: React.FC = ({ children }) => { }); }, []); + // Artifact Preview + const { preview, previewArtifact, openPreview, navigateToVersion, closePreview, setPreviewByArtifact } = useArtifactPreview({ + sessionId, + projectId: activeProject?.id, + artifacts, + setError, + }); + + // Artifact Operations + const { + uploadArtifactFile, + + isDeleteModalOpen, + artifactToDelete, + openDeleteModal, + closeDeleteModal, + confirmDelete, + + isArtifactEditMode, + setIsArtifactEditMode, + selectedArtifactFilenames, + setSelectedArtifactFilenames, + isBatchDeleteModalOpen, + setIsBatchDeleteModalOpen, + handleDeleteSelectedArtifacts, + confirmBatchDeleteArtifacts, + + downloadAndResolveArtifact, + } = useArtifactOperations({ + sessionId, + artifacts, + setArtifacts, + artifactsRefetch, + addNotification, + setError, + previewArtifact, + closePreview, + }); + const { backgroundTasks, notifications: backgroundNotifications, @@ -430,130 +447,11 @@ export const ChatProvider: React.FC = ({ children }) => { [deserializeTaskToMessages] ); - const uploadArtifactFile = useCallback( - async (file: File, overrideSessionId?: string, description?: string, silent: boolean = false): Promise<{ uri: string; sessionId: string } | { error: string } | null> => { - const effectiveSessionId = overrideSessionId || sessionId; - const formData = new FormData(); - formData.append("upload_file", file); - formData.append("filename", file.name); - // Send sessionId as form field (can be empty string for new sessions) - formData.append("sessionId", effectiveSessionId || ""); - - // Add description as metadata if provided - if (description) { - const metadata = { description }; - formData.append("metadata_json", JSON.stringify(metadata)); - } - - try { - const response = await api.webui.post("/api/v1/artifacts/upload", formData, { fullResponse: true }); - - if (response.status === 413) { - const errorData = await response.json().catch(() => ({ message: `Failed to upload ${file.name}.` })); - const actualSize = errorData.actual_size_bytes; - const maxSize = errorData.max_size_bytes; - const errorMessage = actualSize && maxSize ? createFileSizeErrorMessage(file.name, actualSize, maxSize) : errorData.message || `File "${file.name}" exceeds the maximum allowed size.`; - setError({ title: "File Upload Failed", error: errorMessage }); - return { error: errorMessage }; - } - - if (!response.ok) { - throw new Error( - await response - .json() - .then((d: { message?: string }) => d.message) - .catch(() => `Failed to upload ${file.name}.`) - ); - } - - const result = await response.json(); - if (!silent) { - addNotification(`File "${file.name}" uploaded.`, "success"); - } - await artifactsRefetch(); - return result.uri && result.sessionId ? { uri: result.uri, sessionId: result.sessionId } : null; - } catch (error) { - const errorMessage = getErrorMessage(error, `Failed to upload "${file.name}".`); - setError({ title: "File Upload Failed", error: errorMessage }); - return { error: errorMessage }; - } - }, - [sessionId, addNotification, artifactsRefetch, setError] - ); - // Session State const [sessionName, setSessionName] = useState(null); const [sessionToDelete, setSessionToDelete] = useState(null); const [isLoadingSession, setIsLoadingSession] = useState(false); - const deleteArtifactInternal = useCallback( - async (filename: string) => { - try { - await api.webui.delete(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}`); - addNotification(`File "${filename}" deleted.`, "success"); - artifactsRefetch(); - } catch (error) { - setError({ title: "File Deletion Failed", error: getErrorMessage(error, `Failed to delete ${filename}.`) }); - } - }, - [sessionId, addNotification, artifactsRefetch, setError] - ); - - const openDeleteModal = useCallback((artifact: ArtifactInfo) => { - setArtifactToDelete(artifact); - setIsDeleteModalOpen(true); - }, []); - - const closeDeleteModal = useCallback(() => { - setArtifactToDelete(null); - setIsDeleteModalOpen(false); - }, []); - - const confirmDelete = useCallback(async () => { - if (artifactToDelete) { - // Check if the artifact being deleted is currently being previewed - const isCurrentlyPreviewed = previewArtifact?.filename === artifactToDelete.filename; - - await deleteArtifactInternal(artifactToDelete.filename); - - // If the deleted artifact was being previewed, go back to file list - if (isCurrentlyPreviewed) { - closePreview(); - } - } - closeDeleteModal(); - }, [artifactToDelete, deleteArtifactInternal, closeDeleteModal, previewArtifact, closePreview]); - - const handleDeleteSelectedArtifacts = useCallback(() => { - if (selectedArtifactFilenames.size === 0) { - return; - } - setIsBatchDeleteModalOpen(true); - }, [selectedArtifactFilenames]); - - const confirmBatchDeleteArtifacts = useCallback(async () => { - setIsBatchDeleteModalOpen(false); - const filenamesToDelete = Array.from(selectedArtifactFilenames); - let successCount = 0; - let errorCount = 0; - for (const filename of filenamesToDelete) { - try { - await api.webui.delete(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}`); - successCount++; - } catch (error: unknown) { - console.error(error); - errorCount++; - } - } - if (successCount > 0) addNotification(`${successCount} files(s) deleted.`, "success"); - if (errorCount > 0) { - setError({ title: "File Deletion Failed", error: `${errorCount} file(s) failed to delete.` }); - } - artifactsRefetch(); - setSelectedArtifactFilenames(new Set()); - setIsArtifactEditMode(false); - }, [selectedArtifactFilenames, addNotification, artifactsRefetch, sessionId, setError]); - const openSidePanelTab = useCallback((tab: "files" | "workflow") => { setIsSidePanelCollapsed(false); setActiveSidePanelTab(tab); @@ -581,77 +479,6 @@ export const ChatProvider: React.FC = ({ children }) => { isFinalizing.current = false; }, []); - // Download and resolve artifact with embeds - const downloadAndResolveArtifact = useCallback( - async (filename: string): Promise => { - // Prevent duplicate downloads for the same file - if (artifactDownloadInProgressRef.current.has(filename)) { - console.log(`[ChatProvider] Skipping duplicate download for ${filename} - already in progress`); - return null; - } - - // Mark this file as being downloaded - artifactDownloadInProgressRef.current.add(filename); - - try { - // Find the artifact in state - const artifact = artifacts.find(art => art.filename === filename); - if (!artifact) { - console.error(`Artifact ${filename} not found in state`); - return null; - } - - // Fetch the latest version with embeds resolved - const availableVersions: number[] = await api.webui.get(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}/versions`); - if (!availableVersions || availableVersions.length === 0) { - throw new Error("No versions available"); - } - - const latestVersion = Math.max(...availableVersions); - const contentResponse = await api.webui.get(`/api/v1/artifacts/${sessionId}/${encodeURIComponent(filename)}/versions/${latestVersion}`, { fullResponse: true }); - if (!contentResponse.ok) { - throw new Error(`Failed to fetch artifact content: ${contentResponse.statusText}`); - } - const blob = await contentResponse.blob(); - const base64Content = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result?.toString().split(",")[1] || ""); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - - const fileData: FileAttachment = { - name: filename, - mime_type: artifact.mime_type || "application/octet-stream", - content: base64Content, - last_modified: artifact.last_modified || new Date().toISOString(), - }; - - // Clear the accumulated content and flags after successful download - setArtifacts(prevArtifacts => { - return prevArtifacts.map(art => - art.filename === filename - ? { - ...art, - accumulatedContent: undefined, - needsEmbedResolution: false, - } - : art - ); - }); - - return fileData; - } catch (error) { - setError({ title: "File Download Failed", error: getErrorMessage(error, `Failed to download ${filename}.`) }); - return null; - } finally { - // Remove from in-progress set immediately when done - artifactDownloadInProgressRef.current.delete(filename); - } - }, - [sessionId, artifacts, setArtifacts, setError] - ); - const handleSseMessage = useCallback( (event: MessageEvent) => { sseEventSequenceRef.current += 1; From 3c194e09b2989242bde2fcf264440a4a16de1390 Mon Sep 17 00:00:00 2001 From: lgh-solace Date: Mon, 22 Dec 2025 16:24:06 -0500 Subject: [PATCH 8/9] chore: tidying imports --- .../src/lib/providers/ChatProvider.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/client/webui/frontend/src/lib/providers/ChatProvider.tsx b/client/webui/frontend/src/lib/providers/ChatProvider.tsx index 100868fcd..cda011286 100644 --- a/client/webui/frontend/src/lib/providers/ChatProvider.tsx +++ b/client/webui/frontend/src/lib/providers/ChatProvider.tsx @@ -2,13 +2,12 @@ import React, { useState, useCallback, useEffect, useRef, type FormEvent, type ReactNode } from "react"; import { v4 } from "uuid"; +import { api } from "@/lib/api"; +import { ChatContext, type ChatContextValue, type PendingPromptData } from "@/lib/contexts"; import { useConfigContext, useArtifacts, useAgentCards, useErrorDialog, useBackgroundTaskMonitor, useArtifactPreview, useArtifactOperations } from "@/lib/hooks"; import { useProjectContext, registerProjectDeletedCallback } from "@/lib/providers"; +import { getAccessToken, getErrorMessage, fileToBase64, migrateTask, CURRENT_SCHEMA_VERSION } from "@/lib/utils"; -import { getAccessToken, getErrorMessage } from "@/lib/utils/api"; -import { migrateTask, CURRENT_SCHEMA_VERSION } from "@/lib/utils/taskMigration"; -import { api } from "@/lib/api"; -import { ChatContext, type ChatContextValue, type PendingPromptData } from "@/lib/contexts"; import type { CancelTaskRequest, DataPart, @@ -31,7 +30,6 @@ import type { Project, StoredTaskData, } from "@/lib/types"; -import { fileToBase64 } from "../utils"; const INLINE_FILE_SIZE_LIMIT_BYTES = 1 * 1024 * 1024; // 1 MB @@ -112,8 +110,15 @@ export const ChatProvider: React.FC = ({ children }) => { }); }, []); - // Artifact Preview - const { preview, previewArtifact, openPreview, navigateToVersion, closePreview, setPreviewByArtifact } = useArtifactPreview({ + // Artifact Preview Hook (expanded destructuring for direct access) + const { + preview: { availableVersions: previewedArtifactAvailableVersions, currentVersion: currentPreviewedVersionNumber, content: previewFileContent }, + previewArtifact, + openPreview, + navigateToVersion, + closePreview, + setPreviewByArtifact, + } = useArtifactPreview({ sessionId, projectId: activeProject?.id, artifacts, @@ -1984,9 +1989,9 @@ export const ChatProvider: React.FC = ({ children }) => { confirmBatchDeleteArtifacts, isBatchDeleteModalOpen, setIsBatchDeleteModalOpen, - previewedArtifactAvailableVersions: preview.availableVersions, - currentPreviewedVersionNumber: preview.currentVersion, - previewFileContent: preview.content, + previewedArtifactAvailableVersions, + currentPreviewedVersionNumber, + previewFileContent, openArtifactForPreview: openPreview, navigateArtifactVersion: navigateToVersion, previewArtifact, From 46d07f3f526c280d3b7d4b71078b7ba896ff32bf Mon Sep 17 00:00:00 2001 From: lgh-solace Date: Mon, 22 Dec 2025 16:24:38 -0500 Subject: [PATCH 9/9] chore: tidying imports --- client/webui/frontend/src/lib/providers/ChatProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/webui/frontend/src/lib/providers/ChatProvider.tsx b/client/webui/frontend/src/lib/providers/ChatProvider.tsx index cda011286..745212d7b 100644 --- a/client/webui/frontend/src/lib/providers/ChatProvider.tsx +++ b/client/webui/frontend/src/lib/providers/ChatProvider.tsx @@ -110,7 +110,7 @@ export const ChatProvider: React.FC = ({ children }) => { }); }, []); - // Artifact Preview Hook (expanded destructuring for direct access) + // Artifact Preview const { preview: { availableVersions: previewedArtifactAvailableVersions, currentVersion: currentPreviewedVersionNumber, content: previewFileContent }, previewArtifact,