From a0794b6f8ed91c79144737e8b1c3a02f69637e84 Mon Sep 17 00:00:00 2001
From: gupta-sahil01 <01guptasahil@gmail.com>
Date: Sun, 12 Jul 2026 09:18:29 -0700
Subject: [PATCH] feat(frontend): ambient operator recommender suggestions on
the canvas
Frontend half of the ambient operator recommender (apache/texera#5240).
After an operator is added to the canvas, 1-3 faded suggestion chips appear
on its output port showing likely next operators; clicking one materializes
the operator and wires the link as a single undoable action.
- OperatorRecommendationService: calls the stateless agent-service
POST /api/recommend endpoint and turns a chosen suggestion into a real
operator. Fails soft - a disabled feature, an output-less operator, or a
backend error simply yields no suggestions, leaving the canvas unchanged.
- Suggestion overlay in the workflow editor, reusing the chat-popover
anchoring pattern (screen-coord conversion, re-anchored on move / zoom /
pan); dismissed on blank click or when the anchor operator is deleted.
- Opt-in GUI config flag `operatorRecommendationEnabled`, default off,
threaded through gui.conf, GuiConfig, ConfigResource, and the frontend
GuiConfig type + mock.
---
.../base/gateway/gateway-routes.yaml | 5 +
common/config/src/main/resources/gui.conf | 4 +
.../texera/common/config/GuiConfig.scala | 2 +
.../service/resource/ConfigResource.scala | 1 +
frontend/proxy.config.json | 5 +
.../common/service/gui-config.service.mock.ts | 1 +
frontend/src/app/common/type/gui-config.ts | 1 +
.../workflow-editor.component.html | 20 +++
.../workflow-editor.component.scss | 45 +++++
.../workflow-editor.component.ts | 158 +++++++++++++++-
.../operator-recommendation.service.spec.ts | 168 ++++++++++++++++++
.../operator-recommendation.service.ts | 152 ++++++++++++++++
12 files changed, 561 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/app/workspace/service/operator-recommendation/operator-recommendation.service.spec.ts
create mode 100644 frontend/src/app/workspace/service/operator-recommendation/operator-recommendation.service.ts
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;
+ }
+}