Copilot commented on code in PR #7601:
URL: https://github.com/apache/texera/pull/7601#discussion_r3771195430


##########
frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts:
##########
@@ -312,6 +322,117 @@ export class UserWorkflowComponent implements 
AfterViewInit {
       });
   }
 
+  public get pythonNotebookMigrationEnabled(): boolean {
+    return this.config.env.pythonNotebookMigrationEnabled;
+  }
+
+  /**
+   * Open the AI-generate import modal from the dashboard. The modal collects 
the notebook file and
+   * model and shows a loading state while generation runs (the requestImport 
callback below).
+   */
+  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.generateWorkflowFromNotebook(file, model),
+      },
+    });
+  }
+
+  /**
+   * Generate a workflow from the uploaded notebook and open it. Runs entirely 
on the dashboard while
+   * the modal shows a loading state: parse the notebook, send it to the LLM, 
save the result as a new
+   * workflow, store the notebook and cell mapping, then navigate to the new 
workflow. The workspace
+   * lays the generated operators out (via the autolayout query param) and 
opens the notebook panel
+   * (driven by the workflow id). Resolves true so the modal closes on 
success, or false (leaving the
+   * modal open with the selection intact) when the file is not a notebook or 
generation fails.
+   */
+  private async generateWorkflowFromNotebook(file: NzUploadFile, model: 
string): Promise<boolean> {
+    const fileExtension = file.name.split(".").pop()?.toLowerCase();
+    if (fileExtension !== "ipynb") {
+      this.notificationService.error("Please upload a valid Jupyter Notebook 
(.ipynb) file.");
+      return false;
+    }
+    let notebook: Notebook;
+    try {
+      notebook = await this.notebookMigrationService.parseAndTagNotebook(file 
as unknown as File);
+    } catch (error) {
+      this.notificationService.error("Failed to read the notebook file. Please 
upload a valid .ipynb file.");
+      console.error("Notebook parse failed:", error);
+      return false;
+    }
+
+    let generated: { workflowContent: WorkflowContent; mappingContent: 
MappingContent };
+    try {
+      generated = await 
this.notebookMigrationService.sendToAIGenerateWorkflow(notebook, model);
+    } catch (error) {
+      this.notificationService.error("Error while communicating with the LLM, 
check console for details.");
+      console.error("LLM generation failed:", error);
+      return false;
+    }
+
+    // Create the workflow. This is the commit point: persisting captures the 
expensive LLM
+    // result. If it fails nothing was created, so returning false (letting 
the user retry) is safe.
+    let wid: number;
+    try {
+      const createdWorkflow = await firstValueFrom(
+        this.workflowPersistService.createWorkflow(
+          generated.workflowContent,
+          this.deriveWorkflowName(file.name) + "_GENERATED_BY_LLM"
+        )
+      );
+      if (!createdWorkflow.workflow.wid) {
+        throw new Error("Created workflow has no wid.");
+      }
+      wid = createdWorkflow.workflow.wid;
+    } catch (error) {
+      this.notificationService.error("Failed to save the generated workflow, 
check console for details.");
+      console.error("Saving the generated workflow failed:", error);
+      return false;
+    }
+
+    // Past the commit point the follow-up steps are best-effort: a transient 
failure must not
+    // discard the created workflow or the LLM result, so we log/warn and 
still open the workflow
+    // rather than force a full re-generation.
+    if (this.pid) {
+      try {
+        await 
firstValueFrom(this.userProjectService.addWorkflowToProject(this.pid, wid));
+      } catch (error) {
+        console.error("Adding the generated workflow to the project failed:", 
error);
+      }
+    }
+    try {
+      await firstValueFrom(
+        this.notebookMigrationService.storeNotebookAndMapping(wid, 
generated.mappingContent, notebook)

Review Comment:
   `storeNotebookAndMapping` defaults this omitted `vid` to `1`, but 
`workflow_version.vid` is a global `SERIAL` foreign key, not a per-workflow 
version number (`sql/texera_ddl.sql:190-196,568-576`). Thus generated workflows 
are attached to unrelated version 1 (or the store fails if that row is absent); 
deleting the workflow that owns vid 1 can cascade-delete every such mapping. 
Persist and later fetch the actual version ID created for this workflow instead 
of relying on the default.



##########
frontend/src/app/workspace/component/workspace.component.ts:
##########
@@ -259,9 +259,17 @@ export class WorkspaceComponent implements AfterViewInit, 
OnInit, OnDestroy {
           this.workflowActionService.setNewSharedModel(wid, 
this.userService.getCurrentUser());
           // remember URL fragment
           const fragment = this.route.snapshot.fragment;
-          // load the fetched workflow
-          this.workflowActionService.reloadWorkflow(workflow);
+          // A freshly AI-generated workflow arrives with autolayout=1: there 
was no canvas on the
+          // dashboard to lay the operators out on, so render synchronously 
(asyncRendering = false)
+          // so the operators exist in the graph, then tidy the layout once.
+          const shouldAutoLayout = this.route.snapshot.queryParams.autolayout 
=== "1";
+          // load the fetched workflow (asyncRendering = false for autolayout 
so the operators
+          // exist synchronously before the layout runs; undefined otherwise 
uses the config default)
+          this.workflowActionService.reloadWorkflow(workflow, shouldAutoLayout 
? false : undefined);
           this.workflowActionService.enableWorkflowModification();
+          if (shouldAutoLayout) {
+            this.workflowActionService.autoLayoutWorkflow();
+          }

Review Comment:
   The initial layout occurs before `registerAutoPersistWorkflow()` at line 
295. `workflowChanged()` merges hot, non-replaying event streams, so on a fresh 
workspace this layout's position changes are missed and the saved workflow 
retains its original linear positions. Since the one-shot query parameter is 
then removed, reopening the workflow will not lay it out again. Register 
auto-persistence before invoking auto-layout.



##########
frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts:
##########
@@ -312,6 +322,117 @@ export class UserWorkflowComponent implements 
AfterViewInit {
       });
   }
 
+  public get pythonNotebookMigrationEnabled(): boolean {
+    return this.config.env.pythonNotebookMigrationEnabled;
+  }
+
+  /**
+   * Open the AI-generate import modal from the dashboard. The modal collects 
the notebook file and
+   * model and shows a loading state while generation runs (the requestImport 
callback below).
+   */
+  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.generateWorkflowFromNotebook(file, model),
+      },
+    });
+  }
+
+  /**
+   * Generate a workflow from the uploaded notebook and open it. Runs entirely 
on the dashboard while
+   * the modal shows a loading state: parse the notebook, send it to the LLM, 
save the result as a new
+   * workflow, store the notebook and cell mapping, then navigate to the new 
workflow. The workspace
+   * lays the generated operators out (via the autolayout query param) and 
opens the notebook panel
+   * (driven by the workflow id). Resolves true so the modal closes on 
success, or false (leaving the
+   * modal open with the selection intact) when the file is not a notebook or 
generation fails.
+   */
+  private async generateWorkflowFromNotebook(file: NzUploadFile, model: 
string): Promise<boolean> {
+    const fileExtension = file.name.split(".").pop()?.toLowerCase();
+    if (fileExtension !== "ipynb") {
+      this.notificationService.error("Please upload a valid Jupyter Notebook 
(.ipynb) file.");
+      return false;
+    }
+    let notebook: Notebook;
+    try {
+      notebook = await this.notebookMigrationService.parseAndTagNotebook(file 
as unknown as File);
+    } catch (error) {
+      this.notificationService.error("Failed to read the notebook file. Please 
upload a valid .ipynb file.");
+      console.error("Notebook parse failed:", error);
+      return false;
+    }
+
+    let generated: { workflowContent: WorkflowContent; mappingContent: 
MappingContent };
+    try {
+      generated = await 
this.notebookMigrationService.sendToAIGenerateWorkflow(notebook, model);
+    } catch (error) {
+      this.notificationService.error("Error while communicating with the LLM, 
check console for details.");
+      console.error("LLM generation failed:", error);
+      return false;
+    }
+
+    // Create the workflow. This is the commit point: persisting captures the 
expensive LLM
+    // result. If it fails nothing was created, so returning false (letting 
the user retry) is safe.
+    let wid: number;
+    try {
+      const createdWorkflow = await firstValueFrom(
+        this.workflowPersistService.createWorkflow(
+          generated.workflowContent,
+          this.deriveWorkflowName(file.name) + "_GENERATED_BY_LLM"
+        )
+      );

Review Comment:
   The workflow name column is limited to 128 characters 
(`sql/texera_ddl.sql:157-161`), but this appends a 17-character suffix to an 
unbounded notebook basename. A valid filename with a basename longer than 111 
characters therefore makes workflow creation fail. Truncate the basename to 
leave room for the generated suffix.



##########
frontend/src/app/workspace/component/notebook-import-modal/notebook-import-modal.component.html:
##########
@@ -17,122 +17,131 @@
  under the License.
 -->
 
-<form
-  class="import-modal-form"
-  [formGroup]="importForm"
-  nz-form>
-  <div class="import-modal-diagram">
-    <img
-      ngSrc="assets/notebook_migration_tool/tool_popup_diagram.png"
-      alt="Notebook to Workflow"
-      width="1132"
-      height="290" />
-  </div>
+<div class="import-modal-diagram">
+  <img
+    ngSrc="assets/notebook_migration_tool/tool_popup_diagram.png"
+    alt="Notebook to Workflow"
+    width="1132"
+    height="290" />
+</div>
 
-  <nz-alert
-    class="import-modal-warning"
-    nzType="warning"
-    nzShowIcon
-    nzMessage="Generating overwrites your current workflow; the previous 
version is kept in version history."></nz-alert>
+<div class="import-modal-content">
+  <form
+    class="import-modal-form"
+    [formGroup]="importForm"
+    nz-form>
+    <nz-form-item>
+      <p class="import-modal-text">
+        This tool converts a Python Jupyter Notebook into a Texera workflow 
using LLM capabilities. After you submit a
+        notebook, the LLM service generates a corresponding Texera workflow. 
The conversion time depends on the
+        notebook's complexity and can take 1-5 minutes. Once generation 
finishes, you are taken to the new workflow,
+        which opens with:
+      </p>
+      <ol class="import-modal-list">
+        <li>
+          The generated workflow ready to use (Note: you will still need to 
upload the dataset and connect it to the
+          workflow).
+        </li>
+        <li>A floating Jupyter window containing the uploaded notebook for 
reference.</li>
+      </ol>
+      <p class="import-modal-text">
+        Generation runs here after you submit. Please keep this window open 
while you wait.
+      </p>
+    </nz-form-item>
 
-  <nz-form-item>
-    <p class="import-modal-text">
-      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:
-    </p>
-    <ol class="import-modal-list">
-      <li>
-        The generated workflow ready to use (Note: you will still need to 
upload the dataset and connect it to the
-        workflow).
-      </li>
-      <li>A floating Jupyter window containing the uploaded notebook for 
reference.</li>
-    </ol>
-    <p class="import-modal-text">
-      Feel free to navigate away from this tab while you wait for the workflow 
to generate. Please do not close the
-      window.
-    </p>
-  </nz-form-item>
+    <nz-form-item>
+      <nz-form-label [nzNoColon]="true">
+        <span class="import-modal-label"> Upload Python Jupyter Notebook 
</span>
+      </nz-form-label>
+      <nz-form-control>
+        <div class="import-modal-upload-row">
+          <nz-upload
+            nzAccept=".ipynb"
+            [nzBeforeUpload]="beforeUpload"
+            [nzShowUploadList]="false">
+            <button
+              nz-button
+              type="button"
+              title="Upload notebook"
+              aria-label="Upload notebook">
+              <i
+                nz-icon
+                nzType="upload"></i>
+            </button>
+          </nz-upload>
 
-  <nz-form-item>
-    <nz-form-label [nzNoColon]="true">
-      <span class="import-modal-label"> Upload Python Jupyter Notebook </span>
-    </nz-form-label>
-    <nz-form-control>
-      <div class="import-modal-upload-row">
-        <nz-upload
-          nzAccept=".ipynb"
-          [nzBeforeUpload]="beforeUpload"
-          [nzShowUploadList]="false">
-          <button
-            nz-button
-            type="button"
-            title="Upload notebook"
-            aria-label="Upload notebook">
-            <i
-              nz-icon
-              nzType="upload"></i>
-          </button>
-        </nz-upload>
+          <span *ngIf="importForm.get('file')?.value?.name">
+            Selected file: {{ importForm.get('file')?.value?.name }}
+          </span>
+        </div>
+      </nz-form-control>
+    </nz-form-item>
 
-        <span *ngIf="importForm.get('file')?.value?.name">
-          Selected file: {{ importForm.get('file')?.value?.name }}
-        </span>
-      </div>
-    </nz-form-control>
-  </nz-form-item>
+    <nz-form-item>
+      <nz-form-label [nzNoColon]="true">
+        <span class="import-modal-label"> Select Model Type </span>
+      </nz-form-label>
 
-  <nz-form-item>
-    <nz-form-label [nzNoColon]="true">
-      <span class="import-modal-label"> Select Model Type </span>
-    </nz-form-label>
+      <nz-form-control>
+        <ng-container *ngIf="models$ | async as models; else loadingTpl">
+          <nz-select
+            *ngIf="models.length > 0; else noModelsTpl"
+            class="import-modal-select"
+            formControlName="model"
+            nzPlaceHolder="Select a model">
+            <nz-option
+              *ngFor="let model of models"
+              [nzValue]="model.name"
+              [nzLabel]="model.name"></nz-option>
+          </nz-select>
+          <ng-template #noModelsTpl>
+            <nz-select
+              class="import-modal-select"
+              nzPlaceHolder="No models available"
+              [nzDisabled]="true"></nz-select>
+          </ng-template>
+        </ng-container>
 
-    <nz-form-control>
-      <ng-container *ngIf="models$ | async as models; else loadingTpl">
-        <nz-select
-          *ngIf="models.length > 0; else noModelsTpl"
-          class="import-modal-select"
-          formControlName="model"
-          nzPlaceHolder="Select a model">
-          <nz-option
-            *ngFor="let model of models"
-            [nzValue]="model.name"
-            [nzLabel]="model.name"></nz-option>
-        </nz-select>
-        <ng-template #noModelsTpl>
+        <ng-template #loadingTpl>
           <nz-select
             class="import-modal-select"
-            nzPlaceHolder="No models available"
+            nzPlaceHolder="Loading models..."
+            [nzLoading]="true"
             [nzDisabled]="true"></nz-select>
         </ng-template>
-      </ng-container>
+      </nz-form-control>
+    </nz-form-item>
+  </form>
 
-      <ng-template #loadingTpl>
-        <nz-select
-          class="import-modal-select"
-          nzPlaceHolder="Loading models..."
-          [nzLoading]="true"
-          [nzDisabled]="true"></nz-select>
-      </ng-template>
-    </nz-form-control>
-  </nz-form-item>
-</form>
+  <div class="import-modal-footer">
+    <button
+      nz-button
+      type="button"
+      [disabled]="isSubmitting"
+      (click)="onCancel()">
+      Cancel
+    </button>
+    <button
+      nz-button
+      type="button"
+      nzType="primary"
+      [disabled]="!importForm.valid || isSubmitting"
+      (click)="onSubmit()">
+      Submit
+    </button>
+  </div>
 
-<div class="import-modal-footer">
-  <button
-    nz-button
-    type="button"
-    [disabled]="isSubmitting"
-    (click)="onCancel()">
-    Cancel
-  </button>
-  <button
-    nz-button
-    type="button"
-    nzType="primary"
-    [disabled]="!importForm.valid || isSubmitting"
-    (click)="onSubmit()">
-    Submit
-  </button>
+  <div
+    *ngIf="isSubmitting"
+    class="import-modal-loading">

Review Comment:
   This absolute overlay only hides the form visually; the upload button, model 
select, and footer remain in the accessibility tree and keyboard focus order 
while generation runs. A keyboard or screen-reader user can therefore tab into 
controls that appear absent. Make the covered form/footer inert (or 
visibility-hidden while preserving layout), and expose this loading content as 
a status/live region.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to