Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ <h2 class="page-title">Workflows</h2>
nzTheme="outline"></i>
</button>
</nz-upload>
<button
*ngIf="pythonNotebookMigrationEnabled"
[disabled]="accessLevel === 'READ'"
nz-button
(click)="openAiGenerateModal()"
title="AI generate a workflow from a Python notebook"
nz-tooltip="AI generate a workflow from a Python notebook"
nzTooltipPlacement="bottom"
type="button">
<i
nz-icon
nzType="robot"
nzTheme="outline"></i>
</button>
<button
*ngIf="multiWorkflowsOperationButtonEnabled()"
(click)="toggleSelection()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,17 @@ import { StubSearchService } from "../../../service/user/stub-search.service";
import { SearchResultsComponent } from "../search-results/search-results.component";
import { delay, firstValueFrom, of, throwError } from "rxjs";
import JSZip from "jszip";
import { NzModalService } from "ng-zorro-antd/modal";
import { ModalOptions, NzModalRef, NzModalService } from "ng-zorro-antd/modal";
import { NzButtonModule } from "ng-zorro-antd/button";
import { DownloadService } from "../../../service/user/download/download.service";
import { commonTestProviders } from "../../../../common/testing/test-utils";
import { Router } from "@angular/router";
import { USER_WORKSPACE } from "../../../../app-routing.constant";
import { GuiConfigService } from "../../../../common/service/gui-config.service";
import { MockGuiConfigService } from "../../../../common/service/gui-config.service.mock";
import { NotebookMigrationService } from "../../../../workspace/service/notebook-migration/notebook-migration.service";
import { NotebookImportModalComponent } from "../../../../workspace/component/notebook-import-modal/notebook-import-modal.component";
import { NzUploadFile } from "ng-zorro-antd/upload";
import type { Mocked } from "vitest";
describe("SavedWorkflowSectionComponent", () => {
let component: UserWorkflowComponent;
Expand Down Expand Up @@ -340,6 +345,157 @@ describe("SavedWorkflowSectionComponent", () => {
});
});

describe("AI generate workflow (dashboard entry point)", () => {
const ipynbFile = { name: "analysis.ipynb" } as NzUploadFile;
const AI_BUTTON_SELECTOR = 'button[title="AI generate a workflow from a Python notebook"]';

// Opens the modal and returns the requestImport callback the component handed to it; calling
// it drives the create-new-workflow decision (true => close modal, false => keep it open).
function getRequestImport(): (file: NzUploadFile, model: string) => Promise<boolean> {
const modalService = TestBed.inject(NzModalService);
const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as unknown as NzModalRef);
component.openAiGenerateModal();
const config = createSpy.mock.calls[0][0] as ModalOptions;
return (config.nzData as { requestImport: (file: NzUploadFile, model: string) => Promise<boolean> })
.requestImport;
}

it("openAiGenerateModal opens the NotebookImportModalComponent with a requestImport callback and no footer", () => {
const modalService = TestBed.inject(NzModalService);
const createSpy = vi.spyOn(modalService, "create").mockReturnValue({} as unknown as NzModalRef);

component.openAiGenerateModal();

expect(createSpy).toHaveBeenCalledTimes(1);
const config = createSpy.mock.calls[0][0] as ModalOptions;
expect(config.nzContent).toBe(NotebookImportModalComponent);
expect(config.nzFooter).toBeNull();
expect(typeof (config.nzData as { requestImport: unknown }).requestImport).toBe("function");
});

it("creates a new workflow, stashes the notebook handoff for its wid, navigates, and resolves true", async () => {
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, "navigate").mockResolvedValue(true);
const persist = TestBed.inject(WorkflowPersistService) as any;
persist.createWorkflow = vi.fn().mockReturnValue(of({ workflow: { wid: 99 } }));
const setPendingSpy = vi.spyOn(TestBed.inject(NotebookMigrationService), "setPendingGeneration");
component.pid = undefined;

const proceed = await getRequestImport()(ipynbFile, "gpt-4");

expect(persist.createWorkflow).toHaveBeenCalledTimes(1);
// The workspace picks up this handoff keyed by the newly created wid.
expect(setPendingSpy).toHaveBeenCalledWith(ipynbFile, "gpt-4", 99);
expect(navigateSpy).toHaveBeenCalledWith([USER_WORKSPACE, 99]);
expect(proceed).toBe(true);
});

