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]