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
5 changes: 5 additions & 0 deletions bin/k8s/templates/base/gateway/gateway-routes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions common/config/src/main/resources/gui.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions frontend/proxy.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/common/service/gui-config.service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class MockGuiConfigService {
expirationTimeInMinutes: 2880,
activeTimeInMinutes: 15,
copilotEnabled: false,
operatorRecommendationEnabled: false,
limitColumns: 15,
attributionEnabled: false,
pythonNotebookMigrationEnabled: false,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/common/type/gui-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface GuiConfig {
expirationTimeInMinutes: number;
activeTimeInMinutes: number;
copilotEnabled: boolean;
operatorRecommendationEnabled: boolean;
limitColumns: number;
attributionEnabled: boolean;
pythonNotebookMigrationEnabled: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,24 @@
</div>
</div>
</div>

<!-- Ambient operator recommender: operator suggestions on the output port -->
@if (operatorSuggestion) {
<div
class="operator-recommendation-container"
[style.left.px]="operatorSuggestion.position.x"
[style.top.px]="operatorSuggestion.position.y">
@for (recommendation of operatorSuggestion.recommendations; track recommendation.operatorType) {
<button
type="button"
class="operator-recommendation-item"
[title]="recommendation.reason"
(click)="materializeRecommendation(recommendation)">
<span class="operator-recommendation-name"
>{{ recommendation.userFriendlyName || recommendation.operatorType }}</span
>
</button>
}
</div>
}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand All @@ -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();
}
Expand Down Expand Up @@ -205,6 +220,7 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
this.invokeResize();
this.handleCenterEvent();
this.handleOperatorChatButton();
this.handleOperatorRecommendation();
}

ngOnDestroy(): void {
Expand Down Expand Up @@ -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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional performance fix: getOperatorAddStream triggers a POST /api/recommend on every add, including loading a saved workflow, pasting, and undo/redo. I recommend gating this to interactive ads or suppressing while a workflow is being restored.


// 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
*/
Expand Down
Loading