diff --git a/.licenserc.yaml b/.licenserc.yaml index 4ce2a448843..a9fcde7e3b9 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -51,6 +51,7 @@ header: - 'frontend/src/assets/svg/operator-view-result.svg' - 'frontend/src/assets/svg/operator-reuse-cache-invalid.svg' - 'frontend/src/assets/svg/operator-reuse-cache-valid.svg' + - 'frontend/src/assets/notebook_migration_tool/jupyter-logo.svg' - 'frontend/src/app/common/type/proto/org/apache/texera/amber/core/virtualidentity.ts' - 'frontend/src/app/common/type/proto/org/apache/texera/amber/core/workflow.ts' - 'frontend/src/app/common/type/proto/google/protobuf/descriptor.ts' diff --git a/frontend/LICENSE-binary b/frontend/LICENSE-binary index 59a2701336e..bb7bde31bf3 100644 --- a/frontend/LICENSE-binary +++ b/frontend/LICENSE-binary @@ -224,6 +224,11 @@ Angular / npm packages: - jschardet@3.1.3 - rxjs@7.8.2 +Third-party assets: + - Google Material Symbols icons (psychology, smart_toy) + frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png + https://github.com/google/material-design-icons + -------------------------------------------------------------------------------- Dependencies under the MIT License -------------------------------------------------------------------------------- @@ -358,6 +363,12 @@ Angular / npm packages: - parchment@3.0.0 - quill@2.0.3 +Third-party assets: + - Project Jupyter logo + frontend/src/assets/notebook_migration_tool/jupyter-logo.svg + frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png + https://github.com/jupyter/design + -------------------------------------------------------------------------------- Dependencies under the ISC License -------------------------------------------------------------------------------- diff --git a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss index 8d8bb0631f9..d395480f2db 100644 --- a/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss +++ b/frontend/src/app/workspace/component/jupyter-notebook-panel/jupyter-notebook-panel.component.scss @@ -19,9 +19,8 @@ .draggable-panel { position: absolute; - top: 50%; - left: 50%; - margin-top: 200px; + top: 100px; + left: 200px; width: 660px; height: 400px; background-color: #fff; diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html index 85a856eaba8..d15ed0b0270 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -149,6 +149,26 @@ nz-icon nzType="info-circle"> + + @@ -456,3 +476,104 @@ + + +
+
+ Notebook to Workflow +
+ + + + +

+ This tool converts a Python Jupyter Notebook into a Texera workflow using LLM capabilities. After you submit a + notebook, the LLM service will generate a corresponding Texera workflow. The conversion time depends on the + notebook’s complexity and can take 1–5 minutes. Once the process is complete, the workflow workspace will reload + with: +

+
    +
  1. + The generated workflow ready to use (Note: you will still need to upload the dataset and connect it to the + workflow). +
  2. +
  3. A floating Jupyter window containing the uploaded notebook for reference.
  4. +
+

+ Feel free to navigate away from this tab while you wait for the workflow to generate. Please do not close the + window. +