it("resolves false, clears the handoff, and errors when navigation is blocked", async () => {
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, "navigate").mockResolvedValue(false);
const persist = TestBed.inject(WorkflowPersistService) as any;
persist.createWorkflow = vi.fn().mockReturnValue(of({ workflow: { wid: 99 } }));
const migration = TestBed.inject(NotebookMigrationService);
const setPendingSpy = vi.spyOn(migration, "setPendingGeneration");
const consumeSpy = vi.spyOn(migration, "consumePendingGeneration");
const errorSpy = vi.spyOn(TestBed.inject(NotificationService), "error").mockImplementation(() => {});
component.pid = undefined;

const proceed = await getRequestImport()(ipynbFile, "gpt-4");

expect(setPendingSpy).toHaveBeenCalledWith(ipynbFile, "gpt-4", 99);
expect(navigateSpy).toHaveBeenCalledWith([USER_WORKSPACE, 99]);
// The stranded handoff is cleared and the failure is surfaced; the modal stays open (false).
expect(consumeSpy).toHaveBeenCalledWith(99);
expect(errorSpy).toHaveBeenCalledWith("Could not open a new workflow.");
expect(proceed).toBe(false);
});

it("rejects a non-ipynb file: errors, resolves false, and creates nothing", async () => {
const persist = TestBed.inject(WorkflowPersistService) as any;
persist.createWorkflow = vi.fn();
const setPendingSpy = vi.spyOn(TestBed.inject(NotebookMigrationService), "setPendingGeneration");
const errorSpy = vi.spyOn(TestBed.inject(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 was created.
expect(proceed).toBe(false);
expect(errorSpy).toHaveBeenCalledWith("Please upload a valid Jupyter Notebook (.ipynb) file.");
expect(persist.createWorkflow).not.toHaveBeenCalled();
expect(setPendingSpy).not.toHaveBeenCalled();
});

it("shows the button only when the migration flag is enabled", () => {
// The default mock config has the flag off, so the button is absent.
expect(fixture.nativeElement.querySelector(AI_BUTTON_SELECTOR)).toBeNull();

(TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({
pythonNotebookMigrationEnabled: true,
});
fixture.detectChanges();

expect(fixture.nativeElement.querySelector(AI_BUTTON_SELECTOR)).not.toBeNull();
});

it("clicking the toolbar button opens the AI generate modal", () => {
(TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({
pythonNotebookMigrationEnabled: true,
});
fixture.detectChanges();
const openSpy = vi.spyOn(component, "openAiGenerateModal").mockImplementation(() => {});

const button = fixture.nativeElement.querySelector(AI_BUTTON_SELECTOR) as HTMLButtonElement;
button.click();

expect(openSpy).toHaveBeenCalled();
});

it("adds the new workflow to the current project when opened inside one, then navigates", async () => {
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, "navigate").mockResolvedValue(true);
const persist = TestBed.inject(WorkflowPersistService) as any;
persist.createWorkflow = vi.fn().mockReturnValue(of({ workflow: { wid: 99 } }));
const projectService = TestBed.inject(UserProjectService) as any;
const addSpy = vi.spyOn(projectService, "addWorkflowToProject").mockReturnValue(of(undefined));
vi.spyOn(TestBed.inject(NotebookMigrationService), "setPendingGeneration").mockImplementation(() => {});
component.pid = 5;

const proceed = await getRequestImport()(ipynbFile, "gpt-4");

expect(addSpy).toHaveBeenCalledWith(5, 99);
expect(navigateSpy).toHaveBeenCalledWith([USER_WORKSPACE, 99]);
expect(proceed).toBe(true);
});

it("errors and resolves false when creation returns no wid, stashing nothing", async () => {
const persist = TestBed.inject(WorkflowPersistService) as any;
// A created workflow with no wid trips the guard, which throws into the same failure path.
persist.createWorkflow = vi.fn().mockReturnValue(of({ workflow: {} }));
const setPendingSpy = vi.spyOn(TestBed.inject(NotebookMigrationService), "setPendingGeneration");
const errorSpy = vi.spyOn(TestBed.inject(NotificationService), "error").mockImplementation(() => {});

const proceed = await getRequestImport()(ipynbFile, "gpt-4");

expect(proceed).toBe(false);
expect(errorSpy).toHaveBeenCalledWith("Workflow creation failed");
expect(setPendingSpy).not.toHaveBeenCalled();
});

it("errors and resolves false when workflow creation fails, stashing nothing", async () => {
const persist = TestBed.inject(WorkflowPersistService) as any;
persist.createWorkflow = vi.fn().mockReturnValue(throwError(() => new Error("boom")));
const setPendingSpy = vi.spyOn(TestBed.inject(NotebookMigrationService), "setPendingGeneration");
const errorSpy = vi.spyOn(TestBed.inject(NotificationService), "error").mockImplementation(() => {});

const proceed = await getRequestImport()(ipynbFile, "gpt-4");

expect(proceed).toBe(false);
expect(errorSpy).toHaveBeenCalledWith("Workflow creation failed");
expect(setPendingSpy).not.toHaveBeenCalled();
});
});

it("downloads checked files", async () => {
// If multiple workflows in a single batch download have name conflicts, rename them as workflow-1, workflow-2, etc.
component.searchResultsComponent.entries = component.searchResultsComponent.entries.concat(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ import { DashboardWorkflow } from "../../../type/dashboard-workflow.interface";
import { DownloadService } from "../../../service/user/download/download.service";
import { USER_WORKSPACE } from "../../../../app-routing.constant";
import { GuiConfigService } from "../../../../common/service/gui-config.service";
import { NotebookMigrationService } from "../../../../workspace/service/notebook-migration/notebook-migration.service";
import {
NotebookImportModalComponent,
NotebookImportModalData,
} from "../../../../workspace/component/notebook-import-modal/notebook-import-modal.component";
import { NzCardComponent } from "ng-zorro-antd/card";
import { NzSpaceCompactItemDirective, NzSpaceCompactComponent } from "ng-zorro-antd/space";
import { NzButtonComponent } from "ng-zorro-antd/button";
Expand Down Expand Up @@ -157,7 +162,8 @@ export class UserWorkflowComponent implements AfterViewInit {
private router: Router,
private downloadService: DownloadService,
private searchService: SearchService,
private config: GuiConfigService
private config: GuiConfigService,
private notebookMigrationService: NotebookMigrationService
) {
this.userService
.userChanged()
Expand Down Expand Up @@ -312,6 +318,92 @@ export class UserWorkflowComponent implements AfterViewInit {
});
}

public get pythonNotebookMigrationEnabled(): boolean {
return this.config.env.pythonNotebookMigrationEnabled;
}

/**
* Open the AI-generate import modal from the dashboard. This is the same modal the canvas
* toolbar uses (notebook upload and model selection); it hands the selection back through the
* requestImport callback. Unlike the canvas entry point there is no open workflow to overwrite,
* so the callback always creates and opens a new workflow.
*/
public openAiGenerateModal(): void {
this.modalService.create<NotebookImportModalComponent, NotebookImportModalData>({
nzTitle: "AI Generate Workflow from Python Notebook",
nzContent: NotebookImportModalComponent,
nzWidth: 700,
nzFooter: null,
nzCentered: true,
nzData: {
requestImport: (file, model) => this.startAiGeneratedWorkflow(file, model),
// The dashboard always creates a new workflow, so there is nothing to overwrite.
showOverwriteWarning: false,
},
});
}

/**
* Create the new workflow the generation will fill, record the notebook file and model for the
* workspace to pick up, and navigate to the new workflow. The workspace menu consumes the
* handoff once the workflow loads and runs the shared generation pipeline (generate, auto
* layout, open the Jupyter panel). Resolves true so the modal closes, or false (leaving the
* modal open with the selection intact) when the file is not a notebook or creation fails.
*/
private startAiGeneratedWorkflow(file: NzUploadFile, model: string): Promise<boolean> {
// Reject a non-notebook file before creating anything, so we never leave an empty workflow
// behind on a no-op import. The workspace pipeline validates the extension again downstream.
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 emptyWorkflowContent: WorkflowContent = {
operators: [],
commentBoxes: [],
links: [],
operatorPositions: {},
settings: {
dataTransferBatchSize: this.config.env.defaultDataTransferBatchSize,
executionMode: this.config.env.defaultExecutionMode,
},
};
const localPid = this.pid;
return firstValueFrom(
this.workflowPersistService.createWorkflow(emptyWorkflowContent, DEFAULT_WORKFLOW_NAME).pipe(
tap(createdWorkflow => {
if (!createdWorkflow.workflow.wid) {
throw new Error("Workflow creation failed.");
}
}),
mergeMap(createdWorkflow => {
const wid = createdWorkflow.workflow.wid!;
// Mirror the create-workflow path: add to the current project when inside one.
if (localPid) {
return this.userProjectService.addWorkflowToProject(localPid, wid).pipe(map(() => wid));
}
return of(wid);
}),
untilDestroyed(this)
)
)
.then(wid => {
this.notebookMigrationService.setPendingGeneration(file, model, wid);
return this.router.navigate([USER_WORKSPACE, wid]).then(navigated => {
if (!navigated) {
// Navigation was blocked or cancelled: clear the stranded handoff and surface an error
this.notebookMigrationService.consumePendingGeneration(wid);
this.notificationService.error("Could not open a new workflow.");
}
return navigated;
});
})
.catch(() => {
this.notificationService.error("Workflow creation failed");
return false;
});
}

/**
* duplicate the current workflow. A new record will appear in frontend
* workflow list and backend database.
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/app/workspace/component/menu/menu.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1312,4 +1312,67 @@ describe("MenuComponent", () => {
expect(persistSpy).not.toHaveBeenCalled();
});
});

// The dashboard AI-generate entry point creates the workflow, stashes the notebook file and
// model in NotebookMigrationService, and navigates here. Once this workflow becomes modifiable
// (loaded), the menu consumes the handoff and runs the same import pipeline the toolbar uses.
describe("dashboard-deferred generation trigger", () => {
let notebookMigrationService: NotebookMigrationService;

beforeEach(() => {
notebookMigrationService = TestBed.inject(NotebookMigrationService);
(TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({
pythonNotebookMigrationEnabled: true,
});
});

it("runs onClickImportNotebook when a pending generation matches the loaded workflow's wid", () => {
const file = { name: "x.ipynb" } as NzUploadFile;
vi.spyOn(workflowActionService, "getWorkflowModificationEnabledStream").mockReturnValue(of(true));
vi.spyOn(workflowActionService, "getWorkflowMetadata").mockReturnValue({ wid: 7 } as any);
const consumeSpy = vi
.spyOn(notebookMigrationService, "consumePendingGeneration")
.mockReturnValue({ file, model: "gpt-4" });
const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false);

(component as any).registerWorkflowModifiableChangedHandler();

expect(consumeSpy).toHaveBeenCalledWith(7);
expect(importSpy).toHaveBeenCalledWith(file, "gpt-4");
});

it("does nothing when there is no pending generation for the loaded workflow", () => {
vi.spyOn(workflowActionService, "getWorkflowModificationEnabledStream").mockReturnValue(of(true));
vi.spyOn(workflowActionService, "getWorkflowMetadata").mockReturnValue({ wid: 7 } as any);
vi.spyOn(notebookMigrationService, "consumePendingGeneration").mockReturnValue(null);
const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false);

(component as any).registerWorkflowModifiableChangedHandler();

expect(importSpy).not.toHaveBeenCalled();
});

it("does not consume the handoff when the loaded workflow has no wid", () => {
vi.spyOn(workflowActionService, "getWorkflowModificationEnabledStream").mockReturnValue(of(true));
vi.spyOn(workflowActionService, "getWorkflowMetadata").mockReturnValue({ wid: undefined } as any);
const consumeSpy = vi.spyOn(notebookMigrationService, "consumePendingGeneration");
const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false);

(component as any).registerWorkflowModifiableChangedHandler();

expect(consumeSpy).not.toHaveBeenCalled();
expect(importSpy).not.toHaveBeenCalled();
});

it("does not consume the handoff while the workflow is not yet modifiable", () => {
vi.spyOn(workflowActionService, "getWorkflowModificationEnabledStream").mockReturnValue(of(false));
const consumeSpy = vi.spyOn(notebookMigrationService, "consumePendingGeneration");
const importSpy = vi.spyOn(component, "onClickImportNotebook").mockReturnValue(false);

(component as any).registerWorkflowModifiableChangedHandler();

expect(consumeSpy).not.toHaveBeenCalled();
expect(importSpy).not.toHaveBeenCalled();
});
});
});
Loading
Loading