This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5273-1b61a0e46b087172d56bbf9a01eaa0cb91845eb6 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 8b9d017cdb26e7153d2784602fac971fc6470601 Author: Ryan Zhang <[email protected]> AuthorDate: Thu Aug 6 15:52:36 2026 -0700 feat(python-notebook-migration, frontend): add AI-generate-workflow modal, panel host, and LLM loading spinner (#5273) ### What changes were proposed in this PR? Adds the user facing entry point for the Python notebook to Texera workflow migration tool: an "AI generate workflow" button in the workspace menu that opens the import modal (added in #7314), a workspace loading overlay with an elapsed time stopwatch shown while the LLM converts, auto layout of the generated workflow, and the `<texera-jupyter-notebook-panel>` host that embeds the iframe panel component from #5271. **Menu toolbar (`menu.component.{ts,html,scss}`)** - Two buttons, both gated on the `pythonNotebookMigrationEnabled` config flag: - "AI generate workflow" (robot icon), also gated on workflow modifiability and disabled while a conversion is in flight, opens the import modal. - "Expand Jupyter notebook" (Jupyter logo), shown only when the current workflow has a stored notebook (`jupyterNotebookExists$`), reopens the panel. - `openImportNotebookModal()` opens `NotebookImportModalComponent` (from #7314) with `NzModalService`. The modal collects the `.ipynb` file and the LiteLLM model and returns them to the menu. - Generating overwrites the current workflow. The menu shows a confirmation only when the current workflow already has content (operators or comment boxes). The confirmation is centered over the modal, and the modal stays open if the user cancels so the file and model selection is preserved; approving, or an empty workflow, starts the import. - `onClickImportNotebook(file, model)` runs the generation: - validates the `.ipynb` extension, then emits `setWaitingForLLM(true)` to show the overlay - parses the notebook and tags each cell with a `metadata.uuid` - calls `sendToAIGenerateWorkflow(...)` to get the generated workflow content and the cell to operator mapping - always overwrites the current workflow by reusing its wid so `persistWorkflow(...)` updates that row in place (a never saved workflow becomes a new row), which avoids leaving a duplicate "Untitled workflow" - persists the notebook and mapping via `storeNotebookAndMapping(...)` - reloads the workflow onto the live canvas and runs auto layout on the generated operators - shows the panel: when the wid is unchanged it sends the notebook and, only if the send succeeded, opens the panel directly; when a new row was created it points the URL at the generated workflow so `JupyterPanelService.init()` sends the notebook and opens the panel (avoids a double "sent to Jupyter" toast) - clears the overlay at every terminal state, including file read errors, parse failures, invalid notebooks, and LLM errors **Workspace (`workspace.component.{ts,html,scss}`)** - Hosts `<texera-jupyter-notebook-panel>` (component landed in #5271, now on main). - Adds a loading overlay driven by the menu's `setWaitingForLLM` output, with an `mm:ss` elapsed time stopwatch. The interval is cleared on component destroy and reset if a new conversion starts. **Assets and licensing** - `jupyter-logo.svg`: the Project Jupyter logo mark (BSD 3-Clause, from jupyter/design), used as the expand button icon. - `.licenserc.yaml`: excludes `jupyter-logo.svg` from the license header check, since SVG assets carry no Apache header. - `frontend/LICENSE-binary`: adds `jupyter-logo.svg` under the Project Jupyter logo attribution (BSD 3-Clause). The import modal component, its diagram image, and that diagram's attributions (Project Jupyter logo and Google Material Symbols icons) are in #<modal-PR>. ### Demo <img width="423" height="185" alt="Screenshot 2026-08-03 at 2 15 49 PM" src="https://github.com/user-attachments/assets/fe6706bf-ee2e-4933-a140-c316e07f1f89" /> New menu buttons <img width="1512" height="838" alt="image" src="https://github.com/user-attachments/assets/bc7c1e5a-f90f-431b-91e9-076b7002fc8a" /> Import notebook popup <img width="1511" height="835" alt="image" src="https://github.com/user-attachments/assets/d4e6c19f-269c-431d-9306-6abba6a5d0b0" /> Waiting for LLM spinner <img width="1512" height="836" alt="image" src="https://github.com/user-attachments/assets/a3020b94-87bc-49e9-b2b3-ade1c3c8e6f2" /> Workflow canvas with a Jupyter notebook panel https://github.com/user-attachments/assets/52b901c7-3a27-406c-b251-6e45f7236fb5 Mapping highlighting from operator to cell and vice versa ### Any related issues, documentation, discussions? Closes #4427 Parent issue #4301 Depends on #7314 (the import modal component) and #5271 (the Jupyter notebook panel), both required on `main` before this merges. ### How was this PR tested? - Added unit specs in `menu.component.spec.ts` and `workspace.component.spec.ts`. #### Manual testing `npx ng test` for the menu and workspace specs. The menu spec covers opening the modal component, the overwrite confirmation flow (empty workflow, confirm, and cancel), the generation pipeline across its success and error paths, and the two toolbar button clicks. The workspace spec covers the elapsed time stopwatch and interval cleanup. Manual end to end on a branch with the dependency PRs present: enabled the flag, opened the workspace menu, clicked AI generate workflow, picked a sample `.ipynb` and a LiteLLM model (auth is via the user's Texera JWT, no API key input), confirmed the overwrite prompt, and watched the stopwatch while the conversion ran. The generated workflow appeared in the editor with `PythonUDFV2` operators auto laid out, the panel opened with the notebook in the embedded JupyterLab, clicking an operator scrolled its notebook cell into view, and clicking a cell highlighted its operators. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --------- Co-authored-by: Meng Wang <[email protected]> --- .licenserc.yaml | 1 + frontend/LICENSE-binary | 1 + .../jupyter-notebook-panel.component.scss | 10 +- .../workspace/component/menu/menu.component.html | 20 + .../workspace/component/menu/menu.component.scss | 6 + .../component/menu/menu.component.spec.ts | 455 ++++++++++++++++++++- .../app/workspace/component/menu/menu.component.ts | 267 +++++++++++- .../workspace/component/workspace.component.html | 20 +- .../workspace/component/workspace.component.scss | 30 ++ .../component/workspace.component.spec.ts | 82 ++++ .../app/workspace/component/workspace.component.ts | 47 +++ .../notebook_migration_tool/jupyter-logo.svg | 42 ++ 12 files changed, 967 insertions(+), 14 deletions(-) diff --git a/.licenserc.yaml b/.licenserc.yaml index 4ce2a44884..a9fcde7e3b 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 fcd134da14..61e929c65c 100644 --- a/frontend/LICENSE-binary +++ b/frontend/LICENSE-binary @@ -366,6 +366,7 @@ Angular / npm packages: Third-party assets: - Project Jupyter logo frontend/src/assets/notebook_migration_tool/tool_popup_diagram.png + frontend/src/assets/notebook_migration_tool/jupyter-logo.svg https://github.com/jupyter/design -------------------------------------------------------------------------------- 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 8d8bb0631f..3433cfc1ae 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; @@ -30,7 +29,8 @@ border-radius: 5px; z-index: 1000; resize: both; - overflow: auto; + // The iframe scrolls its own content; the wrapper must not add a second scrollbar. + overflow: hidden; } .panel-header { @@ -74,4 +74,6 @@ width: 100%; height: 100%; border: none; + // Block removes the inline-element baseline gap that would otherwise overflow the wrapper. + display: block; } diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html index 33fbf539f6..8b5314b49a 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -137,6 +137,26 @@ nz-icon nzType="info-circle"></i> </button> + <button + *ngIf="pythonNotebookMigrationEnabled" + nz-button + [disabled]="!isWorkflowModifiable || isWaitingForLLM" + (click)="openImportNotebookModal()" + title="AI generate workflow"> + <i + nz-icon + nzType="robot"></i> + </button> + <button + *ngIf="pythonNotebookMigrationEnabled && (jupyterNotebookExists$ | async)" + nz-button + (click)="onClickExpandJupyterNotebookPanel()" + title="expand Jupyter notebook"> + <img + class="jupyter-notebook-icon" + src="assets/notebook_migration_tool/jupyter-logo.svg" + alt="Jupyter" /> + </button> </nz-space-compact> <ng-template #utilities> <nz-space-compact> diff --git a/frontend/src/app/workspace/component/menu/menu.component.scss b/frontend/src/app/workspace/component/menu/menu.component.scss index 1b3c45a52d..cefda1cd79 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.scss +++ b/frontend/src/app/workspace/component/menu/menu.component.scss @@ -189,3 +189,9 @@ texera-coeditor-user-icon { ::ng-deep .layers-dropdown { user-select: none; } + +.jupyter-notebook-icon { + height: 1.1em; + width: auto; + vertical-align: -0.2em; +} 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 f766651b26..f35cea2ac1 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,13 @@ 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 { NotebookImportModalComponent } from "../notebook-import-modal/notebook-import-modal.component"; +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() })); @@ -861,4 +870,446 @@ 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(); + }); + + it("clicking the expand-jupyter button opens the panel", () => { + const openSpy = vi + .spyOn(TestBed.inject(JupyterPanelService), "openJupyterNotebookPanel") + .mockImplementation(() => {}); + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ + pythonNotebookMigrationEnabled: true, + }); + (TestBed.inject(JupyterPanelService) as any).jupyterNotebookExists$ = of(true); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector( + 'button[title="expand Jupyter notebook"]' + ) as HTMLButtonElement; + button.click(); + + expect(openSpy).toHaveBeenCalled(); + }); + }); + + // 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 opens the NotebookImportModalComponent with a requestImport callback and no menu footer", () => { + const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as unknown as NzModalRef); + + 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.nzContent).toBe(NotebookImportModalComponent); + expect(config.nzFooter).toBeNull(); + expect(typeof (config.nzData as { requestImport: unknown }).requestImport).toBe("function"); + }); + + // Opens the modal and returns the requestImport callback the menu handed to it; calling + // it drives the overwrite-confirm + import decision (true => close modal, false => keep open). + function getRequestImport(): (file: NzUploadFile, model: string) => Promise<boolean> { + const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as unknown as NzModalRef); + component.openImportNotebookModal(); + const config = createSpy.mock.calls[0][0] as ModalOptions; + return (config.nzData as { requestImport: (file: NzUploadFile, model: string) => Promise<boolean> }) + .requestImport; + } + + it("imports directly and resolves true when the current workflow is empty", async () => { + const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false); + const confirmSpy = vi.spyOn(modalService, "confirm").mockImplementation(() => ({}) as NzModalRef); + + const proceed = await getRequestImport()({ name: "x.ipynb" } as NzUploadFile, "gpt-4"); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(importSpy).toHaveBeenCalledWith({ name: "x.ipynb" }, "gpt-4"); + expect(proceed).toBe(true); + }); + + it("rejects a non-ipynb file: resolves false, errors, and neither confirms nor imports", async () => { + const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false); + const confirmSpy = vi.spyOn(modalService, "confirm").mockImplementation(() => ({}) as NzModalRef); + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + + const proceed = await getRequestImport()({ name: "data.txt" } as NzUploadFile, "gpt-4"); + + // Resolving false keeps the modal open with the selection preserved; nothing started. + expect(proceed).toBe(false); + expect(errorSpy).toHaveBeenCalledWith("Please upload a valid Jupyter Notebook (.ipynb) file."); + expect(confirmSpy).not.toHaveBeenCalled(); + expect(importSpy).not.toHaveBeenCalled(); + }); + + it("confirms before overwriting a non-empty workflow, imports and resolves true on confirm", async () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false); + const confirmSpy = vi.spyOn(modalService, "confirm").mockImplementation(() => ({}) as NzModalRef); + + const proceedPromise = getRequestImport()({ name: "x.ipynb" } as NzUploadFile, "gpt-4"); + + // Confirmation is shown; the import has not started. + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(importSpy).not.toHaveBeenCalled(); + + // Confirming ("Overwrite") starts the import and lets the modal close. + const confirmConfig = confirmSpy.mock.calls[0][0] as { nzOnOk: () => void }; + confirmConfig.nzOnOk(); + await expect(proceedPromise).resolves.toBe(true); + expect(importSpy).toHaveBeenCalledWith({ name: "x.ipynb" }, "gpt-4"); + }); + + it("resolves false without importing when the overwrite confirmation is cancelled", async () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false); + const confirmSpy = vi.spyOn(modalService, "confirm").mockImplementation(() => ({}) as NzModalRef); + + const proceedPromise = getRequestImport()({ name: "x.ipynb" } as NzUploadFile, "gpt-4"); + + // Backing out keeps the modal open (resolve false) and starts no import. + const confirmConfig = confirmSpy.mock.calls[0][0] as { nzOnCancel: () => void }; + confirmConfig.nzOnCancel(); + await expect(proceedPromise).resolves.toBe(false); + expect(importSpy).not.toHaveBeenCalled(); + }); + + 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("clicking the AI-generate button opens the import modal", () => { + const openSpy = vi.spyOn(component, "openImportNotebookModal").mockImplementation(() => {}); + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ + pythonNotebookMigrationEnabled: true, + }); + component.isWorkflowModifiable = true; // enable the button so the click lands + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button[title="AI generate workflow"]') as HTMLButtonElement; + button.click(); + + expect(openSpy).toHaveBeenCalled(); + }); + + 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("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(); + }); + }); }); diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts b/frontend/src/app/workspace/component/menu/menu.component.ts index 8e5f16199d..8930d9d0bb 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.ts @@ -17,11 +17,14 @@ * 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 } from "@angular/common"; +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, Output, EventEmitter } from "@angular/core"; import { Router, RouterLink } from "@angular/router"; import { UserService } from "../../../common/service/user/user.service"; -import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; +import { + DEFAULT_WORKFLOW_NAME, + WorkflowPersistService, +} from "../../../common/service/workflow-persist/workflow-persist.service"; import { Workflow, WorkflowContent } from "../../../common/type/workflow"; import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; import { UndoRedoService } from "../../service/undo-redo/undo-redo.service"; @@ -39,14 +42,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"; @@ -70,6 +73,15 @@ 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 { + NotebookImportModalComponent, + NotebookImportModalData, +} from "../notebook-import-modal/notebook-import-modal.component"; +import { NzUploadFile } from "ng-zorro-antd/upload"; /** * MenuComponent is the top level menu bar that shows @@ -117,6 +129,7 @@ import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; NzTooltipDirective, DatePipe, NzSpaceCompactComponent, + AsyncPipe, ], }) export class MenuComponent implements OnInit, OnDestroy { @@ -142,6 +155,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<HTMLInputElement> | undefined; + // Emit an event to parent component (workspace) when AI generation starts or stops + @Output() public setWaitingForLLM = new EventEmitter<boolean>(); + public isWaitingForLLM = false; // variable bound with HTML to decide if the running spinner should show public runButtonText = "Run"; @@ -182,7 +198,9 @@ export class MenuComponent implements OnInit, OnDestroy { private panelService: PanelService, private computingUnitStatusService: ComputingUnitStatusService, protected config: GuiConfigService, - private router: Router + private router: Router, + private jupyterPanelService: JupyterPanelService, + private notebookMigrationService: NotebookMigrationService ) { workflowWebsocketService .subscribeToEvent("ExecutionDurationUpdateEvent") @@ -579,6 +597,243 @@ 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(); + } + + public openImportNotebookModal(): void { + // The modal owns the upload form and the model dropdown. It delegates the decision to + // proceed back here via requestImport so we keep the overwrite-confirm and the + // generation pipeline (and the workflow/persist/jupyter state they touch) in the menu. + this.modalService.create<NotebookImportModalComponent, NotebookImportModalData>({ + nzTitle: "AI Generate Workflow from Python Notebook", + nzContent: NotebookImportModalComponent, + nzWidth: 700, + nzFooter: null, + // Center in the viewport so the overwrite confirm (also centered) overlays this modal's center. + nzCentered: true, + nzData: { + requestImport: (file, model) => this.confirmAndImport(file, model), + }, + }); + } + + // Decides whether an import may proceed, then kicks it off. Resolves true when the import + // has started (the modal should close), false when the user backs out of the overwrite + // confirmation (the modal should stay open with the selection intact). + private confirmAndImport(file: NzUploadFile, model: string): Promise<boolean> { + // Reject a non-notebook file here, before starting anything, so the modal stays open + // with the selection intact (resolving false) instead of closing on a no-op import. + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + if (fileExtension !== "ipynb") { + this.notificationService.error("Please upload a valid Jupyter Notebook (.ipynb) file."); + return Promise.resolve(false); + } + const startImport = () => this.onClickImportNotebook(file, model); + // 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) { + startImport(); + return Promise.resolve(true); + } + return new Promise<boolean>(resolve => { + 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, + // Center over the import modal, and leave only Cancel/Overwrite (no X, no click-outside). + nzCentered: true, + nzClosable: false, + nzMaskClosable: false, + nzOnOk: () => { + startImport(); + resolve(true); + }, + nzOnCancel: () => resolve(false), + }); + }); + } + + 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 onClickExportWorkflow(): void { const workflowContent: WorkflowContent = this.workflowActionService.getWorkflowContent(); const workflowContentJson = JSON.stringify(workflowContent, null, 2); diff --git a/frontend/src/app/workspace/component/workspace.component.html b/frontend/src/app/workspace/component/workspace.component.html index 89eeb656f1..78155285d4 100644 --- a/frontend/src/app/workspace/component/workspace.component.html +++ b/frontend/src/app/workspace/component/workspace.component.html @@ -21,7 +21,22 @@ <nz-spin [nzSpinning]="isLoading" [nzSize]="'large'" - nzTip="Loading workflow..."></nz-spin> + nzTip="Loading workflow..."> + </nz-spin> +</div> +<div class="openai-spinner"> + <nz-spin + [nzSize]="'large'" + [nzSpinning]="isWaitingForLLM"></nz-spin> + + @if (isWaitingForLLM) { + <div class="llm-spinner-text"> + <div>Waiting for LLM response...</div> + <div>Estimated time 1-5 minutes</div> + <div class="elapsed-time">Do not close this tab</div> + <div class="elapsed-time">Elapsed time: {{ formattedElapsedTime }}</div> + </div> + } </div> <div id="result"> <texera-result-panel></texera-result-panel> @@ -29,7 +44,8 @@ <texera-workflow-editor></texera-workflow-editor> <texera-menu [writeAccess]="writeAccess" - [pid]="pid"> + [pid]="pid" + (setWaitingForLLM)="onWaitingForLLMChanged($event)"> </texera-menu> <texera-mini-map class="box"></texera-mini-map> <texera-left-panel> </texera-left-panel> diff --git a/frontend/src/app/workspace/component/workspace.component.scss b/frontend/src/app/workspace/component/workspace.component.scss index 60fc4abf40..012fa3dfea 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 f85294e42a..d930af601f 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 b978107c83..bdc0cdb8a9 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<typeof setInterval> | 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 0000000000..4a63571eb5 --- /dev/null +++ b/frontend/src/assets/notebook_migration_tool/jupyter-logo.svg @@ -0,0 +1,42 @@ +<svg width="39" height="51" viewBox="0 0 39 51" version="2.0" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:figma="http://www.figma.com/figma/ns"> +<title>logo-8.svg</title> +<desc>Created using Figma 0.90</desc> +<g id="Canvas" transform="translate(-2362 -2281)" figma:type="canvas"> +<g id="logo" style="mix-blend-mode:normal;" figma:type="group"> +<g id="g" style="mix-blend-mode:normal;" figma:type="group"> +<g id="path" style="mix-blend-mode:normal;" figma:type="group"> +<g id="path7 fill" style="mix-blend-mode:normal;" figma:type="vector"> +<use xlink:href="#path0_fill" transform="translate(2393.3 2281.31)" style="mix-blend-mode:normal;"/> +</g> +</g> +<g id="path" style="mix-blend-mode:normal;" figma:type="group"> +<g id="path8 fill" style="mix-blend-mode:normal;" figma:type="vector"> +<use xlink:href="#path1_fill" transform="translate(2363.74 2311.98)" style="mix-blend-mode:normal;"/> +</g> +</g> +<g id="path" style="mix-blend-mode:normal;" figma:type="group"> +<g id="path9 fill" style="mix-blend-mode:normal;" figma:type="vector"> +<use xlink:href="#path2_fill" transform="translate(2363.73 2285.48)" style="mix-blend-mode:normal;"/> +</g> +</g> +<g id="path" style="mix-blend-mode:normal;" figma:type="group"> +<g id="path10 fill" style="mix-blend-mode:normal;" figma:type="vector"> +<use xlink:href="#path3_fill" transform="translate(2363.8 2323.81)" style="mix-blend-mode:normal;"/> +</g> +</g> +<g id="path" style="mix-blend-mode:normal;" figma:type="group"> +<g id="path11 fill" style="mix-blend-mode:normal;" figma:type="vector"> +<use xlink:href="#path4_fill" transform="translate(2362.36 2286.06)" style="mix-blend-mode:normal;"/> +</g> +</g> +</g> +</g> +</g> +<defs> +<path id="path0_fill" d="M 5.89353 2.844C 5.91889 3.43165 5.77085 4.01367 5.46815 4.51645C 5.16545 5.01922 4.72168 5.42015 4.19299 5.66851C 3.6643 5.91688 3.07444 6.00151 2.49805 5.91171C 1.92166 5.8219 1.38463 5.5617 0.954898 5.16401C 0.52517 4.76633 0.222056 4.24903 0.0839037 3.67757C -0.0542483 3.10611 -0.02123 2.50617 0.178781 1.95364C 0.378793 1.4011 0.736809 0.920817 1.20754 0.573538C 1.67826 0.226259 2.24055 0.0275919 2.82326 0.00267229C 3.60389 -0.0307115 4.36573 0.249789 4.94142 [...] +<path id="path1_fill" d="M 18.2646 7.13411C 10.4145 7.13411 3.55872 4.2576 0 0C 1.32539 3.8204 3.79556 7.13081 7.0686 9.47303C 10.3417 11.8152 14.2557 13.0734 18.269 13.0734C 22.2823 13.0734 26.1963 11.8152 29.4694 9.47303C 32.7424 7.13081 35.2126 3.8204 36.538 0C 32.9705 4.2576 26.1148 7.13411 18.2646 7.13411Z"/> +<path id="path2_fill" d="M 18.2733 5.93931C 26.1235 5.93931 32.9793 8.81583 36.538 13.0734C 35.2126 9.25303 32.7424 5.94262 29.4694 3.6004C 26.1963 1.25818 22.2823 0 18.269 0C 14.2557 0 10.3417 1.25818 7.0686 3.6004C 3.79556 5.94262 1.32539 9.25303 0 13.0734C 3.56745 8.82463 10.4232 5.93931 18.2733 5.93931Z"/> +<path id="path3_fill" d="M 7.42789 3.58338C 7.46008 4.3243 7.27355 5.05819 6.89193 5.69213C 6.51031 6.32607 5.95075 6.83156 5.28411 7.1446C 4.61747 7.45763 3.87371 7.56414 3.14702 7.45063C 2.42032 7.33712 1.74336 7.0087 1.20184 6.50695C 0.660328 6.0052 0.27861 5.35268 0.105017 4.63202C -0.0685757 3.91135 -0.0262361 3.15494 0.226675 2.45856C 0.479587 1.76217 0.931697 1.15713 1.52576 0.720033C 2.11983 0.282935 2.82914 0.0334395 3.56389 0.00313344C 4.54667 -0.0374033 5.50529 0.316706 6.2296 [...] +<path id="path4_fill" d="M 2.27471 4.39629C 1.84363 4.41508 1.41671 4.30445 1.04799 4.07843C 0.679268 3.8524 0.385328 3.52114 0.203371 3.12656C 0.0214136 2.73198 -0.0403798 2.29183 0.0258116 1.86181C 0.0920031 1.4318 0.283204 1.03126 0.575213 0.710883C 0.867222 0.39051 1.24691 0.164708 1.66622 0.0620592C 2.08553 -0.0405897 2.52561 -0.0154714 2.93076 0.134235C 3.33591 0.283941 3.68792 0.551505 3.94222 0.90306C 4.19652 1.25462 4.34169 1.67436 4.35935 2.10916C 4.38299 2.69107 4.17678 3.2586 [...] +</defs> +</svg>