+
+ + + + Upload Python Jupyter Notebook + + +
+ + + + + + Selected file: {{ importForm.get('file')?.value?.name }} + +
+
+
+ + + + Select Model Type + + + + + + + + + + + + + + + + + +
+
diff --git a/frontend/src/app/workspace/component/menu/menu.component.scss b/frontend/src/app/workspace/component/menu/menu.component.scss index 414a1c9cef6..2bbc4ffc58b 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.scss +++ b/frontend/src/app/workspace/component/menu/menu.component.scss @@ -190,3 +190,68 @@ texera-coeditor-user-icon { ::ng-deep .layers-dropdown { user-select: none; } + +.jupyter-notebook-icon { + height: 1.1em; + width: auto; + vertical-align: -0.2em; +} + +.import-modal { + // Tighten the default form-item spacing so the modal fits without scrolling. + &-form nz-form-item { + margin-bottom: 12px; + } + + &-diagram { + text-align: center; + margin-bottom: 12px; + + img { + max-width: 100%; + height: auto; + border-radius: 8px; + } + } + + &-text { + margin: 0; + font-size: 15px; + line-height: 1.4; + font-weight: 500; + user-select: text; + } + + &-list { + margin: 4px 0 0; + padding-left: 20px; + font-size: 15px; + line-height: 1.4; + font-weight: 500; + user-select: text; + } + + &-label { + font-size: 14px; + font-weight: 700; + } + + &-upload-row { + display: inline-flex; + align-items: center; + gap: 8px; + + button { + white-space: normal; + } + } + + &-select { + width: 50%; + } + + &-warning { + display: block; + margin-bottom: 12px; + } +} diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts b/frontend/src/app/workspace/component/menu/menu.component.spec.ts index caa86bf614d..7b46b92c64e 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts @@ -38,7 +38,10 @@ import { WorkflowActionService } from "../../service/workflow-graph/model/workfl import { ValidationWorkflowService, ValidationOutput } from "../../service/validation/validation-workflow.service"; import { PanelService } from "../../service/panel/panel.service"; import { WorkflowVersionService } from "../../../dashboard/service/user/workflow-version/workflow-version.service"; -import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; +import { + WorkflowPersistService, + DEFAULT_WORKFLOW_NAME, +} from "../../../common/service/workflow-persist/workflow-persist.service"; import { NotificationService } from "../../../common/service/notification/notification.service"; import { ExecutionState } from "../../types/execute-workflow.interface"; import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface"; @@ -49,7 +52,12 @@ import type { ComputingUnitSelectionComponent } from "../power-button/computing- import { WorkflowContent } from "../../../common/type/workflow"; import { Router } from "@angular/router"; import { ReportGenerationService } from "../../service/report-generation/report-generation.service"; -import { USER_WORKFLOW } from "../../../app-routing.constant"; +import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant"; +import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; +import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; +import { NzUploadFile } from "ng-zorro-antd/upload"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; +import { MockGuiConfigService } from "../../../common/service/gui-config.service.mock"; import type { Mocked } from "vitest"; vi.mock("file-saver", () => ({ saveAs: vi.fn() })); @@ -844,4 +852,505 @@ describe("MenuComponent", () => { expect(input.style.width).toMatch(/^\d+px$/); }); }); + + describe("expand jupyter notebook panel", () => { + it("onClickExpandJupyterNotebookPanel delegates to JupyterPanelService", () => { + const openSpy = vi + .spyOn(TestBed.inject(JupyterPanelService), "openJupyterNotebookPanel") + .mockImplementation(() => {}); + + component.onClickExpandJupyterNotebookPanel(); + + expect(openSpy).toHaveBeenCalled(); + }); + + it("shows the expand-jupyter button only when the flag is on and a notebook exists", () => { + const button = () => fixture.nativeElement.querySelector('button[title="expand Jupyter notebook"]'); + // commonTestProviders' MockGuiConfigService defaults the flag to false, and no notebook exists. + expect(button()).toBeNull(); + + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ + pythonNotebookMigrationEnabled: true, + }); + fixture.detectChanges(); + // Flag on but the current workflow still has no notebook -> hidden. + expect(button()).toBeNull(); + + (TestBed.inject(JupyterPanelService) as any).jupyterNotebookExists$ = of(true); + fixture.detectChanges(); + // Flag on and a notebook exists -> shown. + expect(button()).not.toBeNull(); + }); + }); + + // Coverage for the notebook -> workflow import flow: the modal wiring, the + // upload guard, and the read/generate/persist pipeline. The pipeline tests + // double as regressions for the spinner bugs (emit true on start, emit false + // only at the terminal state of every path). + describe("notebook import", () => { + let notebookMigrationService: NotebookMigrationService; + let jupyterPanelService: JupyterPanelService; + + const validNotebook = { + cells: [{ cell_type: "code", source: "print(1)", metadata: {} }], + metadata: {}, + nbformat: 4, + nbformat_minor: 5, + }; + + // A real File is a Blob, so FileReader.readAsText works in jsdom; NzUploadFile + // is the raw File augmented at runtime, matching what nz-upload passes through. + function ipynbFile(content: unknown, name = "my_nb.ipynb"): NzUploadFile { + return new File([JSON.stringify(content)], name, { type: "application/json" }) as unknown as NzUploadFile; + } + + beforeEach(() => { + notebookMigrationService = TestBed.inject(NotebookMigrationService); + jupyterPanelService = TestBed.inject(JupyterPanelService); + }); + + it("openImportNotebookModal creates the modal seeded with the available models and Cancel/Submit buttons", () => { + vi.spyOn(notebookMigrationService, "getAvailableModels").mockReturnValue(of([{ name: "gpt-4" }])); + const fakeModalRef = { close: vi.fn() } as unknown as NzModalRef; + const createSpy = vi.spyOn(modalService, "create").mockReturnValue(fakeModalRef); + + component.openImportNotebookModal(); + + expect(createSpy).toHaveBeenCalledTimes(1); + const config = createSpy.mock.calls[0][0] as ModalOptions; + expect(config.nzTitle).toBe("AI Generate Workflow from Python Notebook"); + expect((config.nzData as { models$: unknown }).models$).toBeDefined(); + const footer = config.nzFooter as { label: string }[]; + expect(footer.map(b => b.label)).toEqual(["Cancel", "Submit"]); + expect(notebookMigrationService.getAvailableModels).toHaveBeenCalledTimes(1); + }); + + // Opens the modal, returns its Submit button's onClick and the modal ref so tests can + // drive the submit path (which is where the overwrite confirmation lives). + function openModalAndGetSubmit(): { submit: () => void; modalRef: NzModalRef } { + vi.spyOn(notebookMigrationService, "getAvailableModels").mockReturnValue(of([])); + const modalRef = { close: vi.fn() } as unknown as NzModalRef; + const createSpy = vi.spyOn(modalService, "create").mockReturnValue(modalRef); + component.openImportNotebookModal(); + const config = createSpy.mock.calls[0][0] as ModalOptions; + const submit = (config.nzFooter as { label: string; onClick: () => void }[]).find(b => b.label === "Submit")!; + return { submit: submit.onClick, modalRef }; + } + + it("Submit imports directly (no confirmation) when the current workflow is empty", () => { + const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false); + const confirmSpy = vi.spyOn(modalService, "confirm").mockImplementation(() => ({}) as NzModalRef); + + const { submit, modalRef } = openModalAndGetSubmit(); + submit(); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(importSpy).toHaveBeenCalledTimes(1); + expect(modalRef.close).toHaveBeenCalled(); + }); + + it("Submit confirms before overwriting a non-empty workflow, and imports only on confirm", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false); + const confirmSpy = vi.spyOn(modalService, "confirm").mockImplementation(() => ({}) as NzModalRef); + + const { submit, modalRef } = openModalAndGetSubmit(); + submit(); + + // Confirmation is shown; the import has not started and the modal is still open. + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(importSpy).not.toHaveBeenCalled(); + expect(modalRef.close).not.toHaveBeenCalled(); + + // Confirming ("Overwrite") starts the import and closes the modal. + const confirmConfig = confirmSpy.mock.calls[0][0] as { nzOnOk: () => void }; + confirmConfig.nzOnOk(); + expect(importSpy).toHaveBeenCalledTimes(1); + expect(modalRef.close).toHaveBeenCalled(); + }); + + it("beforeUpload stores the file on the form and prevents auto-upload", () => { + const file = { name: "x.ipynb" } as NzUploadFile; + + const result = component.beforeUpload(file); + + expect(result).toBe(false); + expect(component.importForm.get("file")?.value).toBe(file); + }); + + it("rejects a non-ipynb file without entering the loading state", () => { + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + const result = component.onClickImportNotebook({ name: "data.txt" } as NzUploadFile, "gpt-4"); + + expect(result).toBe(false); + expect(errorSpy).toHaveBeenCalledWith("Please upload a valid Jupyter Notebook (.ipynb) file."); + expect(emitSpy).not.toHaveBeenCalledWith(true); + }); + + // Import always overwrites the current workflow: it reuses the current wid so + // persistWorkflow updates that row in place. When the current workflow was never + // saved (no wid) a new row is created and the wid changes, which routes the notebook + // send + panel open through JupyterPanelService.init() instead of doing it here. + function stubGenerationServices() { + // 1 == the notebook reached Jupyter; the in-place path opens the panel only on 1. + vi.spyOn(notebookMigrationService, "sendNotebookToJupyter").mockResolvedValue(1 as any); + vi.spyOn(notebookMigrationService, "sendToAIGenerateWorkflow").mockResolvedValue({ + workflowContent: { operators: [], links: [], commentBoxes: [], settings: {} } as unknown as WorkflowContent, + mappingContent: {} as any, + }); + vi.spyOn(notebookMigrationService, "setMapping").mockImplementation(() => {}); + vi.spyOn(notebookMigrationService, "storeNotebookAndMapping").mockReturnValue(of({ success: true }) as any); + vi.spyOn(workflowActionService, "reloadWorkflow").mockImplementation(() => {}); + vi.spyOn(jupyterPanelService, "openPanel").mockImplementation(() => {}); + vi.spyOn(notificationService, "success").mockImplementation(() => {}); + // The new-row branch updates the URL via Location.go; stub it out. + vi.spyOn(location, "go").mockImplementation(() => {}); + } + + it("overwrites the saved current workflow in place, reloads it, and opens the panel itself", async () => { + stubGenerationServices(); + // Saved current workflow (wid 7); persist keeps the same wid, so the wid does not change. + vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7 } as any); + const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({ wid: 7 } as any)); + const autoLayoutSpy = vi.spyOn(component, "onClickAutoLayout").mockImplementation(() => {}); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4"); + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + + expect(emitSpy).toHaveBeenCalledWith(true); + // Reuses the current wid so the row is overwritten in place; reloads synchronously on the + // live canvas and tidies the layout. + expect(persistSpy.mock.calls[0][0].wid).toBe(7); + expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith({ wid: 7 }, false); + expect(autoLayoutSpy).toHaveBeenCalled(); + // wid unchanged: we send the notebook + open the panel ourselves (init() does not react). + expect(notebookMigrationService.sendNotebookToJupyter).toHaveBeenCalled(); + expect(jupyterPanelService.openPanel).toHaveBeenCalledWith("JupyterNotebookPanel"); + // Stayed on the same workflow, so the URL is not changed. + expect(location.go).not.toHaveBeenCalled(); + }); + + it("marks isWaitingForLLM true at the start of import and false once the flow settles", async () => { + stubGenerationServices(); + vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7 } as any); + vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({ wid: 7 } as any)); + vi.spyOn(component, "onClickAutoLayout").mockImplementation(() => {}); + + component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4"); + // emit(true) fires synchronously at the start of the import. + expect(component.isWaitingForLLM).toBe(true); + + await vi.waitFor(() => expect(component.isWaitingForLLM).toBe(false)); + }); + + it("disables the AI-generate button while a conversion is in flight", () => { + const button = () => + fixture.nativeElement.querySelector('button[title="AI generate workflow"]') as HTMLButtonElement; + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ + pythonNotebookMigrationEnabled: true, + }); + // Isolate the waiting flag's effect from the modifiable gate. + component.isWorkflowModifiable = true; + fixture.detectChanges(); + expect(button().disabled).toBe(false); + + component.isWaitingForLLM = true; + fixture.detectChanges(); + expect(button().disabled).toBe(true); + }); + + it("does not open the panel when the notebook fails to reach Jupyter", async () => { + stubGenerationServices(); + // sendNotebookToJupyter resolves 0 on failure (it toasts the error itself). + vi.spyOn(notebookMigrationService, "sendNotebookToJupyter").mockResolvedValue(0 as any); + vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7 } as any); + vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({ wid: 7 } as any)); + vi.spyOn(component, "onClickAutoLayout").mockImplementation(() => {}); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4"); + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + // Let the sendNotebookToJupyter().then(...) microtask settle before asserting. + await Promise.resolve(); + + // The reload still happened, but the panel stays closed since the send failed. + expect(notebookMigrationService.sendNotebookToJupyter).toHaveBeenCalled(); + expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith({ wid: 7 }, false); + expect(jupyterPanelService.openPanel).not.toHaveBeenCalled(); + }); + + it("creates a new row and points the URL at it when the current workflow was never saved", async () => { + stubGenerationServices(); + // Current workflow has no wid; persist returns a new wid, so the wid changes. + vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: undefined } as any); + const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({ wid: 99 } as any)); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4"); + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + + // No current wid -> the backend inserts a new row. + expect(persistSpy.mock.calls[0][0].wid).toBeUndefined(); + expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith({ wid: 99 }, false); + expect(location.go).toHaveBeenCalledWith(`${USER_WORKSPACE}/99`); + // wid changed: JupyterPanelService.init() sends the notebook + opens the panel, not us, + // so the "sent to Jupyter" toast fires only once. + expect(notebookMigrationService.sendNotebookToJupyter).not.toHaveBeenCalled(); + expect(jupyterPanelService.openPanel).not.toHaveBeenCalled(); + }); + + it("on LLM error: surfaces an error notification and clears the loading flag", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(notebookMigrationService, "sendNotebookToJupyter").mockResolvedValue(undefined as any); + vi.spyOn(notebookMigrationService, "sendToAIGenerateWorkflow").mockRejectedValue(new Error("boom")); + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4"); + + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + + expect(emitSpy).toHaveBeenCalledWith(true); + expect(errorSpy).toHaveBeenCalledWith("Error while communicating with LLM, check console for details"); + }); + + it("on invalid notebook structure: surfaces an error, clears the loading flag, and never calls jupyter", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const jupyterSpy = vi.spyOn(notebookMigrationService, "sendNotebookToJupyter"); + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + // No `cells` array -> the structure guard throws before any network call. + component.onClickImportNotebook(ipynbFile({ metadata: {} }), "gpt-4"); + + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + + expect(errorSpy).toHaveBeenCalledWith("Failed to import the notebook."); + expect(jupyterSpy).not.toHaveBeenCalled(); + }); + + it("falls back to the default workflow name when the file has no base name", async () => { + stubGenerationServices(); + vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7 } as any); + const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({ wid: 7 } as any)); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + // A file named ".ipynb" has an empty base name, so the default name is used. + component.onClickImportNotebook(ipynbFile(validNotebook, ".ipynb"), "gpt-4"); + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + + expect(persistSpy.mock.calls[0][0].name).toBe(`${DEFAULT_WORKFLOW_NAME}_GENERATED_BY_LLM`); + }); + + it("uses the whole file name when it has no dot", async () => { + stubGenerationServices(); + vi.spyOn(workflowActionService, "getWorkflow").mockReturnValue({ wid: 7 } as any); + const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({ wid: 7 } as any)); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + // A file named "ipynb" (no dot) passes the extension check and has no extension to strip, + // so the whole name becomes the base name. + component.onClickImportNotebook(ipynbFile(validNotebook, "ipynb"), "gpt-4"); + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + + expect(persistSpy.mock.calls[0][0].name).toBe("ipynb_GENERATED_BY_LLM"); + }); + + it("tags code cells that arrive without a metadata object", async () => { + stubGenerationServices(); + const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({ wid: 5 } as any)); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + const notebookWithoutCellMetadata = { + cells: [{ cell_type: "code", source: "x = 1" }], + metadata: {}, + nbformat: 4, + }; + component.onClickImportNotebook(ipynbFile(notebookWithoutCellMetadata), "gpt-4"); + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + + expect(persistSpy).toHaveBeenCalledTimes(1); + }); + + it("on persist failure: surfaces an error notification and clears the loading flag", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(notebookMigrationService, "sendToAIGenerateWorkflow").mockResolvedValue({ + workflowContent: { operators: [], links: [], commentBoxes: [], settings: {} } as unknown as WorkflowContent, + mappingContent: {} as any, + }); + vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(throwError(() => new Error("db down"))); + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4"); + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + + expect(errorSpy).toHaveBeenCalledWith("Failed to import notebook, check console for detailed error"); + }); + + it("on file read error: surfaces an error and clears the loading flag", async () => { + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + // Swap in a FileReader that errors instead of loading, so reader.onerror runs. + const RealFileReader = globalThis.FileReader; + class FakeFileReader { + onerror: ((e: unknown) => void) | null = null; + onload: (() => void) | null = null; + readAsText(): void { + setTimeout(() => this.onerror?.(new Error("read fail")), 0); + } + } + (globalThis as any).FileReader = FakeFileReader; + try { + component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4"); + await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledWith("Failed to read the notebook file.")); + expect(emitSpy).toHaveBeenCalledWith(false); + } finally { + (globalThis as any).FileReader = RealFileReader; + } + }); + + it("Cancel closes the modal", () => { + vi.spyOn(notebookMigrationService, "getAvailableModels").mockReturnValue(of([])); + const modalRef = { close: vi.fn() } as unknown as NzModalRef; + const createSpy = vi.spyOn(modalService, "create").mockReturnValue(modalRef); + + component.openImportNotebookModal(); + const config = createSpy.mock.calls[0][0] as ModalOptions; + const cancel = (config.nzFooter as { label: string; onClick: () => void }[]).find(b => b.label === "Cancel")!; + cancel.onClick(); + + expect(modalRef.close).toHaveBeenCalled(); + }); + + it("Submit is disabled until the form has both a file and a model", () => { + vi.spyOn(notebookMigrationService, "getAvailableModels").mockReturnValue(of([])); + const modalRef = { close: vi.fn() } as unknown as NzModalRef; + const createSpy = vi.spyOn(modalService, "create").mockReturnValue(modalRef); + + component.openImportNotebookModal(); + const config = createSpy.mock.calls[0][0] as ModalOptions; + const submit = (config.nzFooter as { label: string; disabled: () => boolean }[]).find(b => b.label === "Submit")!; + + // Empty form -> disabled. + component.importForm.reset({ file: null, model: "" }); + expect(submit.disabled()).toBe(true); + + // Both required controls filled -> enabled. + component.importForm.setValue({ file: { name: "x.ipynb" } as NzUploadFile, model: "gpt-4" }); + expect(submit.disabled()).toBe(false); + }); + + it("on non-string file content: surfaces an error and clears the loading flag", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + // Swap in a FileReader that loads a non-string result, so the string guard throws. + const RealFileReader = globalThis.FileReader; + class FakeFileReader { + result: unknown = null; + onerror: (() => void) | null = null; + onload: (() => void) | null = null; + readAsText(): void { + setTimeout(() => this.onload?.(), 0); + } + } + (globalThis as any).FileReader = FakeFileReader; + try { + component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4"); + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + expect(errorSpy).toHaveBeenCalledWith("Failed to import the notebook."); + } finally { + (globalThis as any).FileReader = RealFileReader; + } + }); + + it("when the LLM returns no result: surfaces an error, clears the loading flag, and never persists", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(notebookMigrationService, "sendToAIGenerateWorkflow").mockResolvedValue(undefined as any); + const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow"); + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const emitSpy = vi.spyOn(component.setWaitingForLLM, "emit"); + + component.onClickImportNotebook(ipynbFile(validNotebook), "gpt-4"); + await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith(false)); + + expect(errorSpy).toHaveBeenCalledWith("No workflow was generated from the notebook."); + expect(persistSpy).not.toHaveBeenCalled(); + }); + }); + + // The import modal body is an passed to NzModal as nzContent, so it is + // never rendered by the component's own change detection. Render it directly through the + // component's ViewContainerRef (the same context NzModal supplies: nzData as $implicit) + // to exercise the form markup and the three model-select states. + describe("import modal template", () => { + // Renders #importNotebookModal with the given models$ observable and returns the view. + function renderModal(models$: unknown) { + const vcr = (component as any).viewContainerRef; + const viewRef = vcr.createEmbeddedView(component.importModalTpl, { $implicit: { models$ } }); + viewRef.detectChanges(); + return viewRef; + } + + // Finds the first element matching selector across the view's root nodes and their subtrees. + function query(viewRef: any, selector: string): Element | null { + for (const node of viewRef.rootNodes as Node[]) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as Element; + if (el.matches(selector)) return el; + const found = el.querySelector(selector); + if (found) return found; + } + return null; + } + + function text(viewRef: any): string { + return (viewRef.rootNodes as Node[]).map(n => n.textContent ?? "").join(""); + } + + it("renders the warning, diagram, and a usable model select once models load", () => { + const viewRef = renderModal(of([{ name: "gpt-4" }])); + try { + expect(query(viewRef, ".import-modal-warning")).not.toBeNull(); + expect(query(viewRef, "img[alt='Notebook to Workflow']")).not.toBeNull(); + expect(query(viewRef, "nz-select")).not.toBeNull(); + expect(text(viewRef)).toContain("Select a model"); + } finally { + viewRef.destroy(); + } + }); + + it("shows the disabled 'no models available' select when the list is empty", () => { + const viewRef = renderModal(of([])); + try { + expect(text(viewRef)).toContain("No models available"); + } finally { + viewRef.destroy(); + } + }); + + it("shows the loading select while models have not resolved yet", () => { + // A subject that never emits keeps the async pipe pending, so the loading branch renders. + const viewRef = renderModal(new Subject()); + try { + expect(text(viewRef)).toContain("Loading models..."); + } finally { + viewRef.destroy(); + } + }); + + it("shows the selected file name once a file is on the form", () => { + component.importForm.patchValue({ file: { name: "demo.ipynb" } }); + const viewRef = renderModal(of([{ name: "gpt-4" }])); + try { + expect(text(viewRef)).toContain("Selected file: demo.ipynb"); + } finally { + viewRef.destroy(); + } + }); + }); }); diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts b/frontend/src/app/workspace/component/menu/menu.component.ts index a205b2b8bbf..e61fec5a04d 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.ts @@ -17,9 +17,21 @@ * under the License. */ -import { DatePipe, Location, NgIf, NgFor, NgTemplateOutlet } from "@angular/common"; -import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { DatePipe, Location, NgIf, NgFor, NgTemplateOutlet, AsyncPipe, NgOptimizedImage } from "@angular/common"; +import { + Component, + ElementRef, + Input, + OnDestroy, + OnInit, + ViewChild, + Output, + EventEmitter, + TemplateRef, + ViewContainerRef, +} from "@angular/core"; import { Router, RouterLink } from "@angular/router"; +import { FormBuilder, FormGroup, Validators } from "@angular/forms"; import { UserService } from "../../../common/service/user/user.service"; import { DEFAULT_WORKFLOW_NAME, @@ -43,14 +55,14 @@ import { saveAs } from "file-saver"; import { NotificationService } from "src/app/common/service/notification/notification.service"; import { OperatorMenuService } from "../../service/operator-menu/operator-menu.service"; import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service"; -import { EMPTY, firstValueFrom, of, timer } from "rxjs"; +import { EMPTY, firstValueFrom, of, timer, map } from "rxjs"; import { isDefined } from "../../../common/util/predicate"; import { NzModalService } from "ng-zorro-antd/modal"; import { ResultExportationComponent } from "../result-exportation/result-exportation.component"; import { ReportGenerationService } from "../../service/report-generation/report-generation.service"; import { ShareAccessComponent } from "src/app/dashboard/component/user/share-access/share-access.component"; import { PanelService } from "../../service/panel/panel.service"; -import { USER_WORKFLOW } from "../../../app-routing.constant"; +import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant"; import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface"; import { ComputingUnitSelectionComponent } from "../power-button/computing-unit-selection.component"; @@ -74,6 +86,14 @@ import { NzPopoverDirective } from "ng-zorro-antd/popover"; import { NzSwitchComponent } from "ng-zorro-antd/switch"; import { NzBadgeComponent } from "ng-zorro-antd/badge"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; +import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; +import { v4 as uuidv4 } from "uuid"; +import { Notebook } from "../../service/notebook-migration/migration-llm"; +import { NotebookMigrationService } from "../../service/notebook-migration/notebook-migration.service"; +import { NzFormModule } from "ng-zorro-antd/form"; +import { NzSelectModule } from "ng-zorro-antd/select"; +import { NzAlertModule } from "ng-zorro-antd/alert"; +import { ReactiveFormsModule } from "@angular/forms"; /** * MenuComponent is the top level menu bar that shows @@ -122,6 +142,12 @@ import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; NzTooltipDirective, DatePipe, NzSpaceCompactComponent, + NzFormModule, + AsyncPipe, + NzSelectModule, + NzAlertModule, + ReactiveFormsModule, + NgOptimizedImage, ], }) export class MenuComponent implements OnInit, OnDestroy { @@ -147,6 +173,9 @@ export class MenuComponent implements OnInit, OnDestroy { @Input() public currentExecutionName: string = ""; // reset executionName @Input() public particularVersionDate: string = ""; // placeholder for the metadata information of a particular workflow version @ViewChild("workflowNameInput") workflowNameInput: ElementRef | undefined; + // Emit an event to parent component (workspace) when AI generation starts or stops + @Output() public setWaitingForLLM = new EventEmitter(); + public isWaitingForLLM = false; // variable bound with HTML to decide if the running spinner should show public runButtonText = "Run"; @@ -165,6 +194,9 @@ export class MenuComponent implements OnInit, OnDestroy { @ViewChild(ComputingUnitSelectionComponent) computingUnitSelectionComponent!: ComputingUnitSelectionComponent; + public importForm: FormGroup; + @ViewChild("importNotebookModal", { static: true }) importModalTpl!: TemplateRef; + constructor( public executeWorkflowService: ExecuteWorkflowService, public workflowActionService: WorkflowActionService, @@ -187,7 +219,11 @@ export class MenuComponent implements OnInit, OnDestroy { private panelService: PanelService, private computingUnitStatusService: ComputingUnitStatusService, protected config: GuiConfigService, - private router: Router + private router: Router, + private fb: FormBuilder, + private viewContainerRef: ViewContainerRef, + private jupyterPanelService: JupyterPanelService, + private notebookMigrationService: NotebookMigrationService ) { workflowWebsocketService .subscribeToEvent("ExecutionDurationUpdateEvent") @@ -214,6 +250,11 @@ export class MenuComponent implements OnInit, OnDestroy { // Subscribe to computing unit this.subscribeToComputingUnitSelection(); this.subscribeToComputingUnitStatus(); + + this.importForm = this.fb.group({ + file: [null, Validators.required], + model: ["", Validators.required], + }); } public ngOnInit(): void { @@ -584,6 +625,245 @@ export class MenuComponent implements OnInit, OnDestroy { this.workflowActionService.deleteOperatorsAndLinks(allOperatorIDs); } + public get pythonNotebookMigrationEnabled(): boolean { + return this.config.env.pythonNotebookMigrationEnabled; + } + + // Emits whether the current workflow has an associated Jupyter notebook, used to + // show the expand button only when there is a notebook to expand. + public get jupyterNotebookExists$() { + return this.jupyterPanelService.jupyterNotebookExists$; + } + + /** + * Expand and redisplay the Jupyter notebook panel. + */ + public onClickExpandJupyterNotebookPanel(): void { + this.jupyterPanelService.openJupyterNotebookPanel(); + } + + openImportNotebookModal(): void { + const models$ = this.notebookMigrationService.getAvailableModels(); + + const modalRef = this.modalService.create({ + nzTitle: "AI Generate Workflow from Python Notebook", + nzContent: this.importModalTpl, + nzViewContainerRef: this.viewContainerRef, + nzWidth: 700, + nzData: { + models$: models$, + }, + nzFooter: [ + { + label: "Cancel", + onClick: () => { + modalRef.close(); + }, + }, + { + label: "Submit", + type: "primary", + disabled: () => !this.importForm.valid, + onClick: () => { + const file: NzUploadFile = this.importForm.get("file")?.value; + const model: string = this.importForm.get("model")?.value; + const startImport = () => { + this.onClickImportNotebook(file, model); + modalRef.close(); + }; + // Generating overwrites the currently open workflow. Confirm first only when + // there is actual content to replace; a fresh empty workflow needs no prompt. + const graph = this.workflowActionService.getTexeraGraph(); + const currentWorkflowHasContent = + graph.getAllOperators().length > 0 || graph.getAllCommentBoxes().length > 0; + if (currentWorkflowHasContent) { + this.modalService.confirm({ + nzTitle: "Overwrite current workflow?", + nzContent: + "Generating will replace the contents of the workflow you have open. " + + "The previous version is kept in this workflow's version history.", + nzOkText: "Overwrite", + nzOkDanger: true, + nzOnOk: startImport, + }); + } else { + startImport(); + } + }, + }, + ], + }); + } + + public beforeUpload = (file: NzUploadFile) => { + this.importForm.patchValue({ file }); + this.importForm.get("file")?.markAsDirty(); + this.importForm.get("file")?.updateValueAndValidity(); + return false; // prevent auto upload + }; + + public onClickImportNotebook = (file: NzUploadFile, model: string): boolean => { + const reader = new FileReader(); + + // Check if the file is a Jupyter notebook based on its extension + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + if (fileExtension !== "ipynb") { + this.notificationService.error("Please upload a valid Jupyter Notebook (.ipynb) file."); + return false; + } + + this.emitWaitingForLLM(true); // start loading + + // Read the notebook file as text + reader.readAsText(file as any); + reader.onload = async () => { + try { + const result = reader.result; + if (typeof result !== "string") { + throw new Error("File content is not a valid string."); + } + + // Parse the content of the .ipynb file (it's in JSON format) + const notebookContent = JSON.parse(result) as Notebook; + + // Validate the notebook structure + if (!notebookContent || !Array.isArray(notebookContent.cells)) { + throw new Error("Invalid notebook structure."); + } + + // Add UUID's to each cell in the notebook + for (const cell of notebookContent.cells) { + if (!cell.metadata) { + cell.metadata = {}; + } + cell.metadata.uuid = uuidv4(); + } + + // Get workflow and mapping from LLM + await this.notebookMigrationService + .sendToAIGenerateWorkflow(notebookContent, model) + .then(result => { + if (result) { + const { workflowContent, mappingContent } = result; + + const fileExtensionIndex = file.name.lastIndexOf("."); + let workflowName: string; + if (fileExtensionIndex === -1) { + workflowName = file.name; + } else { + workflowName = file.name.substring(0, fileExtensionIndex); + } + if (workflowName.trim() === "") { + workflowName = DEFAULT_WORKFLOW_NAME; + } + + // Always overwrite the current workflow: reuse its wid so persistWorkflow + // updates that row in place instead of inserting a new one (which would leave + // a duplicate behind). Read it now, after generation, so a wid assigned by + // auto-persist during the wait is picked up. If the current workflow was never + // saved, wid is undefined and a new row is created (there is nothing to overwrite). + const reuseWid = this.workflowActionService.getWorkflow().wid; + + const workflow: Workflow = { + content: workflowContent, + name: `${workflowName}_GENERATED_BY_LLM`, + isPublished: 0, + description: undefined, + wid: reuseWid, + creationTime: undefined, + lastModifiedTime: undefined, + readonly: false, + }; + + this.workflowPersistService + .persistWorkflow(workflow) + .pipe( + switchMap((updatedWorkflow: Workflow) => { + const mappingID = "mapping_wid_" + updatedWorkflow.wid; + + this.notebookMigrationService.setMapping(mappingID, mappingContent); + + return this.notebookMigrationService + .storeNotebookAndMapping(updatedWorkflow.wid, 1, mappingContent, notebookContent) + .pipe(map(() => updatedWorkflow)); + }), + untilDestroyed(this) + ) + .subscribe({ + next: updatedWorkflow => { + this.notificationService.success("Successfully generated workflow and mapping from notebook."); + // Reload the generated workflow onto the current (already live) canvas so it + // renders immediately; we never remount the workspace. Render synchronously + // (asyncRendering = false) so the operators exist before auto-layout runs. + this.workflowActionService.reloadWorkflow(updatedWorkflow, false); + // Tidy the LLM-generated layout; the position changes get auto-persisted. + this.onClickAutoLayout(); + if (reuseWid === updatedWorkflow.wid) { + // Overwrote the current workflow in place: the wid did not change, so + // JupyterPanelService.init() does not react. Send the notebook to Jupyter + // and open the panel ourselves. Use openPanel, not openJupyterNotebookPanel: + // init()'s wid-change handler is not involved and openPanel opens + // unconditionally without the hasMapping gate. + // sendNotebookToJupyter never rejects: it resolves 1 on success and 0 on + // failure (it toasts the error itself). Open the panel only on success so we + // do not float it over a blank iframe, matching the init()-driven path which + // opens only when fetchNotebookAndMapping reports the send succeeded. + this.notebookMigrationService.sendNotebookToJupyter(notebookContent).then(result => { + if (result == 1) { + this.jupyterPanelService.openPanel("JupyterNotebookPanel"); + } + }); + } else { + // The current workflow had never been saved, so a new row was created and the + // wid changed. reloadWorkflow's synchronous wid change drives init() to fetch + // the stored notebook/mapping, send it to Jupyter, and open the panel, so we + // do not do that here (doing so would double the "sent to Jupyter" toast). + // Point the URL at the generated workflow. + this.location.go(`${USER_WORKSPACE}/${updatedWorkflow.wid}`); + } + }, + error: (err: unknown) => { + this.notificationService.error("Failed to import notebook, check console for detailed error"); + console.error("Import notebook failed:", err); + this.emitWaitingForLLM(false); + }, + complete: () => { + this.emitWaitingForLLM(false); + }, + }); + } else { + this.notificationService.error("No workflow was generated from the notebook."); + console.error("Result is undefined"); + this.emitWaitingForLLM(false); + } + }) + .catch(error => { + this.notificationService.error("Error while communicating with LLM, check console for details"); + console.error("Error while fetching data from LLM: ", error); + this.emitWaitingForLLM(false); + }); + } catch (error) { + this.notificationService.error("Failed to import the notebook."); + console.error(error); + this.emitWaitingForLLM(false); + } + }; + + reader.onerror = () => { + this.notificationService.error("Failed to read the notebook file."); + this.emitWaitingForLLM(false); + }; + + return false; // Prevent automatic upload handling + }; + + // Keeps the local waiting flag and the parent-facing output in lockstep so the + // AI-generate button can be disabled while a conversion is in flight. + private emitWaitingForLLM(waiting: boolean): void { + this.isWaitingForLLM = waiting; + this.setWaitingForLLM.emit(waiting); + } + public onClickImportWorkflow = (file: NzUploadFile): boolean => { const reader = new FileReader(); reader.readAsText(file as any); diff --git a/frontend/src/app/workspace/component/workspace.component.html b/frontend/src/app/workspace/component/workspace.component.html index 89eeb656f18..78155285d4d 100644 --- a/frontend/src/app/workspace/component/workspace.component.html +++ b/frontend/src/app/workspace/component/workspace.component.html @@ -21,7 +21,22 @@ + nzTip="Loading workflow..."> + + +
+ + + @if (isWaitingForLLM) { +
+
Waiting for LLM response...
+
Estimated time 1-5 minutes
+
Do not close this tab
+
Elapsed time: {{ formattedElapsedTime }}
+
+ }
@@ -29,7 +44,8 @@ + [pid]="pid" + (setWaitingForLLM)="onWaitingForLLMChanged($event)"> diff --git a/frontend/src/app/workspace/component/workspace.component.scss b/frontend/src/app/workspace/component/workspace.component.scss index 60fc4abf401..012fa3dfead 100644 --- a/frontend/src/app/workspace/component/workspace.component.scss +++ b/frontend/src/app/workspace/component/workspace.component.scss @@ -31,6 +31,12 @@ texera-menu { background-color: white; } +texera-jupyter-notebook-panel { + position: absolute; + top: 0; + left: 0; +} + texera-mini-map { position: absolute; bottom: 0; @@ -57,3 +63,27 @@ texera-workflow-editor { :host { user-select: none; } + +.openai-spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + z-index: 10; + pointer-events: none; +} + +.llm-spinner-text { + margin-top: 20px; + text-align: center; +} + +.elapsed-time { + color: #ff4d4f; + font-weight: 500; +} diff --git a/frontend/src/app/workspace/component/workspace.component.spec.ts b/frontend/src/app/workspace/component/workspace.component.spec.ts index f85294e42ad..d930af601fd 100644 --- a/frontend/src/app/workspace/component/workspace.component.spec.ts +++ b/frontend/src/app/workspace/component/workspace.component.spec.ts @@ -479,4 +479,86 @@ describe("WorkspaceComponent", () => { expect(typeof codeEditorService.vc.createEmbeddedView).toBe("function"); }); }); + + // The LLM waiting spinner is driven by an elapsed-time timer started/stopped + // from the menu's setWaitingForLLM output. These tests pin the 1s cadence, the + // single-digit minute format, the stop-on-idle behavior, and, crucially, that + // the interval is cleared on destroy so it cannot keep firing detectChanges on + // a torn-down view. + describe("LLM waiting timer", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("formattedElapsedTime is 0:00 before the timer starts", async () => { + await createFixture(); + fixture.detectChanges(); + expect(component.formattedElapsedTime).toBe("0:00"); + }); + + it("onWaitingForLLMChanged(true) starts the timer and advances elapsed time each second", async () => { + vi.useFakeTimers(); + await createFixture(); + fixture.detectChanges(); + + component.onWaitingForLLMChanged(true); + expect(component.isWaitingForLLM).toBe(true); + expect(component.formattedElapsedTime).toBe("0:00"); + + vi.advanceTimersByTime(1000); + expect(component.formattedElapsedTime).toBe("0:01"); + + // 1 minute 2 seconds later; minutes are not zero-padded. + vi.advanceTimersByTime(61000); + expect(component.formattedElapsedTime).toBe("1:02"); + }); + + it("onWaitingForLLMChanged(false) stops the timer so elapsed time no longer advances", async () => { + vi.useFakeTimers(); + await createFixture(); + fixture.detectChanges(); + + component.onWaitingForLLMChanged(true); + vi.advanceTimersByTime(1000); + expect(component.formattedElapsedTime).toBe("0:01"); + + component.onWaitingForLLMChanged(false); + expect(component.isWaitingForLLM).toBe(false); + // startTime is reset and the interval cleared, so further ticks do nothing. + vi.advanceTimersByTime(5000); + expect(component.formattedElapsedTime).toBe("0:00"); + }); + + it("clears the interval on destroy so the timer stops firing after teardown", async () => { + vi.useFakeTimers(); + await createFixture(); + fixture.detectChanges(); + + component.onWaitingForLLMChanged(true); + vi.advanceTimersByTime(1000); + + const clearSpy = vi.spyOn(globalThis, "clearInterval"); + component.ngOnDestroy(); + + expect(clearSpy).toHaveBeenCalled(); + }); + + it("clears the previous interval when the timer is started again without stopping", async () => { + vi.useFakeTimers(); + await createFixture(); + fixture.detectChanges(); + + const clearSpy = vi.spyOn(globalThis, "clearInterval"); + + component.onWaitingForLLMChanged(true); + const firstInterval = (component as any).timerInterval; + + // A second start (e.g. a double click) must not leave the first interval running. + component.onWaitingForLLMChanged(true); + const secondInterval = (component as any).timerInterval; + + expect(secondInterval).not.toBe(firstInterval); + expect(clearSpy).toHaveBeenCalledWith(firstInterval); + }); + }); }); diff --git a/frontend/src/app/workspace/component/workspace.component.ts b/frontend/src/app/workspace/component/workspace.component.ts index b978107c83f..bdc0cdb8a9b 100644 --- a/frontend/src/app/workspace/component/workspace.component.ts +++ b/frontend/src/app/workspace/component/workspace.component.ts @@ -93,6 +93,10 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { public pid?: number = undefined; public writeAccess: boolean = false; public isLoading: boolean = false; + // variable to track whether we are waiting for AI to finish generating (whether a loading icon should show) + public isWaitingForLLM = false; + private timerInterval: ReturnType | null = null; + private startTime: number | null = null; @ViewChild("codeEditor", { read: ViewContainerRef }) codeEditorViewRef!: ViewContainerRef; /** @@ -200,6 +204,7 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { // re-entered workflow starts clean instead of reusing the previous one. this.computingUnitStatusService.disconnect(); this.resetWorkflowSessionState(); + this.stopTimer(); } /** @@ -320,6 +325,7 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { this.registerAutoPersistWorkflow(); }); } + onWIDChange() { this.workflowActionService .workflowMetaDataChanged() @@ -333,6 +339,7 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { this.writeAccess = !metadata.readonly; }); } + updateViewCount() { let wid = this.route.snapshot.params.id; let uid = this.userService.getCurrentUser()?.uid; @@ -342,6 +349,7 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { .pipe(untilDestroyed(this)) .subscribe(); } + public triggerCenter(): void { this.workflowActionService.getTexeraGraph().triggerCenterEvent(); } @@ -354,4 +362,43 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { public get copilotEnabled(): boolean { return this.config.env.copilotEnabled; } + + onWaitingForLLMChanged(isWaiting: boolean) { + this.isWaitingForLLM = isWaiting; + + if (isWaiting) { + this.startTimer(); + } else { + this.stopTimer(); + } + } + + startTimer() { + this.stopTimer(); // clear any interval already running so repeated starts don't stack + this.startTime = Date.now(); + this.updateElapsedTime(); + this.timerInterval = setInterval(() => { + this.updateElapsedTime(); + }, 1000); + } + + stopTimer() { + if (this.timerInterval !== null) { + clearInterval(this.timerInterval); + } + this.timerInterval = null; + this.startTime = null; + } + + updateElapsedTime() { + this.changeDetectorRef.detectChanges(); + } + + get formattedElapsedTime(): string { + if (!this.startTime) return "0:00"; + const diff = Date.now() - this.startTime; + const minutes = Math.floor(diff / 60000); + const seconds = Math.floor((diff % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + } } diff --git a/frontend/src/assets/notebook_migration_tool/jupyter-logo.svg b/frontend/src/assets/notebook_migration_tool/jupyter-logo.svg new file mode 100644 index 00000000000..4a63571eb56 --- /dev/null +++ b/frontend/src/assets/notebook_migration_tool/jupyter-logo.svg @@ -0,0 +1,42 @@ + +logo-8.svg +Created using Figma 0.90 + + + + + + + + + + + diff --git a/frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png b/frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png new file mode 100644 index 00000000000..d17ed923fd9 Binary files /dev/null and b/frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png differ