import { FF_DEV_1752, FF_DEV_2186, FF_DEV_2887, FF_DEV_3034, FF_LSDV_4620_3_ML, FF_REGION_VISIBILITY_FROM_URL, isFF, } from "../utils/feature-flags"; import { isDefined } from "../utils/utils"; import { Modal } from "../components/Common/Modal/Modal"; import { CommentsSdk } from "./comments-sdk"; // import { LSFHistory } from "./lsf-history"; import { annotationToServer, taskToLSFormat } from "./lsf-utils"; import { when } from "mobx"; const DEFAULT_INTERFACES = [ "basic", "controls", "submit", "update", "predictions", "topbar", "predictions:menu", // right menu with prediction items "annotations:menu", // right menu with annotation items "annotations:current", "side-column", // entity "edit-history", // undo/redo ]; let LabelStudioDM; const resolveLabelStudio = () => { if (LabelStudioDM) { return LabelStudioDM; } if (window.LabelStudio) { return (LabelStudioDM = window.LabelStudio); } }; // Returns true to suppress (swallow) the error, false to bubble to global handler. // We allow 403 PAUSED to bubble so the app-level ApiProvider can show the paused modal const errorHandlerAllowPaused = (result) => { const isPaused = result?.status === 403 && typeof result?.response === "object" && result?.response?.display_context?.reason === "PAUSED"; return !isPaused; }; // Support portal URL constants used to construct error reporting links // These are used in showOperationToast() to create support links with request IDs // for better error tracking and customer support export const SUPPORT_URL = "https://support.humansignal.com/hc/en-us/requests/new"; export const SUPPORT_URL_REQUEST_ID_PARAM = "tf_37934448633869"; // request_id field ID in ZD export class LSFWrapper { /** @type {HTMLElement} */ root = null; /** @type {DataManager} */ datamanager = null; /** @type {Task} */ task = null; /** @type {Annotation} */ initialAnnotation = null; /** @type {LabelStudio} */ lsf = null; /** @type {LSFHistory} */ // history = null; /** @type {boolean} */ labelStream = false; /** @type {boolean} */ isInteractivePreannotations = false; /** @type {function} */ interfacesModifier = (interfaces) => interfaces; /** * * @param {DataManager} dm * @param {HTMLElement} element * @param {LSFOptions} options */ constructor(dm, element, options) { // we need to pass the rest of the options to LSF below const { task, preload, isLabelStream, annotation, interfacesModifier, isInteractivePreannotations, user, keymap, messages, ...restOptions } = options; this.datamanager = dm; this.store = dm.store; this.root = element; this.task = task; this.preload = preload; this.labelStream = isLabelStream ?? false; this.initialAnnotation = annotation; this.interfacesModifier = interfacesModifier; this.isInteractivePreannotations = isInteractivePreannotations ?? false; let interfaces = [...DEFAULT_INTERFACES]; if (this.project.enable_empty_annotation === false) { interfaces.push("annotations:deny-empty"); } if (window.APP_SETTINGS.annotator_reviewer_firewall_enabled && this.labelStream) { interfaces.push("annotations:hide-info"); } if (this.labelStream) { interfaces.push("infobar"); if (!window.APP_SETTINGS.label_stream_navigation_disabled) interfaces.push("topbar:prevnext"); if (FF_DEV_2186 && this.project.review_settings?.require_comment_on_reject) { interfaces.push("comments:update"); } if (this.project.show_skip_button) { interfaces.push("skip"); } } else { interfaces.push( "infobar", "annotations:add-new", "annotations:view-all", "annotations:delete", "annotations:tabs", "predictions:tabs", ); if (isFF(FF_REGION_VISIBILITY_FROM_URL)) { interfaces.push("annotations:copy-link"); } } if (this.datamanager.hasInterface("instruction")) { interfaces.push("instruction"); } if (!this.labelStream && this.datamanager.hasInterface("groundTruth")) { interfaces.push("ground-truth"); } if (this.datamanager.hasInterface("autoAnnotation")) { interfaces.push("auto-annotation"); } if (isFF(FF_DEV_2887)) { interfaces.push("annotations:comments"); interfaces.push("comments:resolve-any"); } if (this.project.review_settings?.require_comment_on_reject) { interfaces.push("comments:reject"); } if (this.interfacesModifier) { interfaces = this.interfacesModifier(interfaces, this.labelStream); } if (!this.shouldLoadNext()) { interfaces = interfaces.filter((item) => { return !["topbar:prevnext", "skip"].includes(item); }); } const queueTotal = dm.store.project.reviewer_queue_total || dm.store.project.queue_total; const queueDone = dm.store.project.queue_done; const queueLeft = dm.store.project.queue_left; const queuePosition = queueDone ? queueDone + 1 : queueLeft ? queueTotal - queueLeft + 1 : 1; const commentClassificationConfig = dm.store.project.comment_classification_config; const lsfProperties = { user: options.user, config: this.lsfConfig, task: taskToLSFormat(this.task), description: this.instruction, interfaces, users: dm.store.users.map((u) => u.toJSON()), keymap: options.keymap, forceAutoAnnotation: this.isInteractivePreannotations, forceAutoAcceptSuggestions: this.isInteractivePreannotations, messages: options.messages, queueTotal, queuePosition, commentClassificationConfig, /* EVENTS */ onSubmitDraft: this.onSubmitDraft, onLabelStudioLoad: this.onLabelStudioLoad, onTaskLoad: this.onTaskLoad, onPresignUrlForProject: this.onPresignUrlForProject, onStorageInitialized: this.onStorageInitialized, onSubmitAnnotation: this.onSubmitAnnotation, onUpdateAnnotation: this.onUpdateAnnotation, onDeleteAnnotation: this.onDeleteAnnotation, onSkipTask: this.onSkipTask, onUnskipTask: this.onUnskipTask, onGroundTruth: this.onGroundTruth, onEntityCreate: this.onEntityCreate, onEntityDelete: this.onEntityDelete, onSelectAnnotation: this.onSelectAnnotation, onNextTask: this.onNextTask, onPrevTask: this.onPrevTask, ...restOptions, }; this.initLabelStudio(lsfProperties); } /** @private */ initLabelStudio(settings) { try { const LSF = resolveLabelStudio(); this.lsfInstance = new LSF(this.root, settings); this.lsfInstance.on("presignUrlForProject", this.onPresignUrlForProject); const names = Array.from(this.datamanager.callbacks.keys()).filter((k) => k.startsWith("lsf:")); names.forEach((name) => { this.datamanager.getEventCallbacks(name).forEach((clb) => { this.lsfInstance.on(name.replace(/^lsf:/, ""), clb); }); }); if (isFF(FF_DEV_2887)) { new CommentsSdk(this.lsfInstance, this.datamanager); } this.datamanager.invoke("lsfInit", this, this.lsfInstance); } catch (err) { console.error("Failed to initialize LabelStudio", settings); console.error(err); } } /** @private */ async preloadTask() { const { comment: commentId, task: taskID } = this.preload; const api = this.datamanager.api; const params = { taskID }; if (commentId) { params.with_comment = commentId; } if (params) { const task = await api.call("task", { params }); const noData = !task || (!task.annotations?.length && !task.drafts?.length); const body = `Task #${taskID}${commentId ? ` with comment #${commentId}` : ""} was not found!`; if (noData) { Modal.modal({ title: "Can't find task", body, }); return false; } // for preload it's good to always load the first one const annotation = task.annotations[0]; this.selectTask(task, annotation?.id, true); } return false; } /** @private */ async loadTask(taskID, annotationID, fromHistory = false) { if (!this.lsf) { return console.error("Make sure that LSF was properly initialized"); } const nextAction = async () => { const tasks = this.datamanager.store.taskStore; const newTask = await this.withinLoadingState(async () => { let nextTask; if (!isDefined(taskID)) { nextTask = await tasks.loadNextTask(); } else { nextTask = await tasks.loadTask(taskID); } /** * If we're in label stream and there's no task – end the stream * Otherwise allow user to continue exploring tasks after finished labelling */ const noTask = this.labelStream && !nextTask; this.lsf.setFlags({ noTask }); return nextTask; }); // Add new data from received task if (newTask) this.selectTask(newTask, annotationID, fromHistory); }; if (isFF(FF_DEV_2887) && this.lsf?.commentStore?.hasUnsaved) { Modal.confirm({ title: "You have unsaved changes", body: "There are comments which are not persisted. Please submit the annotation. Continuing will discard these comments.", onOk() { nextAction(); }, okText: "Discard and continue", }); return; } await nextAction(); } exitStream() { this.datamanager.invoke("navigate", "projects"); } selectTask(task, annotationID, fromHistory = false) { const needsAnnotationsMerge = task && this.task?.id === task.id; const annotations = needsAnnotationsMerge ? [...this.annotations] : []; this.task = task; if (needsAnnotationsMerge) { this.task.mergeAnnotations(annotations); } this.loadUserLabels(); this.setLSFTask(task, annotationID, fromHistory); } setLSFTask(task, annotationID, fromHistory, selectPrediction = false) { if (!this.lsf) return; const hasChangedTasks = this.lsf?.task?.id !== task?.id && task?.id; this.setLoading(true, hasChangedTasks); const lsfTask = taskToLSFormat(task); const isRejectedQueue = isDefined(task.default_selected_annotation); const taskList = this.datamanager.store.taskStore.list; // annotations are set in LSF only and order in DM only, so combine them const taskHistory = taskList .map((task) => this.taskHistory.find((item) => item.taskId === task.id)) .filter(Boolean); const extracted = taskHistory.find((item) => item.taskId === task.id); if (!fromHistory && extracted) { taskHistory.splice(taskHistory.indexOf(extracted), 1); taskHistory.push(extracted); } if (!extracted) { taskHistory.push({ taskId: task.id, annotationId: null }); } if (isRejectedQueue && !annotationID) { annotationID = task.default_selected_annotation; } if (hasChangedTasks) { this.lsf.resetState(); } else { this.lsf.resetAnnotationStore(); } // Initial idea to show counter for Manual assignment only // But currently we decided to hide it for any stream // const distribution = this.project.assignment_settings.label_stream_task_distribution; // const isManuallyAssigned = distribution === "assigned_only"; // undefined or true for backward compatibility this.lsf.toggleInterface("postpone", this.task.allow_postpone !== false); this.lsf.toggleInterface("topbar:task-counter", true); this.lsf.assignTask(task); this.lsf.initializeStore(lsfTask); this.setAnnotation(annotationID, fromHistory || isRejectedQueue, selectPrediction); this.setLoading(false); } /** @private */ setAnnotation(annotationID, selectAnnotation = false, selectPrediction = false) { const id = annotationID ? annotationID.toString() : null; const { annotationStore: cs } = this.lsf; let annotation; const activeDrafts = cs.annotations.map((a) => a.draftId).filter(Boolean); if (this.task.drafts) { for (const draft of this.task.drafts) { if (activeDrafts.includes(draft.id)) continue; let c; if (draft.annotation) { // Annotation existed - add draft to existed annotation const draftAnnotationPk = String(draft.annotation); c = cs.annotations.find((c) => c.pk === draftAnnotationPk); if (c) { c.history.freeze(); c.addVersions({ draft: draft.result }); c.deleteAllRegions({ deleteReadOnly: true }); } else { // that shouldn't happen console.error(`No annotation found for pk=${draftAnnotationPk}`); continue; } } else { // Annotation not found - restore annotation from draft c = cs.addAnnotation({ draft: draft.result, userGenerate: true, comment_count: draft.comment_count, unresolved_comment_count: draft.unresolved_comment_count, createdBy: draft.created_username, createdAgo: draft.created_ago, createdDate: draft.created_at, }); } cs.selectAnnotation(c.id); c.deserializeResults(draft.result); c.setDraftId(draft.id); c.setDraftSaved(draft.created_at); c.history.safeUnfreeze(); c.history.reinit(); } } const first = this.annotations?.length ? this.annotations[0] : null; // if we have annotations created automatically, we don't need to create another one // automatically === created here and haven't saved yet, so they don't have pk // @todo because of some weird reason pk may be string uid, so check flags then const hasAutoAnnotations = !!first && (!first.pk || (first.userGenerate && first.sentUserGenerate === false)); const showPredictions = this.project.show_collab_predictions === true; if (this.labelStream) { if (first?.draftId) { // not submitted draft, most likely from previous labeling session annotation = first; } else if (isDefined(annotationID) && selectAnnotation) { annotation = this.annotations.find(({ pk }) => pk === annotationID); } else if (showPredictions && this.predictions.length > 0 && !this.isInteractivePreannotations) { annotation = cs.addAnnotationFromPrediction(this.predictions[0]); } else { annotation = cs.createAnnotation(); } } else { if (selectPrediction) { annotation = this.predictions.find((p) => p.pk === id); annotation ??= first; // if prediction not found, select first annotation and resume existing behaviour } else if (this.annotations.length === 0 && this.predictions.length > 0 && !this.isInteractivePreannotations) { const predictionByModelVersion = this.predictions.find((p) => p.createdBy === this.project.model_version); annotation = cs.addAnnotationFromPrediction(predictionByModelVersion ?? this.predictions[0]); } else if (this.annotations.length > 0 && id && id !== "auto") { annotation = this.annotations.find((c) => c.pk === id || c.id === id); } else if (this.annotations.length > 0 && (id === "auto" || hasAutoAnnotations)) { annotation = first; } else { annotation = cs.createAnnotation(); } } if (annotation) { // We want to be sure this is explicitly understood to be a prediction and the // user wants to select it directly if (selectPrediction && annotation.type === "prediction") { cs.selectPrediction(annotation.id); } else { // Otherwise we default the behaviour to being as was before cs.selectAnnotation(annotation.id); } this.datamanager.invoke("annotationSet", annotation); } } saveUserLabels = async () => { const body = []; const userLabels = this.lsf?.userLabels?.controls; if (!userLabels) return; for (const from_name in userLabels) { for (const label of userLabels[from_name]) { body.push({ value: label.path, title: [from_name, JSON.stringify(label.path)].join(":"), from_name, project: this.project.id, }); } } if (!body.length) return; await this.datamanager.apiCall("saveUserLabels", {}, { body }); }; async loadUserLabels() { if (!this.lsf?.userLabels) return; const userLabels = await this.datamanager.apiCall("userLabelsForProject", { project: this.project.id, expand: "label", }); if (!userLabels) return; const controls = {}; for (const result of userLabels.results ?? []) { // don't trust server's response! if (!result?.label?.value?.length) continue; const control = result.from_name; if (!controls[control]) controls[control] = []; controls[control].push(result.label.value); } this.lsf.userLabels.init(controls); } onLabelStudioLoad = async (ls) => { this.datamanager.invoke("labelStudioLoad", ls); this.lsf = ls; if (!this.lsf.task) this.setLoading(true); const _taskHistory = await this.datamanager.store.taskStore.loadTaskHistory({ projectId: this.datamanager.store.project.id, }); this.lsf.setTaskHistory(_taskHistory); await this.loadUserLabels(); if (this.canPreloadTask && isFF(FF_DEV_1752)) { await this.preloadTask(); } else if (this.labelStream) { await this.loadTask(); } this.setLoading(false); }; /** @private */ onTaskLoad = async (...args) => { this.datamanager.invoke("onSelectAnnotation", ...args); }; /** * Proxy urls to presign them if storage is connected * @param {*} _ LS instance * @param {string} url http/https are not proxied and returned as is */ onPresignUrlForProject = (_, url) => { // if URL is a relative, presigned url (url matches /tasks|projects/:id/resolve/.*) make it absolute const presignedUrlPattern = /^\/(?:tasks|projects)\/\d+\/resolve\/?/; if (presignedUrlPattern.test(url)) { url = new URL(url, document.location.origin).toString(); } const parsedUrl = new URL(url); // return same url if http(s) if (["http:", "https:"].includes(parsedUrl.protocol)) return url; const api = this.datamanager.api; const projectId = this.project.id; const fileuri = btoa(url); return api.createUrl(api.endpoints.presignUrlForProject, { projectId, fileuri }).url; }; onStorageInitialized = async (ls) => { this.datamanager.invoke("onStorageInitialized", ls); if (this.task && this.labelStream === false) { const annotationID = this.initialAnnotation?.pk ?? this.task.lastAnnotation?.pk ?? this.task.lastAnnotation?.id ?? "auto"; this.setAnnotation(annotationID); } }; /** @private */ showOperationToast(status, successMessage, errorAction, result) { if (status === 200 || status === 201) { this.datamanager.invoke("toast", { message: successMessage, type: "info" }); } else if (status !== undefined) { const requestId = result?.$meta?.headers?.get("x-ls-request-id"); const supportUrl = requestId ? `${SUPPORT_URL}?${SUPPORT_URL_REQUEST_ID_PARAM}=${requestId}` : SUPPORT_URL; this.datamanager.invoke("toast", { message: ( {errorAction}, please try again or{" "} e.stopPropagation()} > contact our team {" "} if it doesn't help. ), type: "error", }); } } /** @private */ onSubmitAnnotation = async () => { const exitStream = this.shouldExitStream(); const loadNext = exitStream ? false : this.shouldLoadNext(); const result = await this.submitCurrentAnnotation( "submitAnnotation", async (taskID, body) => { return await this.datamanager.apiCall( "submitAnnotation", { taskID }, { body }, // errors are displayed by "toast" event - we don't want to show blocking modal { errorHandler: errorHandlerAllowPaused }, ); }, false, loadNext, ); const status = result?.$meta?.status; this.showOperationToast(status, "Annotation saved successfully", "Annotation is not saved", result); if (exitStream) return this.exitStream(); }; /** @private */ onUpdateAnnotation = async (ls, annotation, extraData) => { const { task } = this; const serializedAnnotation = this.prepareData(annotation); const exitStream = this.shouldExitStream(); Object.assign(serializedAnnotation, extraData); await this.saveUserLabels(); const result = await this.withinLoadingState(async () => { return this.datamanager.apiCall( "updateAnnotation", { taskID: task.id, annotationID: annotation.pk, }, { body: serializedAnnotation, }, // errors are displayed by "toast" event - we don't want to show blocking modal { errorHandler: errorHandlerAllowPaused }, ); }); const status = result?.$meta?.status; this.showOperationToast(status, "Annotation updated successfully", "Annotation is not updated", result); this.datamanager.invoke("updateAnnotation", ls, annotation, result); if (exitStream) return this.exitStream(); if (status >= 400) { return; } const isRejectedQueue = isDefined(task.default_selected_annotation); if (isRejectedQueue) { // load next task if that one was updated task from rejected queue await this.loadTask(); } else { await this.loadTask(this.task.id, annotation.pk, true); } }; deleteDraft = async (id) => { const response = await this.datamanager.apiCall("deleteDraft", { draftID: id, }); this.task.deleteDraft(id); return response; }; /**@private */ onDeleteAnnotation = async (ls, annotation) => { const { task } = this; let response; task.deleteAnnotation(annotation); if (annotation.userGenerate && annotation.sentUserGenerate === false) { if (annotation.draftId) { response = await this.deleteDraft(annotation.draftId); } else { response = { ok: true }; } } else { response = await this.withinLoadingState(async () => { return this.datamanager.apiCall("deleteAnnotation", { taskID: task.id, annotationID: annotation.pk, }); }); // this.task.deleteAnnotation(annotation); this.datamanager.invoke("deleteAnnotation", ls, annotation); } if (response.ok) { const lastAnnotation = this.annotations[this.annotations.length - 1] ?? {}; const annotationID = lastAnnotation.pk ?? undefined; this.setAnnotation(annotationID); } }; draftToast = (status, result = null) => { this.showOperationToast(status, "Draft saved successfully", "Draft is not saved", result); }; needsDraftSave = (annotation) => { if (annotation.history?.hasChanges && !annotation.draftSaved) return true; if ( annotation.history?.hasChanges && new Date(annotation.history.lastAdditionTime) > new Date(annotation.draftSaved) ) return true; return false; }; saveDraft = async (target = null) => { const selected = target || this.lsf?.annotationStore?.selected; const hasChanges = selected ? this.needsDraftSave(selected) : false; if (selected?.isDraftSaving) { await when(() => !selected.isDraftSaving); this.draftToast(200); } else if (hasChanges && selected) { const res = await selected?.saveDraftImmediatelyWithResults(); this.draftToast(res.$meta?.status, res); } }; onSubmitDraft = async (studio, annotation, params = {}) => { // It should be preserved as soon as possible because each `await` will allow it to be changed const taskId = this.task.id; const annotationDoesntExist = !annotation.pk; const data = { body: this.prepareData(annotation, { isNewDraft: true }) }; // serializedAnnotation const hasChanges = this.needsDraftSave(annotation); const showToast = params?.useToast && hasChanges; // console.log('onSubmitDraft', params?.useToast, hasChanges); if (params?.useToast) delete params.useToast; Object.assign(data.body, params); await this.saveUserLabels(); if (annotation.draftId > 0) { // draft has been already created const res = await this.datamanager.apiCall("updateDraft", { draftID: annotation.draftId }, data); showToast && this.draftToast(res.$meta?.status, res); return res; } let response; if (annotationDoesntExist) { response = await this.datamanager.apiCall("createDraftForTask", { taskID: taskId }, data); } else { response = await this.datamanager.apiCall( "createDraftForAnnotation", { taskID: taskId, annotationID: annotation.pk }, data, ); } response?.id && annotation.setDraftId(response?.id); showToast && this.draftToast(response.$meta?.status, response); return response; }; onSkipTask = async (_, { comment } = {}) => { // Manager roles that can force-skip unskippable tasks (OW=Owner, AD=Admin, MA=Manager) const MANAGER_ROLES = ["OW", "AD", "MA"]; const task = this.task; const taskAllowSkip = task?.allow_skip !== false; const userRole = window.APP_SETTINGS?.user?.role; const hasForceSkipPermission = MANAGER_ROLES.includes(userRole); const canSkip = taskAllowSkip || hasForceSkipPermission; if (!canSkip) { console.warn("Task cannot be skipped: allow_skip is false and user lacks manager role"); this.showOperationToast(400, null, "This task cannot be skipped", { error: "Task cannot be skipped" }); return; } const result = await this.submitCurrentAnnotation( "skipTask", async (taskID, body) => { const { id, ...annotation } = body; const params = { taskID }; const options = { body: { ...annotation, was_cancelled: true } }; if (comment) options.body.comment = comment; if (id !== undefined) params.annotationID = id; return await this.datamanager.apiCall( id === undefined ? "submitAnnotation" : "updateAnnotation", params, options, { errorHandler: errorHandlerAllowPaused }, ); }, true, this.shouldLoadNext(), ); const status = result?.$meta?.status; this.showOperationToast(status, "Task skipped successfully", "Task is not skipped", result); }; onUnskipTask = async () => { const { task, currentAnnotation } = this; if (!isDefined(currentAnnotation) && !isDefined(currentAnnotation.pk)) { console.error("Annotation must be on unskip"); return; } await this.withinLoadingState(async () => { currentAnnotation.pauseAutosave(); if (isFF(FF_DEV_3034)) { await this.datamanager.apiCall("convertToDraft", { annotationID: currentAnnotation.pk, }); } else { if (currentAnnotation.draftId > 0) { await this.datamanager.apiCall( "updateDraft", { draftID: currentAnnotation.draftId, }, { body: { annotation: null }, }, ); } else { const annotationData = { body: this.prepareData(currentAnnotation) }; await this.datamanager.apiCall( "createDraftForTask", { taskID: this.task.id, }, annotationData, ); } // Carry over any comments to when the annotation draft is eventually submitted if (isFF(FF_DEV_2887) && this.lsf?.commentStore?.toCache) { this.lsf.commentStore.toCache(`task.${task.id}`); } await this.datamanager.apiCall("deleteAnnotation", { taskID: task.id, annotationID: currentAnnotation.pk, }); } }); await this.loadTask(task.id); this.datamanager.invoke("unskipTask"); }; shouldLoadNext = () => { if (!this.labelStream) return false; // validating if URL is from notification, in case of notification it shouldn't load next task const urlParam = new URLSearchParams(location.search).get("interaction"); return urlParam !== "notifications"; }; shouldExitStream = () => { const paramName = "exitStream"; const urlParam = new URLSearchParams(location.search).get(paramName); const searchParams = new URLSearchParams(window.location.search); searchParams.delete(paramName); let newRelativePathQuery = window.location.pathname; if (searchParams.toString()) newRelativePathQuery += `?${searchParams.toString()}`; window.history.pushState(null, "", newRelativePathQuery); return !!urlParam; }; // Proxy events that are unused by DM integration onEntityCreate = (...args) => this.datamanager.invoke("onEntityCreate", ...args); onEntityDelete = (...args) => this.datamanager.invoke("onEntityDelete", ...args); onSelectAnnotation = (prevAnnotation, nextAnnotation, options) => { if (window.APP_SETTINGS.read_only_quick_view_enabled && !this.labelStream) { prevAnnotation?.setEditable(false); } if (nextAnnotation?.history?.undoIdx) { this.saveDraft(nextAnnotation).then(() => { this.datamanager.invoke("onSelectAnnotation", prevAnnotation, nextAnnotation, options, this); }); } else { this.datamanager.invoke("onSelectAnnotation", prevAnnotation, nextAnnotation, options, this); } }; onNextTask = async (nextTaskId, nextAnnotationId) => { this.saveDraft(); this.loadTask(nextTaskId, nextAnnotationId, true); }; onPrevTask = async (prevTaskId, prevAnnotationId) => { this.saveDraft(); this.loadTask(prevTaskId, prevAnnotationId, true); }; async submitCurrentAnnotation(eventName, submit, includeId = false, loadNext = true) { const { taskID, currentAnnotation } = this; const unique_id = this.task.unique_lock_id; const serializedAnnotation = this.prepareData(currentAnnotation, { includeId }); if (unique_id) { serializedAnnotation.unique_id = unique_id; } this.setLoading(true); await this.saveUserLabels(); const result = await this.withinLoadingState(async () => { const result = await submit(taskID, serializedAnnotation); return result; }); if (result && result.id !== undefined) { const annotationId = result.id.toString(); currentAnnotation.updatePersonalKey(annotationId); const eventData = annotationToServer(currentAnnotation); this.datamanager.invoke(eventName, this.lsf, eventData, result); // Persist any queued comments which are not currently attached to an annotation if ( isFF(FF_DEV_2887) && ["submitAnnotation", "skipTask"].includes(eventName) && this.lsf?.commentStore?.persistQueuedComments ) { await this.lsf.commentStore.persistQueuedComments(); } } this.setLoading(false); if (result?.$meta?.status >= 400) { // don't reload the task on error to avoid losing the user's changes return result; } if (!loadNext || this.datamanager.isExplorer) { await this.loadTask(taskID, currentAnnotation.pk, true); } else { await this.loadTask(); } return result; } /** * Finds the active draft for the given annotation. * @param {Object} annotation - The annotation object. * @returns {Object|undefined} The active draft or undefined if no draft is found. * @private */ findActiveDraft(annotation) { if (isDefined(annotation.draftId)) { return this.task.drafts.find((possibleDraft) => possibleDraft.id === annotation.draftId); } return undefined; } /** * Calculates the startedAt time for an annotation. * @param {Object|undefined} currentDraft - The current draft object, if any. * @param {Date} loadedDate - The date when the annotation was loaded. * @returns {Date} The calculated startedAt time. * @private */ calculateStartedAt(currentDraft, loadedDate) { if (currentDraft) { const draftStartedAt = new Date(currentDraft.created_at); const draftLeadTime = Number(currentDraft.lead_time ?? 0); const adjustedStartedAt = new Date(Date.now() - draftLeadTime * 1000); if (adjustedStartedAt < draftStartedAt) return draftStartedAt; return adjustedStartedAt; } return loadedDate; } /** * Prepare data for draft/submission of annotation * @param {Object} annotation - The annotation object. * @param {Object} options - The options object. * @param {boolean} options.includeId - Whether to include the id in the result. * @param {boolean} options.isNewDraft - Whether the draft is new. * @returns {Object} The prepared data. * @private */ prepareData(annotation, { includeId, isNewDraft } = {}) { const userGenerate = !annotation.userGenerate || annotation.sentUserGenerate; const currentDraft = this.findActiveDraft(annotation); const sessionTime = (Date.now() - annotation.loadedDate.getTime()) / 1000; const submittedTime = isNewDraft ? 0 : Number(annotation.leadTime ?? 0); const draftTime = Number(currentDraft?.lead_time ?? 0); const leadTime = submittedTime + draftTime + sessionTime; const startedAt = this.calculateStartedAt(currentDraft, annotation.loadedDate); const result = { lead_time: leadTime, result: (isNewDraft ? annotation.versions.draft : annotation.serializeAnnotation()) ?? [], draft_id: annotation.draftId, parent_prediction: annotation.parent_prediction, parent_annotation: annotation.parent_annotation, started_at: startedAt.toISOString(), }; if (includeId && userGenerate) { result.id = Number.parseInt(annotation.pk); } return result; } /** @private */ setLoading(isLoading, shouldReset = false) { if (isFF(FF_LSDV_4620_3_ML) && shouldReset) this.lsf.clearApp(); this.lsf.setFlags({ isLoading }); if (isFF(FF_LSDV_4620_3_ML) && shouldReset) this.lsf.renderApp(); } async withinLoadingState(callback) { let result; this.setLoading(true); if (callback) { result = await callback.call(this); } this.setLoading(false); return result; } destroy() { this.lsfInstance?.destroy?.(); this.lsfInstance = null; } get taskID() { return this.task.id; } get taskHistory() { return this.lsf.taskHistory; } get currentAnnotation() { try { return this.lsf.annotationStore.selected; } catch { return null; } } get annotations() { return this.lsf.annotationStore.annotations; } get predictions() { return this.lsf.annotationStore.predictions; } /** @returns {string|null} */ get lsfConfig() { return this.datamanager.store.labelingConfig; } /** @returns {Dict} */ get project() { return this.datamanager.store.project; } /** @returns {string|null} */ get instruction() { return (this.project.instruction ?? this.project.expert_instruction ?? "").trim() || null; } get canPreloadTask() { return Boolean(this.preload?.interaction); } }