diff --git a/llmstack/assets/apis.py b/llmstack/assets/apis.py index 41c92032765..cac6ef6a51c 100644 --- a/llmstack/assets/apis.py +++ b/llmstack/assets/apis.py @@ -6,6 +6,7 @@ from rest_framework.response import Response as DRFResponse from llmstack.apps.models import AppDataAssets, AppSessionFiles +from llmstack.sheets.models import PromptlySheetFiles logger = logging.getLogger(__name__) @@ -113,6 +114,8 @@ def get_asset_data( model_cls = AppSessionFiles elif category == "appdata": model_cls = AppDataAssets + elif category == "sheets": + model_cls = PromptlySheetFiles if not model_cls: logger.error(f"Invalid category for asset model: {category}") diff --git a/llmstack/client/src/components/sheets/Sheet.jsx b/llmstack/client/src/components/sheets/Sheet.jsx index 797fb8fb907..c7dc2915ef6 100644 --- a/llmstack/client/src/components/sheets/Sheet.jsx +++ b/llmstack/client/src/components/sheets/Sheet.jsx @@ -55,6 +55,7 @@ export const SHEET_CELL_TYPE_TAGS = 3; export const SHEET_CELL_TYPE_BOOLEAN = 4; export const SHEET_CELL_TYPE_IMAGE = 5; export const SHEET_CELL_TYPE_OBJECT = 6; +export const SHEET_CELL_TYPE_FILE = 7; export const SHEET_CELL_STATUS_READY = 0; export const SHEET_CELL_STATUS_RUNNING = 1; @@ -93,6 +94,33 @@ export const sheetFormulaTypes = { }, }; +function memoize(func) { + const cache = {}; + return function (...args) { + const key = JSON.stringify(args); + if (cache[key]) { + return cache[key]; + } + const result = func.apply(this, args); + cache[key] = result; + return result; + }; +} + +function objrefURL(objref) { + if (!objref) { + return ""; + } + const objrefParts = objref.split("/"); + if (objrefParts.length < 4) { + return ""; + } + const objrefId = objrefParts[3]; + return `${window.location.origin}/api/sheets/${objrefId}`; +} + +const memoizedObjrefURL = memoize(objrefURL); + export const sheetCellTypes = { [SHEET_CELL_TYPE_TEXT]: { label: "Text", @@ -326,6 +354,39 @@ export const sheetCellTypes = { return cell.data ? JSON.parse(cell.data) : {}; }, }, + [SHEET_CELL_TYPE_FILE]: { + label: "File", + value: "file", + description: "File Object", + kind: GridCellKind.Uri, + getDataGridCell: (cell, column) => { + if (!cell) { + return { + kind: GridCellKind.Uri, + data: "", + displayData: "", + readonly: column?.formula?.type > 0 || false, + allowOverlay: true, + allowWrapping: true, + hoverEffect: true, + }; + } + + let fileURL = memoizedObjrefURL(cell.value); + return { + kind: GridCellKind.Uri, + data: cell.value || "", + displayData: fileURL, + readonly: true, + allowOverlay: true, + allowWrapping: true, + hoverEffect: true, + }; + }, + getCellValue: (cell) => { + return cell.data; + }, + }, }; const MemoizedSheetHeader = React.memo(SheetHeader); @@ -1212,9 +1273,46 @@ function Sheet(props) { setSheetRunning={setSheetRunning} selectedRows={selectedRows} deleteSelectedRows={deleteSelectedRows} + onFileAttach={({ files, startCell }) => { + if (files) { + const cellColumnLetter = startCell[0]; + let cellRow = Number(startCell[1]); + let lastGridCell = null; + for (let i = 0; i < files.length; i++) { + const fileData = files[i]; + const cellId = `${cellColumnLetter}${cellRow + i}`; + const gridCell = cellIdToGridCell(cellId, columns); + lastGridCell = gridCell; + const cell = { + id: cellId, + value: fileData?.data, + }; + const column = columns[gridCell[0]]; + if (gridCell) { + setCells((cells) => ({ + ...cells, + [cell.id]: { + ...cells[cell.id], + row: gridCell[1] + 1, + col_letter: column.col_letter, + value: cell.value, + error: cells[cell.id]?.error || null, + status: cell.value ? 0 : cells[cell.id]?.status || 0, + }, + })); + updateUserChanges("cells", cellId, cell); + sheetRef.current?.updateCells([{ cell: gridCell }]); + } + if (lastGridCell) { + sheetRef.current?.scrollTo(lastGridCell[0], lastGridCell[1]); + } + } + } + }} runId={runId} lastRunAt={lastRunAt} selectedGrid={selectedGrid} + columns={columns} /> { const navigate = useNavigate(); const [anchorEl, setAnchorEl] = useState(null); @@ -56,11 +60,28 @@ const SheetHeader = ({ const [isPreviousRunsModalOpen, setIsPreviousRunsModalOpen] = useState(false); const [isScheduleRunsModalOpen, setIsScheduleRunsModalOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isAttachFileModalOpen, setIsAttachFileModalOpen] = useState(false); const setSheets = useSetRecoilState(sheetsListSelector); const [isSaving, setIsSaving] = useState(false); const [isRunning, setIsRunning] = useState(false); const [isCommandPressed, setIsCommandPressed] = useState(false); const [isExportSchemaModalOpen, setIsExportSchemaModalOpen] = useState(false); + const [isFileColumnGridSelected, setIsFileColumnGridSelected] = + useState(false); + + useEffect(() => { + const fileColumnsLetters = columns + .filter((column) => column.cell_type === 7) + .map((column) => column.col_letter); + let fileColumnSelected = false; + for (let i = 0; i < selectedGrid.length; i++) { + if (fileColumnsLetters.includes(selectedGrid[i][0])) { + fileColumnSelected = true; + break; + } + } + setIsFileColumnGridSelected(fileColumnSelected); + }, [columns, selectedGrid]); const getYamlSchemaFromSheet = useCallback(() => { let schema = {}; @@ -323,6 +344,31 @@ const SheetHeader = ({ )} + {!sheetRunning && isFileColumnGridSelected && ( +
+ + + + + +
+ )} {!sheetRunning && (
@@ -478,6 +524,18 @@ const SheetHeader = ({ sheetUuid={sheet.uuid} /> )} + {isAttachFileModalOpen && ( + { + setIsAttachFileModalOpen(false); + onFileAttach({ files, startCell }); + }} + sheetUuid={sheet.uuid} + sheet={sheet} + selectedGrid={selectedGrid} + /> + )} setIsDeleteDialogOpen(false)} diff --git a/llmstack/client/src/components/sheets/UploadFileModal.jsx b/llmstack/client/src/components/sheets/UploadFileModal.jsx new file mode 100644 index 00000000000..5427227ab48 --- /dev/null +++ b/llmstack/client/src/components/sheets/UploadFileModal.jsx @@ -0,0 +1,136 @@ +import React, { useState, useRef } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + Button, + DialogActions, +} from "@mui/material"; +import { LoadingButton } from "@mui/lab"; +import { axios } from "../../data/axios"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; + +const getFileDataURI = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const base64Data = reader.result.split(",")[1]; + const dataURI = `data:${file.type};name=${file.name};base64,${base64Data}`; + + resolve({ file_name: file.name, mime_type: file.type, data: dataURI }); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; + +function InputFileUpload({ fileRef, maxFiles }) { + const [files, setFiles] = useState([]); + const handleFileChange = (event) => { + setFiles(Array.from(event.target.files).slice(0, maxFiles)); + }; + + return ( + <> + +
+ {files && + Array.from(files) + .slice(0, maxFiles) + .map((file, index) => ( + + {file.name} + + ))} +
+ + ); +} + +const UploadFileModal = ({ open, onClose, sheetUuid, selectedGrid }) => { + const fileRef = useRef(null); + const [isProcessing, setIsProcessing] = useState(false); + const maxFilesAllowed = 5; + + return ( + + Upload Files + + + + + + { + const startCell = selectedGrid[0].split("-")[0]; + setIsProcessing(true); + // Convert the file to a data URI + const fileConvertPromises = Array.from(fileRef.current.files) + .slice(0, maxFilesAllowed) + .map(getFileDataURI); + Promise.all(fileConvertPromises).then((dataURIs) => { + axios() + .post(`/api/sheets/${sheetUuid}/upload_assets`, { + files: dataURIs, + }) + .then((response) => { + setIsProcessing(false); + onClose({ + files: response.data, + startCell: startCell, + }); + }) + .finally(() => { + setIsProcessing(false); + }); + }); + }} + disabled={isProcessing} + loading={isProcessing} + > + Upload + + + + ); +}; + +export default UploadFileModal; diff --git a/llmstack/sheets/apis.py b/llmstack/sheets/apis.py index c8b69273ae8..14a72c998ae 100644 --- a/llmstack/sheets/apis.py +++ b/llmstack/sheets/apis.py @@ -26,6 +26,7 @@ from llmstack.jobs.models import RepeatableJob from llmstack.sheets.models import ( PromptlySheet, + PromptlySheetFiles, PromptlySheetRunEntry, SheetCell, SheetColumn, @@ -258,8 +259,10 @@ def patch(self, request, sheet_uuid=None): if "cells" in request.data: cell_data = request.data.get("cells", {}).values() + cells = [] + for cell in cell_data: + cells.append(SheetCell(**cell)) - cells = [SheetCell(**cell_data) for cell_data in cell_data] sheet.save(cells=cells, update_fields=["data"]) return DRFResponse(PromptlySheetSerializer(instance=sheet).data) @@ -434,6 +437,37 @@ def _execute_agent_run_cell(self, request, input_data, config_data, sheet_id): return {"errors": run_response.get("errors")} return {"errors": "App run failed."} + def upload_sheet_assets(self, request, sheet_uuid=None): + profile = Profile.objects.get(user=request.user) + PromptlySheet.objects.get(uuid=sheet_uuid, profile_uuid=profile.uuid) + objrefs = [] + files = request.data.get("files", []) + for file in files: + file_name = file.get("file_name") + mime_type = file.get("mime_type") + data = file.get("data") + if not file_name or not data: + objrefs.append(None) + asset = PromptlySheetFiles.create_from_data_uri( + data_uri=data, + ref_id=sheet_uuid, + metadata={ + "file_name": file_name, + "mime_type": mime_type, + "username": request.user.email, + "sheet_uuid": sheet_uuid, + }, + ) + objrefs.append( + { + "data": f"objref://sheets/{asset.uuid}", + "file_name": file_name, + "mime_type": mime_type, + } + ) + + return DRFResponse(data=objrefs) + @action(detail=True, methods=["get"]) def list_runs(self, request, sheet_uuid=None): profile = Profile.objects.get(user=request.user) diff --git a/llmstack/sheets/models.py b/llmstack/sheets/models.py index 1fd19e37f1a..91f2f1adcf3 100644 --- a/llmstack/sheets/models.py +++ b/llmstack/sheets/models.py @@ -25,6 +25,7 @@ class SheetCellType(int, Enum): BOOLEAN = 4 IMAGE = 5 OBJECT = 6 + FILE = 7 def __str__(self): return str(self.value) diff --git a/llmstack/sheets/urls.py b/llmstack/sheets/urls.py index 8da10f288fc..f2c260c7f79 100644 --- a/llmstack/sheets/urls.py +++ b/llmstack/sheets/urls.py @@ -26,6 +26,10 @@ "api/sheets//download", PromptlySheetViewSet.as_view({"get": "download"}), ), + path( + "api/sheets//upload_assets", + PromptlySheetViewSet.as_view({"post": "upload_sheet_assets"}), + ), path( "api/sheets//runs//download", PromptlySheetViewSet.as_view({"get": "download_run"}),