diff --git a/bin/k8s/templates/base/gateway/gateway-routes.yaml b/bin/k8s/templates/base/gateway/gateway-routes.yaml index 935d38bb079..59744888d58 100644 --- a/bin/k8s/templates/base/gateway/gateway-routes.yaml +++ b/bin/k8s/templates/base/gateway/gateway-routes.yaml @@ -154,6 +154,11 @@ spec: - path: type: PathPrefix value: /api/agents + # Ambient operator recommender endpoint (apache/texera#5240) is served + # by the agent-service alongside /api/agents. + - path: + type: PathPrefix + value: /api/recommend backendRefs: - name: agent-service-svc port: 3001 diff --git a/common/config/src/main/resources/gui.conf b/common/config/src/main/resources/gui.conf index 7a522a2fe14..d518a1a6539 100644 --- a/common/config/src/main/resources/gui.conf +++ b/common/config/src/main/resources/gui.conf @@ -108,6 +108,10 @@ gui { copilot-enabled = false copilot-enabled = ${?GUI_WORKFLOW_WORKSPACE_COPILOT_ENABLED} + # whether the ambient operator recommender (ghost next-operator suggestions) is enabled + operator-recommendation-enabled = false + operator-recommendation-enabled = ${?GUI_WORKFLOW_WORKSPACE_OPERATOR_RECOMMENDATION_ENABLED} + # the limit of columns to be displayed in the result table limit-columns = 15 limit-columns = ${?GUI_WORKFLOW_WORKSPACE_LIMIT_COLUMNS} diff --git a/common/config/src/main/scala/org/apache/texera/common/config/GuiConfig.scala b/common/config/src/main/scala/org/apache/texera/common/config/GuiConfig.scala index 68608de4b2c..4415e0aced1 100644 --- a/common/config/src/main/scala/org/apache/texera/common/config/GuiConfig.scala +++ b/common/config/src/main/scala/org/apache/texera/common/config/GuiConfig.scala @@ -69,6 +69,8 @@ object GuiConfig { conf.getInt("gui.workflow-workspace.active-time-in-minutes") val guiWorkflowWorkspaceCopilotEnabled: Boolean = conf.getBoolean("gui.workflow-workspace.copilot-enabled") + val guiWorkflowWorkspaceOperatorRecommendationEnabled: Boolean = + conf.getBoolean("gui.workflow-workspace.operator-recommendation-enabled") val guiWorkflowWorkspaceLimitColumns: Int = conf.getInt("gui.workflow-workspace.limit-columns") val guiAttributionEnabled: Boolean = diff --git a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala index 72f346349a2..b4fe6d26689 100644 --- a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala +++ b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala @@ -76,6 +76,7 @@ class ConfigResource { "pythonLanguageServerPort" -> GuiConfig.guiWorkflowWorkspacePythonLanguageServerPort, "activeTimeInMinutes" -> GuiConfig.guiWorkflowWorkspaceActiveTimeInMinutes, "copilotEnabled" -> GuiConfig.guiWorkflowWorkspaceCopilotEnabled, + "operatorRecommendationEnabled" -> GuiConfig.guiWorkflowWorkspaceOperatorRecommendationEnabled, "limitColumns" -> GuiConfig.guiWorkflowWorkspaceLimitColumns, "pythonNotebookMigrationEnabled" -> GuiConfig.guiWorkflowWorkspacePythonNotebookMigrationEnabled, // flags from the auth.conf if needed diff --git a/frontend/proxy.config.json b/frontend/proxy.config.json index c26a239b178..03688c4df77 100755 --- a/frontend/proxy.config.json +++ b/frontend/proxy.config.json @@ -10,6 +10,11 @@ "secure": false, "changeOrigin": true }, + "/api/recommend": { + "target": "http://localhost:3001", + "secure": false, + "changeOrigin": true + }, "/api/notebook-migration": { "target": "http://localhost:9098", "secure": false, diff --git a/frontend/src/app/common/service/gui-config.service.mock.ts b/frontend/src/app/common/service/gui-config.service.mock.ts index 9efbd75a12f..34d0f69dc8c 100644 --- a/frontend/src/app/common/service/gui-config.service.mock.ts +++ b/frontend/src/app/common/service/gui-config.service.mock.ts @@ -51,6 +51,7 @@ export class MockGuiConfigService { expirationTimeInMinutes: 2880, activeTimeInMinutes: 15, copilotEnabled: false, + operatorRecommendationEnabled: false, limitColumns: 15, attributionEnabled: false, pythonNotebookMigrationEnabled: false, diff --git a/frontend/src/app/common/type/gui-config.ts b/frontend/src/app/common/type/gui-config.ts index 28df71b3654..948fd62a28a 100644 --- a/frontend/src/app/common/type/gui-config.ts +++ b/frontend/src/app/common/type/gui-config.ts @@ -42,6 +42,7 @@ export interface GuiConfig { expirationTimeInMinutes: number; activeTimeInMinutes: number; copilotEnabled: boolean; + operatorRecommendationEnabled: boolean; limitColumns: number; attributionEnabled: boolean; pythonNotebookMigrationEnabled: boolean; diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html index ed0b7cc748b..cdd590ebd2e 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html @@ -46,4 +46,24 @@ + + + @if (operatorSuggestion) { +
+ @for (recommendation of operatorSuggestion.recommendations; track recommendation.operatorType) { + + } +
+ } diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.scss b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.scss index 482dac56a26..85123a18a96 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.scss +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.scss @@ -68,6 +68,51 @@ transform: translateX(-50%); } +// Ambient operator recommender: faded next-operator suggestions, +// anchored just off the source operator's output port and vertically centered. +.operator-recommendation-container { + position: absolute; + pointer-events: auto; + z-index: 15; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 4px; +} + +.operator-recommendation-item { + display: inline-flex; + align-items: center; + padding: 2px 10px; + max-width: 160px; + border: 1px dashed #8c8c8c; + border-radius: 12px; + background: rgba(255, 255, 255, 0.7); + color: #595959; + font-size: 12px; + line-height: 18px; + cursor: pointer; + // Faded until the user hovers, so it never competes with the graph. + opacity: 0.55; + transition: + opacity 0.15s ease, + background 0.15s ease, + border-color 0.15s ease; + + &:hover { + opacity: 1; + background: #e6f7ff; + border-color: #1890ff; + color: #1890ff; + } +} + +.operator-recommendation-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .chat-popover { width: 400px; max-height: 400px; diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts index 042c4669d2c..d2c536197bc 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts @@ -44,6 +44,10 @@ import { GuiConfigService } from "../../../common/service/gui-config.service"; import { line, curveCatmullRomClosed } from "d3-shape"; import concaveman from "concaveman"; import { OperatorResultSummary, AgentService } from "../../service/agent/agent.service"; +import { + OperatorRecommendation, + OperatorRecommendationService, +} from "../../service/operator-recommendation/operator-recommendation.service"; import { NzNoAnimationDirective } from "ng-zorro-antd/core/animation"; import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.component"; import { NgIf } from "@angular/common"; @@ -108,6 +112,16 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy position: { x: number; y: number }; } | null = null; + // Ambient operator recommender state (apache/texera#5240). Holds the faded + // next-operator suggestions anchored on the output port of the + // operator that was just added; null when nothing is being suggested. + public operatorSuggestion: { + operatorId: string; + sourceOutputPortID: string; + position: { x: number; y: number }; + recommendations: OperatorRecommendation[]; + } | null = null; + // Cached agent result summaries for port label display constructor( @@ -128,7 +142,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy public nzContextMenu: NzContextMenuService, private elementRef: ElementRef, private config: GuiConfigService, - private agentService: AgentService + private agentService: AgentService, + private operatorRecommendationService: OperatorRecommendationService ) { this.wrapper = this.workflowActionService.getJointGraphWrapper(); } @@ -205,6 +220,7 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy this.invokeResize(); this.handleCenterEvent(); this.handleOperatorChatButton(); + this.handleOperatorRecommendation(); } ngOnDestroy(): void { @@ -1712,6 +1728,146 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy return this.operatorSummaries.get(operatorId)?.sampleRecords?.[0]?.["__is_visualization__"] === true; } + /** + * Ambient operator recommender (apache/texera#5240). When an operator is + * added, ask the recommender for likely next operators and float them as + * suggestion chips on the operator's output port; clicking one materializes + * it. The whole feature is opt-in and self-effacing: if it is disabled or the + * backend returns nothing, the canvas is untouched. + */ + private handleOperatorRecommendation(): void { + if (!this.operatorRecommendationService.isEnabled()) { + return; + } + + // Trigger: an operator was just added to the canvas. + this.workflowActionService + .getTexeraGraph() + .getOperatorAddStream() + .pipe(untilDestroyed(this)) + .subscribe(operator => this.showRecommendationsFor(operator)); + + // Dismiss when the user clicks on blank canvas. + fromJointPaperEvent(this.paper, "blank:pointerdown") + .pipe(untilDestroyed(this)) + .subscribe(() => this.closeRecommendations()); + + // Dismiss if the anchor operator is deleted out from under the suggestions. + this.workflowActionService + .getTexeraGraph() + .getOperatorDeleteStream() + .pipe(untilDestroyed(this)) + .subscribe(({ deletedOperatorID }) => { + if (this.operatorSuggestion?.operatorId === deletedOperatorID) { + this.closeRecommendations(); + } + }); + + // Keep the suggestions anchored to the operator's output port as it moves. + this.paper.model.on("change:position", (cell: joint.dia.Cell) => { + if (this.operatorSuggestion && cell.id.toString() === this.operatorSuggestion.operatorId) { + this.repositionRecommendations(); + } + }); + + // Keep the suggestions anchored on zoom / pan. + this.wrapper + .getWorkflowEditorZoomStream() + .pipe(untilDestroyed(this)) + .subscribe(() => { + if (this.operatorSuggestion) { + this.repositionRecommendations(); + } + }); + } + + private showRecommendationsFor(operator: OperatorPredicate): void { + this.closeRecommendations(); + if (operator.outputPorts.length === 0) { + return; + } + const sourceOutputPortID = operator.outputPorts[0].portID; + + this.operatorRecommendationService + .getRecommendations(operator) + .pipe(untilDestroyed(this)) + .subscribe(recommendations => { + // The operator may have been deleted while the request was in flight. + if ( + recommendations.length === 0 || + !this.workflowActionService.getTexeraGraph().hasOperator(operator.operatorID) + ) { + return; + } + const position = this.getRecommendationPosition(operator.operatorID); + if (!position) { + return; + } + this.operatorSuggestion = { + operatorId: operator.operatorID, + sourceOutputPortID, + position, + recommendations, + }; + this.changeDetectorRef.detectChanges(); + }); + } + + /** + * Materialize a clicked suggestion into a real operator wired onto the + * source operator's output port. + */ + materializeRecommendation(recommendation: OperatorRecommendation): void { + if (!this.operatorSuggestion) { + return; + } + const graph = this.workflowActionService.getTexeraGraph(); + if (graph.hasOperator(this.operatorSuggestion.operatorId)) { + const sourceOperator = graph.getOperator(this.operatorSuggestion.operatorId); + this.operatorRecommendationService.materialize( + sourceOperator, + this.operatorSuggestion.sourceOutputPortID, + recommendation.operatorType + ); + } + this.closeRecommendations(); + } + + closeRecommendations(): void { + if (this.operatorSuggestion) { + this.operatorSuggestion = null; + this.changeDetectorRef.detectChanges(); + } + } + + private repositionRecommendations(): void { + if (!this.operatorSuggestion) { + return; + } + const position = this.getRecommendationPosition(this.operatorSuggestion.operatorId); + if (position) { + this.operatorSuggestion = { ...this.operatorSuggestion, position }; + } + this.changeDetectorRef.detectChanges(); + } + + /** + * Screen position for the suggestions: just off the right edge of the + * operator, vertically centered — the direction its output port faces. + */ + private getRecommendationPosition(operatorId: string): { x: number; y: number } | null { + const jointCell = this.paper.getModelById(operatorId); + if (!jointCell) { + return null; + } + const bbox = jointCell.getBBox(); + const scale = this.paper.scale(); + const translate = this.paper.translate(); + const screenX = (bbox.x + bbox.width) * scale.sx + translate.tx + 20; + const screenY = (bbox.y + bbox.height / 2) * scale.sy + translate.ty; + return { x: screenX, y: screenY }; + } + /** * Info button on link between operator shown when user hovers over links */ diff --git a/frontend/src/app/workspace/service/operator-recommendation/operator-recommendation.service.spec.ts b/frontend/src/app/workspace/service/operator-recommendation/operator-recommendation.service.spec.ts new file mode 100644 index 00000000000..52556065aee --- /dev/null +++ b/frontend/src/app/workspace/service/operator-recommendation/operator-recommendation.service.spec.ts @@ -0,0 +1,168 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { OperatorRecommendationService } from "./operator-recommendation.service"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { WorkflowUtilService } from "../workflow-graph/util/workflow-util.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; +import { MockGuiConfigService } from "../../../common/service/gui-config.service.mock"; +import { OperatorLink, OperatorPredicate, Point } from "../../types/workflow-common.interface"; + +function makeOperator(overrides: Partial = {}): OperatorPredicate { + return { + operatorID: "op-source", + operatorType: "CSVFileScan", + operatorVersion: "1", + operatorProperties: {}, + inputPorts: [], + outputPorts: [{ portID: "output-0" }], + showAdvanced: false, + ...overrides, + } as OperatorPredicate; +} + +describe("OperatorRecommendationService", () => { + let service: OperatorRecommendationService; + let httpTestingController: HttpTestingController; + let config: MockGuiConfigService; + let addOperatorsAndLinksSpy: ReturnType; + + beforeEach(() => { + addOperatorsAndLinksSpy = vi.fn(); + + const texeraGraphStub = { + getAllOperators: () => [makeOperator()], + }; + const jointGraphWrapperStub = { + getElementPosition: (_id: string): Point => ({ x: 100, y: 200 }), + }; + const workflowActionServiceStub = { + getTexeraGraph: () => texeraGraphStub, + getJointGraphWrapper: () => jointGraphWrapperStub, + addOperatorsAndLinks: addOperatorsAndLinksSpy, + }; + + let linkCounter = 0; + const workflowUtilServiceStub = { + getNewOperatorPredicate: (operatorType: string): OperatorPredicate => + makeOperator({ + operatorID: `op-${operatorType}`, + operatorType, + inputPorts: [{ portID: "input-0" }], + outputPorts: [{ portID: "output-0" }], + }), + getLinkRandomUUID: () => `link-${++linkCounter}`, + }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + OperatorRecommendationService, + { provide: GuiConfigService, useClass: MockGuiConfigService }, + { provide: WorkflowActionService, useValue: workflowActionServiceStub }, + { provide: WorkflowUtilService, useValue: workflowUtilServiceStub }, + ], + }); + + service = TestBed.inject(OperatorRecommendationService); + httpTestingController = TestBed.inject(HttpTestingController); + config = TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService; + // Default the opt-in flag on for the majority of tests. + (config.env as any).operatorRecommendationEnabled = true; + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it("reports enabled state from the GUI config flag", () => { + (config.env as any).operatorRecommendationEnabled = false; + expect(service.isEnabled()).toBe(false); + (config.env as any).operatorRecommendationEnabled = true; + expect(service.isEnabled()).toBe(true); + }); + + it("returns no recommendations and makes no request when disabled", () => { + (config.env as any).operatorRecommendationEnabled = false; + let result: unknown; + service.getRecommendations(makeOperator()).subscribe(r => (result = r)); + expect(result).toEqual([]); + httpTestingController.expectNone("/api/recommend"); + }); + + it("returns no recommendations and makes no request for an operator with no output port", () => { + let result: unknown; + service.getRecommendations(makeOperator({ outputPorts: [] })).subscribe(r => (result = r)); + expect(result).toEqual([]); + httpTestingController.expectNone("/api/recommend"); + }); + + it("posts the operator context and returns the ranked recommendations", () => { + const recommendations = [{ operatorType: "Filter", score: 0.9, reason: "Keep rows" }]; + let result: unknown; + service.getRecommendations(makeOperator()).subscribe(r => (result = r)); + + const req = httpTestingController.expectOne("/api/recommend"); + expect(req.request.method).toBe("POST"); + expect(req.request.body.operatorType).toBe("CSVFileScan"); + expect(req.request.body.existingOperatorTypes).toEqual(["CSVFileScan"]); + req.flush({ recommendations, strategy: "hardcoded" }); + + expect(result).toEqual(recommendations); + }); + + it("swallows backend errors and yields an empty list", () => { + let result: unknown = "unset"; + service.getRecommendations(makeOperator()).subscribe(r => (result = r)); + httpTestingController.expectOne("/api/recommend").flush("boom", { status: 500, statusText: "Server Error" }); + expect(result).toEqual([]); + }); + + it("materializes a suggestion to the right of the source, linked port-to-port", () => { + const source = makeOperator(); + const newId = service.materialize(source, "output-0", "Filter"); + + expect(newId).toBe("op-Filter"); + expect(addOperatorsAndLinksSpy).toHaveBeenCalledTimes(1); + + const [operatorsAndPositions, links] = addOperatorsAndLinksSpy.mock.lastCall as [ + { op: OperatorPredicate; pos: Point }[], + OperatorLink[], + ]; + // New operator placed to the right of the source at (100, 200). + expect(operatorsAndPositions[0].pos.x).toBeGreaterThan(100); + expect(operatorsAndPositions[0].pos.y).toBe(200); + // Link wires the clicked output port to the new operator's first input port. + expect(links.length).toBe(1); + expect(links[0].source).toEqual({ operatorID: "op-source", portID: "output-0" }); + expect(links[0].target).toEqual({ operatorID: "op-Filter", portID: "input-0" }); + }); + + it("materializes without a link when the new operator has no input port", () => { + vi.spyOn(TestBed.inject(WorkflowUtilService), "getNewOperatorPredicate").mockReturnValue( + makeOperator({ operatorID: "op-sink", operatorType: "BarChart", inputPorts: [], outputPorts: [] }) + ); + service.materialize(makeOperator(), "output-0", "BarChart"); + + const [, links] = addOperatorsAndLinksSpy.mock.lastCall as [unknown, OperatorLink[]]; + expect(links.length).toBe(0); + }); +}); diff --git a/frontend/src/app/workspace/service/operator-recommendation/operator-recommendation.service.ts b/frontend/src/app/workspace/service/operator-recommendation/operator-recommendation.service.ts new file mode 100644 index 00000000000..27e9ad0d7da --- /dev/null +++ b/frontend/src/app/workspace/service/operator-recommendation/operator-recommendation.service.ts @@ -0,0 +1,152 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; +import { Observable, catchError, map, of } from "rxjs"; +import { OperatorLink, OperatorPredicate, Point } from "../../types/workflow-common.interface"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { WorkflowUtilService } from "../workflow-graph/util/workflow-util.service"; +import { JointUIService } from "../joint-ui/joint-ui.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; + +/** + * A single operator suggestion returned by the agent-service `/api/recommend` + * endpoint (apache/texera#5240). Mirrors the backend `OperatorRecommendation`. + */ +export interface OperatorRecommendation { + /** Recommended operator type; a real, catalog-known type. */ + operatorType: string; + /** Confidence in `[0, 1]`, monotonically non-increasing down the list. */ + score: number; + /** Short, human-readable rationale shown alongside the suggested operator. */ + reason: string; + /** Display name from operator metadata, when available. */ + userFriendlyName?: string; +} + +interface RecommendationResponse { + recommendations: OperatorRecommendation[]; + strategy: "hardcoded" | "llm"; +} + +/** + * Client for the ambient operator recommender. Asks the stateless agent-service + * endpoint what operators are likely to follow the one just added, and turns a + * chosen suggestion into a real operator wired onto the source's output port. + * + * The service never fails loudly: the recommender is a non-essential, ambient + * aid, so a backend error or a disabled feature simply yields no suggestions + * and the canvas behaves exactly as before. + */ +@Injectable({ + providedIn: "root", +}) +export class OperatorRecommendationService { + private static readonly RECOMMEND_API_URL = "/api/recommend"; + + // Horizontal gap between the source operator and a materialized suggestion. + private static readonly MATERIALIZE_GAP_X = 100; + + constructor( + private http: HttpClient, + private config: GuiConfigService, + private workflowActionService: WorkflowActionService, + private workflowUtilService: WorkflowUtilService + ) {} + + /** Whether the opt-in recommender feature is turned on for this deployment. */ + public isEnabled(): boolean { + return this.config.env.operatorRecommendationEnabled === true; + } + + /** + * Fetch ranked next-operator suggestions for the operator just added. + * + * Returns an empty list (never errors) when the feature is disabled, the + * operator has no output port to suggest from, or the backend call fails. + * + * @param operator the operator that was just added to the canvas + * @param limit maximum number of suggestions to request + */ + public getRecommendations(operator: OperatorPredicate, limit?: number): Observable { + // A source-less/output-less operator (e.g. a chart sink) has no output port + // to hang suggestions on, so we skip the backend call. + if (!this.isEnabled() || operator.outputPorts.length === 0) { + return of([]); + } + + const existingOperatorTypes = this.workflowActionService + .getTexeraGraph() + .getAllOperators() + .map(op => op.operatorType); + + return this.http + .post(OperatorRecommendationService.RECOMMEND_API_URL, { + operatorType: operator.operatorType, + existingOperatorTypes, + ...(limit !== undefined ? { limit } : {}), + }) + .pipe( + map(response => response.recommendations ?? []), + // Ambient feature: swallow failures and fall back to "no suggestions". + catchError(() => of([])) + ); + } + + /** + * Turn a chosen suggestion into a real operator: create it just to the right + * of the source operator and link the source's output port to the new + * operator's first input port. Added as a single undoable action. + * + * @param sourceOperator the operator the suggestion was made from + * @param sourceOutputPortID the output port the suggestion was anchored on + * @param recommendedType the operator type the user clicked + * @returns the new operator's ID, or `undefined` if it could not be created + */ + public materialize( + sourceOperator: OperatorPredicate, + sourceOutputPortID: string, + recommendedType: string + ): string | undefined { + const newOperator = this.workflowUtilService.getNewOperatorPredicate(recommendedType); + + const sourcePosition = this.workflowActionService + .getJointGraphWrapper() + .getElementPosition(sourceOperator.operatorID); + const newPosition: Point = { + x: sourcePosition.x + JointUIService.DEFAULT_OPERATOR_WIDTH + OperatorRecommendationService.MATERIALIZE_GAP_X, + y: sourcePosition.y, + }; + + const links: OperatorLink[] = []; + // Every recommended successor has an input port, but guard defensively so a + // stale rule can never throw on the canvas. + if (newOperator.inputPorts.length > 0) { + links.push({ + linkID: this.workflowUtilService.getLinkRandomUUID(), + source: { operatorID: sourceOperator.operatorID, portID: sourceOutputPortID }, + target: { operatorID: newOperator.operatorID, portID: newOperator.inputPorts[0].portID }, + }); + } + + this.workflowActionService.addOperatorsAndLinks([{ op: newOperator, pos: newPosition }], links); + return newOperator.operatorID; + } +}