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..1d21ca5db 100644 --- a/client/webui/frontend/src/lib/hooks/index.ts +++ b/client/webui/frontend/src/lib/hooks/index.ts @@ -1,4 +1,6 @@ export * from "./useAgentCards"; +export * from "./useArtifactOperations"; +export * from "./useArtifactPreview"; export * from "./useArtifactRendering"; export * from "./useArtifacts"; export * from "./useAudioSettings"; 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..407ed269a --- /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; + addNotification: (message: string, type?: "success" | "info" | "warning") => void; + setError: (error: { title: string; error: string }) => void; + previewArtifact: { filename: string } | null; + closePreview: () => 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, addNotification, setError, previewArtifact, closePreview }: 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.`; + 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] + ); + + /** + * Internal function to delete an artifact + */ + 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] + ); + + /** + * 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) { + closePreview(); + } + } + closeDeleteModal(); + }, [artifactToDelete, deleteArtifactInternal, closeDeleteModal, previewArtifact, closePreview]); + + /** + * 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) 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]); + + /** + * 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) { + 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] + ); + + 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..ec5e799e8 --- /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[]; + setError?: (error: { 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, setError }: 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."); + setError?.({ title: "Artifact Preview Failed", error: errorMessage }); + return null; + } finally { + setIsLoading(false); + fetchInProgressRef.current.delete(filename); + } + }, + [sessionId, projectId, preview.filename, getFileAttachment, setError] + ); + + /** + * 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."); + setError?.({ title: "Artifact Version Preview Failed", error: errorMessage }); + return null; + } finally { + setIsLoading(false); + } + }, + [sessionId, projectId, preview.availableVersions, getFileAttachment, setError] + ); + + /** + * 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/providers/ChatProvider.tsx b/client/webui/frontend/src/lib/providers/ChatProvider.tsx index 5b4aae1df..745212d7b 100644 --- a/client/webui/frontend/src/lib/providers/ChatProvider.tsx +++ b/client/webui/frontend/src/lib/providers/ChatProvider.tsx @@ -1,17 +1,14 @@ /* 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, useArtifactOperations } from "@/lib/hooks"; +import { useProjectContext, registerProjectDeletedCallback } from "@/lib/providers"; +import { getAccessToken, getErrorMessage, fileToBase64, migrateTask, CURRENT_SCHEMA_VERSION } from "@/lib/utils"; + import type { - ArtifactInfo, - ArtifactRenderingState, CancelTaskRequest, DataPart, FileAttachment, @@ -31,49 +28,11 @@ 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 => { - return { - ...task, - taskMetadata: { - ...task.taskMetadata, - schema_version: 1, - }, - }; -}; - -// Migration registry: maps version numbers to migration functions - -const MIGRATIONS: Record any> = { - 0: migrateV0ToV1, - // Uncomment when future branch merges: - // 1: migrateV1ToV2, -}; - 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; } @@ -95,10 +54,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(() => { @@ -129,32 +84,6 @@ export const ChatProvider: React.FC = ({ children }) => { 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(), - }); - // Feedback State const [submittedFeedback, setSubmittedFeedback] = useState>({}); @@ -181,6 +110,52 @@ export const ChatProvider: React.FC = ({ children }) => { }); }, []); + // Artifact Preview + const { + preview: { availableVersions: previewedArtifactAvailableVersions, currentVersion: currentPreviewedVersionNumber, content: previewFileContent }, + 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, @@ -409,30 +384,6 @@ export const ChatProvider: React.FC = ({ children }) => { [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; - }, []); - // Helper function to load session tasks and reconstruct messages const loadSessionTasks = useCallback( async (sessionId: string) => { @@ -446,7 +397,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, @@ -498,58 +449,7 @@ export const ChatProvider: React.FC = ({ children }) => { setTaskIdInSidePanel(mostRecentTask.taskId); } }, - [deserializeTaskToMessages, migrateTask] - ); - - 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] + [deserializeTaskToMessages] ); // Session State @@ -557,222 +457,6 @@ export const ChatProvider: React.FC = ({ children }) => { 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); @@ -800,77 +484,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; @@ -1450,7 +1063,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; @@ -1464,7 +1077,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 @@ -1569,7 +1182,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; @@ -1615,7 +1228,7 @@ export const ChatProvider: React.FC = ({ children }) => { activeProject, projects, setActiveProject, - setPreviewArtifact, + closePreview, setError, backgroundTasks, checkTaskStatus, @@ -1669,31 +1282,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 => { @@ -2404,19 +1992,13 @@ export const ChatProvider: React.FC = ({ children }) => { previewedArtifactAvailableVersions, currentPreviewedVersionNumber, previewFileContent, - openArtifactForPreview, - navigateArtifactVersion, + openArtifactForPreview: openPreview, + navigateArtifactVersion: navigateToVersion, previewArtifact, - setPreviewArtifact, // Now uses the wrapper function that sets filename + setPreviewArtifact: setPreviewByArtifact, 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..0f03de669 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 ParsedTaskData 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..8005d78e7 --- /dev/null +++ b/client/webui/frontend/src/lib/utils/file.ts @@ -0,0 +1,90 @@ +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 + */ +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); + + const basePath = isValidSession ? `/api/v1/artifacts/${sessionId}/${encodedFilename}/versions` : `/api/v1/artifacts/null/${encodedFilename}/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}`; + } + + 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/index.ts b/client/webui/frontend/src/lib/utils/index.ts index 8b12fc469..104d137cb 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 "./file-validation"; 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..54e0f2316 --- /dev/null +++ b/client/webui/frontend/src/lib/utils/taskMigration.ts @@ -0,0 +1,99 @@ +import type { ParsedTaskData } from "@/lib/types/storage"; + +export const CURRENT_SCHEMA_VERSION = 1; + +// Migration function type +type MigrationFunction = (task: ParsedTaskData) => ParsedTaskData; + +/** + * 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: ParsedTaskData): ParsedTaskData => { + 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: ParsedTaskData[]): ParsedTaskData[] => { + 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: ParsedTaskData): 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 () => {},