This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new d10e1a29b8 feat(frontend): add HuggingFace task selector and model
browser component (#5566)
d10e1a29b8 is described below
commit d10e1a29b8704ed9453134cb87e20828ed7684c4
Author: Elliot Lin <[email protected]>
AuthorDate: Mon Jun 29 11:01:39 2026 -0700
feat(frontend): add HuggingFace task selector and model browser component
(#5566)
⚠️ This PR is stacked on #5574. Until that lands, the diff below may
also include PR 5's QA/ranking task changes depending on which base
GitHub is showing. The new code in this PR is the HuggingFaceComponent
(task selector + model browser) under
frontend/src/app/workspace/component/hugging-face/, plus the formly
registration in formly-config.ts and the declaration in app.module.ts.
Once PR #5574 merges and this PR is retargeted to main, the diff should
auto-clean to the PR 6a frontend selector changes only.
### What changes were proposed in this PR?
Add `HuggingFaceComponent`, a custom formly field type (`huggingface`)
that provides:
- A task dropdown listing all supported HuggingFace inference tasks
(fetched from the Texera backend's `/huggingface/tasks` endpoint, with a
static fallback list)
- A paginated model list with client-side search, fetched from the
Texera backend's `/huggingface/models` endpoint (which proxies
HuggingFace Hub)
- Per-task field state preservation — when switching tasks, previously
entered values (modelId, promptColumn, etc.) are saved and restored
This PR registers the component in `formly-config.ts` and declares it in
`AppModule`. The component is not yet wired into the HuggingFace
operator's property editor; the `jsonSchemaMapIntercept` mapping that
routes the `modelId` field to this component is added in the follow-up
property-editor PR (PR 7).
### Any related issues, documentation, discussions?
- Tracking issue: https://github.com/apache/texera/issues/5314
- Closes: https://github.com/apache/texera/issues/5314
- Stacked on: PR tracked in issue #5292
- Parent issue: https://github.com/apache/texera/issues/5041
### How was this PR tested?
7 unit tests added in `hugging-face.component.spec.ts` covering:
- Static task list is non-empty and contains expected tasks
(text-generation, image tasks, audio tasks, QA/ranking tasks)
- Task tags are unique
- Cache invalidation does not throw
Run with `ng test`.
### Was this PR authored or co-authored using generative AI tooling?
Co-authored with Claude Opus 4.7
---------
Signed-off-by: Elliot Lin <[email protected]>
Co-authored-by: Elliot <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
frontend/src/app/app.module.ts | 2 +
frontend/src/app/common/formly/formly-config.ts | 2 +
.../hugging-face/hugging-face.component.html | 208 +++++++
.../hugging-face/hugging-face.component.scss | 162 +++++
.../hugging-face/hugging-face.component.spec.ts | 664 ++++++++++++++++++++
.../hugging-face/hugging-face.component.ts | 680 +++++++++++++++++++++
6 files changed, 1718 insertions(+)
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 5af25b4386..bdebbfba2e 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -107,6 +107,7 @@ import { AgentPanelComponent } from
"./workspace/component/agent/agent-panel/age
import { AgentChatComponent } from
"./workspace/component/agent/agent-panel/agent-chat/agent-chat.component";
import { AgentRegistrationComponent } from
"./workspace/component/agent/agent-panel/agent-registration/agent-registration.component";
import { HuggingFaceImageUploadComponent } from
"./workspace/component/hugging-face-image-upload/hugging-face-image-upload.component";
+import { HuggingFaceComponent } from
"./workspace/component/hugging-face/hugging-face.component";
import { DatasetFileSelectorComponent } from
"./workspace/component/dataset-file-selector/dataset-file-selector.component";
import { DatasetVersionSelectorComponent } from
"./workspace/component/dataset-version-selector/dataset-version-selector.component";
import { DatasetSelectionModalComponent } from
"./workspace/component/dataset-selection-modal/dataset-selection-modal.component";
@@ -331,6 +332,7 @@ registerLocaleData(en);
AgentChatComponent,
AgentRegistrationComponent,
AgentInteractionComponent,
+ HuggingFaceComponent,
HuggingFaceImageUploadComponent,
DatasetFileSelectorComponent,
DatasetVersionSelectorComponent,
diff --git a/frontend/src/app/common/formly/formly-config.ts
b/frontend/src/app/common/formly/formly-config.ts
index ba80dc51f9..f385cf0359 100644
--- a/frontend/src/app/common/formly/formly-config.ts
+++ b/frontend/src/app/common/formly/formly-config.ts
@@ -30,6 +30,7 @@ import { FormlyRepeatDndComponent } from
"./repeat-dnd/repeat-dnd.component";
import { UiUdfParametersComponent } from
"../../workspace/component/ui-udf-parameters/ui-udf-parameters.component";
import { DatasetVersionSelectorComponent } from
"../../workspace/component/dataset-version-selector/dataset-version-selector.component";
import { HuggingFaceImageUploadComponent } from
"../../workspace/component/hugging-face-image-upload/hugging-face-image-upload.component";
+import { HuggingFaceComponent } from
"../../workspace/component/hugging-face/hugging-face.component";
/**
* Configuration for using Json Schema with Formly.
@@ -81,6 +82,7 @@ export const TEXERA_FORMLY_CONFIG = {
{ name: "codearea", component: CodeareaCustomTemplateComponent },
{ name: "inputautocomplete", component: DatasetFileSelectorComponent,
wrappers: ["form-field"] },
{ name: "datasetversionselector", component:
DatasetVersionSelectorComponent, wrappers: ["form-field"] },
+ { name: "huggingface", component: HuggingFaceComponent, wrappers:
["form-field"] },
{ name: "huggingface-image-upload", component:
HuggingFaceImageUploadComponent, wrappers: ["form-field"] },
{ name: "repeat-section-dnd", component: FormlyRepeatDndComponent },
{ name: "ui-udf-parameters", component: UiUdfParametersComponent,
wrappers: ["form-field"] },
diff --git
a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
new file mode 100644
index 0000000000..b44624721a
--- /dev/null
+++
b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
@@ -0,0 +1,208 @@
+<!--
+ 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.
+-->
+
+<div class="hf-model-select-container">
+ <!-- Task dropdown selector -->
+ <label class="hf-section-label">Task</label>
+ <nz-select
+ [(ngModel)]="selectedTaskTag"
+ (ngModelChange)="onTaskSelected($event)"
+ nzPlaceHolder="Select a task"
+ [nzLoading]="tasksLoading"
+ style="width: 100%; margin-bottom: 4px">
+ <nz-option
+ *ngFor="let task of taskOptions"
+ [nzLabel]="task.label"
+ [nzValue]="task.tag">
+ </nz-option>
+ </nz-select>
+
+ <!-- Tasks fetch error (non-blocking: static list is still shown) -->
+ <div
+ *ngIf="tasksError && !tasksLoading"
+ class="hf-error">
+ <span class="error-text">{{ tasksError }}</span>
+ <button
+ nz-button
+ nzType="link"
+ nzSize="small"
+ (click)="retryTasksLoad()">
+ <i
+ nz-icon
+ nzType="reload"></i>
+ Retry
+ </button>
+ </div>
+
+ <!-- Models label -->
+ <label
+ class="hf-section-label"
+ style="margin-top: 8px">
+ <span class="hf-required">*</span> Models
+ </label>
+
+ <!-- Search input -->
+ <nz-input-group
+ [nzSuffix]="searchClearTpl"
+ nzSize="small"
+ style="margin-bottom: 8px">
+ <input
+ nz-input
+ placeholder="Search all models..."
+ [ngModel]="searchText"
+ (ngModelChange)="onSearchInput($event)" />
+ </nz-input-group>
+ <ng-template #searchClearTpl>
+ <nz-spin
+ *ngIf="searchLoading"
+ nzSimple
+ nzSize="small"
+ style="display: inline-block; margin-right: 4px"></nz-spin>
+ <i
+ *ngIf="searchText && !searchLoading"
+ nz-icon
+ nzType="close-circle"
+ nzTheme="fill"
+ style="cursor: pointer; color: #999"
+ (click)="clearSearch()"></i>
+ </ng-template>
+
+ <!-- Loading state -->
+ <div
+ *ngIf="loading"
+ class="hf-loading">
+ <nz-spin
+ nzSimple
+ nzSize="small"></nz-spin>
+ <span class="loading-text">Loading models...</span>
+ </div>
+
+ <!-- Error state -->
+ <div
+ *ngIf="errorMessage && !loading"
+ class="hf-error">
+ <span class="error-text">{{ errorMessage }}</span>
+ <button
+ nz-button
+ nzType="link"
+ nzSize="small"
+ (click)="retryLoad()">
+ <i
+ nz-icon
+ nzType="reload"></i>
+ Retry
+ </button>
+ </div>
+
+ <!-- Truncation notice -->
+ <div
+ *ngIf="truncated && !loading && !errorMessage"
+ class="hf-truncation-notice">
+ Results may be incomplete. Use the search bar to find models not shown
here.
+ </div>
+
+ <!-- Model list -->
+ <div
+ *ngIf="!loading && !errorMessage"
+ class="hf-model-list">
+ <!-- Selected model display -->
+ <div
+ *ngIf="formControl.value"
+ class="hf-selected-model">
+ <span class="hf-selected-label">Selected:</span>
+ <span class="hf-selected-value">{{ formControl.value }}</span>
+ <i
+ nz-icon
+ nzType="close"
+ nzTheme="outline"
+ style="cursor: pointer; color: #999; margin-left: 4px"
+ (click)="formControl.setValue('')"></i>
+ </div>
+
+ <!-- No results message -->
+ <div
+ *ngIf="pagedModels.length === 0"
+ class="hf-empty">
+ {{ isSearching ? 'No models found for "' + searchText + '".' : 'No
models available.' }}
+ </div>
+
+ <!-- Model items -->
+ <div
+ *ngFor="let model of pagedModels"
+ class="hf-model-item"
+ [class.hf-model-item-selected]="formControl.value === model.id"
+ (click)="onModelSelected(model.id)">
+ <span class="hf-model-id">{{ model.id }}</span>
+ <span class="hf-model-meta">
+ <span *ngIf="model.downloads !== undefined">
+ <i
+ nz-icon
+ nzType="download"
+ nzTheme="outline"></i>
+ {{ model.downloads | number }}
+ </span>
+ <span
+ *ngIf="model.likes !== undefined"
+ style="margin-left: 8px">
+ <i
+ nz-icon
+ nzType="heart"
+ nzTheme="outline"></i>
+ {{ model.likes | number }}
+ </span>
+ </span>
+ </div>
+ </div>
+
+ <!-- Pagination controls (outside scrollable list so always visible) -->
+ <div
+ *ngIf="!loading && !errorMessage && totalPages > 1"
+ class="hf-pagination">
+ <button
+ nz-button
+ nzType="default"
+ nzSize="small"
+ [disabled]="currentPage === 0"
+ (click)="prevPage()">
+ <i
+ nz-icon
+ nzType="left"></i>
+ Prev
+ </button>
+ <span class="hf-page-info">Page {{ currentPage + 1 }} of {{ totalPages
}}</span>
+ <button
+ nz-button
+ nzType="default"
+ nzSize="small"
+ [disabled]="!hasNextPage"
+ (click)="nextPage()">
+ Next
+ <i
+ nz-icon
+ nzType="right"></i>
+ </button>
+ </div>
+</div>
+
+<div
+ class="alert alert-danger"
+ role="alert"
+ *ngIf="props.showError && formControl.errors">
+ <formly-validation-message [field]="field"></formly-validation-message>
+</div>
diff --git
a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.scss
b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.scss
new file mode 100644
index 0000000000..f16ddc9153
--- /dev/null
+++
b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.scss
@@ -0,0 +1,162 @@
+/**
+ * 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.
+ */
+
+.hf-model-select-container {
+ width: 100%;
+}
+
+.hf-section-label {
+ display: block;
+ font-size: 14px;
+ font-weight: normal;
+ color: rgba(0, 0, 0, 0.85);
+ line-height: 32px;
+ margin-top: 8px;
+
+ .hf-required {
+ display: inline-block;
+ color: #ff4d4f;
+ font-size: 14px;
+ font-family: SimSun, sans-serif;
+ line-height: 1;
+ margin-right: 4px;
+ }
+}
+
+.hf-loading {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
+
+ .loading-text {
+ font-size: 12px;
+ color: #999;
+ }
+}
+
+.hf-error {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 0;
+
+ .error-text {
+ font-size: 12px;
+ color: #ff4d4f;
+ }
+}
+
+.hf-truncation-notice {
+ font-size: 12px;
+ color: #faad14;
+ padding: 4px 0;
+ margin-bottom: 4px;
+}
+
+.hf-model-list {
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ max-height: 360px;
+ overflow-y: auto;
+}
+
+.hf-selected-model {
+ display: flex;
+ align-items: center;
+ padding: 6px 10px;
+ background: #e6f7ff;
+ border-bottom: 1px solid #d9d9d9;
+ font-size: 12px;
+
+ .hf-selected-label {
+ font-weight: 500;
+ margin-right: 6px;
+ color: rgba(0, 0, 0, 0.65);
+ }
+
+ .hf-selected-value {
+ color: #1890ff;
+ font-weight: 500;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.hf-empty {
+ padding: 16px;
+ text-align: center;
+ color: #999;
+ font-size: 12px;
+}
+
+.hf-model-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px;
+ cursor: pointer;
+ border-bottom: 1px solid #f0f0f0;
+ transition: background 0.15s;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &:hover {
+ background: #fafafa;
+ }
+
+ &.hf-model-item-selected {
+ background: #e6f7ff;
+ }
+
+ .hf-model-id {
+ font-size: 12px;
+ color: rgba(0, 0, 0, 0.85);
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-right: 8px;
+ }
+
+ .hf-model-meta {
+ font-size: 11px;
+ color: #999;
+ white-space: nowrap;
+ flex-shrink: 0;
+ }
+}
+
+.hf-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 8px 0;
+ margin-top: 4px;
+
+ .hf-page-info {
+ font-size: 12px;
+ color: rgba(0, 0, 0, 0.65);
+ }
+}
diff --git
a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
new file mode 100644
index 0000000000..3e2e5bca72
--- /dev/null
+++
b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
@@ -0,0 +1,664 @@
+/**
+ * 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 { ComponentFixture, TestBed, fakeAsync, tick } from
"@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from
"@angular/common/http/testing";
+import { FormControl, FormGroup } from "@angular/forms";
+import { FieldTypeConfig } from "@ngx-formly/core";
+import { AppSettings } from "../../../common/app-setting";
+import {
+ HuggingFaceComponent,
+ HuggingFaceModelOption,
+ HuggingFaceTaskOption,
+ STATIC_TASK_OPTIONS,
+ invalidateHuggingFaceModelCache,
+} from "./hugging-face.component";
+
+const API = "api";
+
+function buildModels(count: number, prefix = "model"):
HuggingFaceModelOption[] {
+ return Array.from({ length: count }, (_, i) => ({
+ id: `${prefix}/${prefix}-${i}`,
+ label: `${prefix}-${i}`,
+ downloads: 1000 - i,
+ likes: 500 - i,
+ }));
+}
+
+function buildTaskResponse(): HuggingFaceTaskOption[] {
+ return [
+ { tag: "text-generation", label: "Text Generation" },
+ { tag: "image-classification", label: "Image Classification" },
+ ];
+}
+
+/**
+ * Build a minimal FormlyFieldConfig with a FormGroup backing it,
+ * similar to what Formly provides at runtime.
+ */
+function buildFieldWithFormGroup(taskValue = "", modelIdValue = ""): { field:
FieldTypeConfig; formGroup: FormGroup } {
+ const formGroup = new FormGroup({
+ task: new FormControl(taskValue),
+ modelId: new FormControl(modelIdValue),
+ promptColumn: new FormControl(""),
+ imageInput: new FormControl(""),
+ audioInput: new FormControl(""),
+ inputImageColumn: new FormControl(""),
+ inputAudioColumn: new FormControl(""),
+ candidateLabels: new FormControl(""),
+ sentencesColumn: new FormControl(""),
+ contextColumn: new FormControl(""),
+ systemPrompt: new FormControl("You are a helpful assistant."),
+ maxNewTokens: new FormControl(256),
+ temperature: new FormControl(0.7),
+ });
+
+ const model: Record<string, unknown> = {
+ task: taskValue,
+ modelId: modelIdValue,
+ };
+
+ const field = {
+ key: "modelId",
+ formControl: formGroup.get("modelId")! as FormControl,
+ form: formGroup,
+ model,
+ props: {},
+ parent: { fieldGroup: [] },
+ options: { detectChanges: vi.fn() },
+ } as unknown as FieldTypeConfig;
+
+ return { field, formGroup };
+}
+
+// ── Pure unit tests (no TestBed) ──
+
+describe("HuggingFaceComponent (unit)", () => {
+ beforeEach(() => {
+ invalidateHuggingFaceModelCache();
+ });
+
+ it("should export a non-empty static task list", () => {
+ expect(STATIC_TASK_OPTIONS.length).toBeGreaterThan(0);
+ });
+
+ it("should include text-generation in static task options", () => {
+ const textGen = STATIC_TASK_OPTIONS.find(t => t.tag === "text-generation");
+ expect(textGen).toBeTruthy();
+ expect(textGen!.label).toBe("Text Generation");
+ });
+
+ it("should include image tasks in static task options", () => {
+ const imageTasks = STATIC_TASK_OPTIONS.filter(t =>
+ ["image-classification", "object-detection", "image-segmentation",
"image-to-text"].includes(t.tag)
+ );
+ expect(imageTasks.length).toBe(4);
+ });
+
+ it("should include audio tasks in static task options", () => {
+ const audioTasks = STATIC_TASK_OPTIONS.filter(t =>
+ ["automatic-speech-recognition", "audio-classification",
"text-to-speech"].includes(t.tag)
+ );
+ expect(audioTasks.length).toBe(3);
+ });
+
+ it("should include QA/ranking tasks in static task options", () => {
+ const qaTasks = STATIC_TASK_OPTIONS.filter(t =>
+ ["question-answering", "zero-shot-classification",
"sentence-similarity", "text-ranking"].includes(t.tag)
+ );
+ expect(qaTasks.length).toBe(4);
+ });
+
+ it("should clear caches on invalidateHuggingFaceModelCache", () => {
+ expect(() => invalidateHuggingFaceModelCache()).not.toThrow();
+ });
+
+ it("should have unique tags in static task options", () => {
+ const tags = STATIC_TASK_OPTIONS.map(t => t.tag);
+ const uniqueTags = new Set(tags);
+ expect(uniqueTags.size).toBe(tags.length);
+ });
+});
+
+// ── TestBed-based integration tests ──
+
+describe("HuggingFaceComponent (TestBed)", () => {
+ let component: HuggingFaceComponent;
+ let fixture: ComponentFixture<HuggingFaceComponent>;
+ let http: HttpTestingController;
+
+ beforeEach(async () => {
+ invalidateHuggingFaceModelCache();
+
+ await TestBed.configureTestingModule({
+ imports: [HuggingFaceComponent, HttpClientTestingModule],
+ }).compileComponents();
+
+ vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API);
+
+ fixture = TestBed.createComponent(HuggingFaceComponent);
+ component = fixture.componentInstance;
+ http = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ // Destroy the component to trigger ngOnDestroy and clean up
subscriptions/timers
+ fixture.destroy();
+ // Flush any pending icon SVG requests from NzIconModule before verifying
+ http.match(req => req.url.startsWith("assets/")).forEach(req =>
req.flush("<svg></svg>"));
+ http.verify();
+ });
+
+ /** Flush any pending NzIcon SVG asset requests. */
+ function flushIconRequests() {
+ http.match(req => req.url.startsWith("assets/")).forEach(req =>
req.flush("<svg></svg>"));
+ }
+
+ /** Set up field + trigger ngOnInit, then flush the two startup HTTP
requests. */
+ function initComponent(taskTag = "text-generation", models:
HuggingFaceModelOption[] = buildModels(3)) {
+ const { field } = buildFieldWithFormGroup(taskTag);
+ component.field = field;
+ fixture.detectChanges(); // triggers ngOnInit
+ flushIconRequests();
+
+ // ngOnInit fires two HTTP requests: tasks + models
+ const tasksReq = http.expectOne(`${API}/huggingface/tasks`);
+ tasksReq.flush(buildTaskResponse());
+
+ const modelsReq = http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`));
+ modelsReq.flush(models);
+ flushIconRequests();
+ }
+
+ // ── Creation ──
+
+ it("should create the component", () => {
+ initComponent();
+ expect(component).toBeTruthy();
+ });
+
+ it("should default selectedTaskTag to text-generation", () => {
+ initComponent();
+ expect(component.selectedTaskTag).toBe("text-generation");
+ });
+
+ // ── Task loading ──
+
+ describe("task loading", () => {
+ it("should fetch tasks from the API on init", () => {
+ const { field } = buildFieldWithFormGroup();
+ component.field = field;
+ fixture.detectChanges();
+
+ const tasksReq = http.expectOne(`${API}/huggingface/tasks`);
+ expect(tasksReq.request.method).toBe("GET");
+ tasksReq.flush(buildTaskResponse());
+
+ // Also flush the models request
+ http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`)).flush([]);
+
+ expect(component.taskOptions).toEqual(buildTaskResponse());
+ expect(component.tasksLoading).toBe(false);
+ });
+
+ it("should fall back to STATIC_TASK_OPTIONS when API returns empty array",
() => {
+ const { field } = buildFieldWithFormGroup();
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush([]);
+ http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`)).flush([]);
+
+ expect(component.taskOptions).toEqual(STATIC_TASK_OPTIONS);
+ });
+
+ it("should fall back to STATIC_TASK_OPTIONS on task fetch error", () => {
+ const { field } = buildFieldWithFormGroup();
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).error(new
ProgressEvent("error"));
+ http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`)).flush([]);
+
+ expect(component.taskOptions).toEqual(STATIC_TASK_OPTIONS);
+ expect(component.tasksError).toBeTruthy();
+ expect(component.tasksLoading).toBe(false);
+ });
+
+ it("retryTasksLoad should clear error and re-fetch tasks", fakeAsync(() =>
{
+ initComponent();
+
+ // Simulate a prior error state by directly calling retryTasksLoad
+ // First, force an error so retryTasksLoad has something to retry
+ invalidateHuggingFaceModelCache();
+ component.tasksError = "previous error";
+ component.retryTasksLoad();
+ tick();
+
+ const tasksReq = http.expectOne(`${API}/huggingface/tasks`);
+ tasksReq.flush(buildTaskResponse());
+
+ expect(component.tasksError).toBeNull();
+ expect(component.taskOptions).toEqual(buildTaskResponse());
+ }));
+ });
+
+ // ── Model loading ──
+
+ describe("model loading", () => {
+ it("should fetch models for the selected task on init", () => {
+ const { field } = buildFieldWithFormGroup("image-classification");
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ const modelsReq =
http.expectOne(`${API}/huggingface/models?task=image-classification`);
+ expect(modelsReq.request.method).toBe("GET");
+ modelsReq.flush(buildModels(5));
+
+ expect(component.pagedModels.length).toBe(5);
+ expect(component.loading).toBe(false);
+ });
+
+ it("should show loading state while models are being fetched", () => {
+ const { field } = buildFieldWithFormGroup();
+ component.field = field;
+ fixture.detectChanges();
+
+ // Tasks request is pending, but check model loading state
+ expect(component.loading).toBe(true);
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(2));
+
+ expect(component.loading).toBe(false);
+ });
+
+ it("should set truncated flag from X-Texera-Truncated header", () => {
+ const { field } = buildFieldWithFormGroup();
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ const modelsReq = http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`));
+ modelsReq.flush(buildModels(5), { headers: { "X-Texera-Truncated":
"true" } });
+
+ expect(component.truncated).toBe(true);
+ });
+
+ it("should not set truncated when header is absent", () => {
+ initComponent("text-generation", buildModels(5));
+ expect(component.truncated).toBe(false);
+ });
+
+ it("should display error on model fetch failure", () => {
+ const { field } = buildFieldWithFormGroup();
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`)).error(new
ProgressEvent("error"));
+
+ expect(component.errorMessage).toBeTruthy();
+ expect(component.loading).toBe(false);
+ expect(component.pagedModels.length).toBe(0);
+ });
+
+ it("retryLoad should clear error and re-fetch models", () => {
+ const { field } = buildFieldWithFormGroup();
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`)).error(new
ProgressEvent("error"));
+
+ expect(component.errorMessage).toBeTruthy();
+
+ component.retryLoad();
+ const retryReq = http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`));
+ retryReq.flush(buildModels(3));
+
+ expect(component.errorMessage).toBeNull();
+ expect(component.pagedModels.length).toBe(3);
+ });
+
+ it("should use cached models on second access for the same task", () => {
+ initComponent("text-generation", buildModels(3));
+ expect(component.pagedModels.length).toBe(3);
+
+ // Simulate switching away and back — models should come from cache, no
HTTP request
+ component.onTaskSelected("image-classification");
+ const modelsReq =
http.expectOne(`${API}/huggingface/models?task=image-classification`);
+ modelsReq.flush(buildModels(2, "img"));
+
+ component.onTaskSelected("text-generation");
+ // No new HTTP request for text-generation — it's cached
+ expect(component.pagedModels.length).toBe(3);
+ });
+ });
+
+ // ── Pagination ──
+
+ describe("pagination", () => {
+ it("should page models with PAGE_SIZE of 50", () => {
+ initComponent("text-generation", buildModels(120));
+
+ expect(component.totalPages).toBe(3);
+ expect(component.currentPage).toBe(0);
+ expect(component.pagedModels.length).toBe(50);
+ });
+
+ it("should navigate to next page", () => {
+ initComponent("text-generation", buildModels(120));
+
+ component.nextPage();
+ expect(component.currentPage).toBe(1);
+ expect(component.pagedModels.length).toBe(50);
+ expect(component.pagedModels[0].id).toBe("model/model-50");
+ });
+
+ it("should navigate to previous page", () => {
+ initComponent("text-generation", buildModels(120));
+
+ component.nextPage();
+ expect(component.currentPage).toBe(1);
+
+ component.prevPage();
+ expect(component.currentPage).toBe(0);
+ expect(component.pagedModels[0].id).toBe("model/model-0");
+ });
+
+ it("should not go below page 0", () => {
+ initComponent("text-generation", buildModels(120));
+
+ component.prevPage();
+ expect(component.currentPage).toBe(0);
+ });
+
+ it("should not go past the last page", () => {
+ initComponent("text-generation", buildModels(120));
+
+ component.nextPage();
+ component.nextPage();
+ expect(component.currentPage).toBe(2);
+ expect(component.pagedModels.length).toBe(20); // 120 - 2*50 = 20
+
+ component.nextPage();
+ expect(component.currentPage).toBe(2); // stays at last page
+ });
+
+ it("hasNextPage should return correct value", () => {
+ initComponent("text-generation", buildModels(120));
+
+ expect(component.hasNextPage).toBe(true);
+ component.nextPage();
+ expect(component.hasNextPage).toBe(true);
+ component.nextPage();
+ expect(component.hasNextPage).toBe(false);
+ });
+
+ it("goToPage should clamp to valid range", () => {
+ initComponent("text-generation", buildModels(120));
+
+ component.goToPage(999);
+ expect(component.currentPage).toBe(2); // last page
+
+ component.goToPage(0);
+ expect(component.currentPage).toBe(0);
+ });
+
+ it("should show single page for small model lists", () => {
+ initComponent("text-generation", buildModels(10));
+
+ expect(component.totalPages).toBe(1);
+ expect(component.currentPage).toBe(0);
+ expect(component.pagedModels.length).toBe(10);
+ expect(component.hasNextPage).toBe(false);
+ });
+
+ it("should handle empty model list", () => {
+ initComponent("text-generation", []);
+
+ expect(component.totalPages).toBe(1);
+ expect(component.currentPage).toBe(0);
+ expect(component.pagedModels.length).toBe(0);
+ });
+ });
+
+ // ── Search ──
+
+ describe("search", () => {
+ it("should filter models locally when list is not truncated", () => {
+ const models = [
+ { id: "bert-base", label: "bert-base", downloads: 100, likes: 50 },
+ { id: "gpt2", label: "gpt2", downloads: 200, likes: 100 },
+ { id: "bert-large", label: "bert-large", downloads: 80, likes: 40 },
+ ];
+ initComponent("text-generation", models);
+
+ component.onSearchInput("bert");
+
+ expect(component.pagedModels.length).toBe(2);
+ expect(component.pagedModels.every(m =>
m.id.includes("bert"))).toBe(true);
+ });
+
+ it("should be case-insensitive for local search", () => {
+ const models = [
+ { id: "BERT-Base", label: "BERT-Base", downloads: 100, likes: 50 },
+ { id: "gpt2", label: "gpt2", downloads: 200, likes: 100 },
+ ];
+ initComponent("text-generation", models);
+
+ component.onSearchInput("bert");
+ expect(component.pagedModels.length).toBe(1);
+ expect(component.pagedModels[0].id).toBe("BERT-Base");
+ });
+
+ it("should clear filter when search text is empty", () => {
+ const models = buildModels(5);
+ initComponent("text-generation", models);
+
+ component.onSearchInput("model-0");
+ expect(component.pagedModels.length).toBe(1);
+
+ component.onSearchInput("");
+ expect(component.pagedModels.length).toBe(5);
+ });
+
+ it("should clear filter when search text is whitespace", () => {
+ const models = buildModels(5);
+ initComponent("text-generation", models);
+
+ component.onSearchInput("model-0");
+ expect(component.pagedModels.length).toBe(1);
+
+ component.onSearchInput(" ");
+ expect(component.pagedModels.length).toBe(5);
+ });
+
+ it("clearSearch should reset search state", () => {
+ initComponent("text-generation", buildModels(5));
+
+ component.onSearchInput("model-0");
+ expect(component.searchText).toBe("model-0");
+
+ component.clearSearch();
+ expect(component.searchText).toBe("");
+ expect(component.searchLoading).toBe(false);
+ expect(component.pagedModels.length).toBe(5);
+ });
+
+ it("isSearching should return true when filtered models exist", () => {
+ initComponent("text-generation", buildModels(5));
+
+ expect(component.isSearching).toBe(false);
+
+ component.onSearchInput("model-0");
+ expect(component.isSearching).toBe(true);
+
+ component.clearSearch();
+ expect(component.isSearching).toBe(false);
+ });
+
+ it("should use server-side search when list is truncated", fakeAsync(() =>
{
+ const { field } = buildFieldWithFormGroup();
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ const modelsReq = http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`));
+ modelsReq.flush(buildModels(5), { headers: { "X-Texera-Truncated":
"true" } });
+
+ expect(component.truncated).toBe(true);
+
+ // Trigger server-side search
+ component.onSearchInput("special-model");
+ tick(300); // debounceTime
+
+ const searchReq = http.expectOne(
+ req => req.url.includes("/huggingface/models") &&
req.url.includes("search=special-model")
+ );
+ const searchResults = [{ id: "special-model/v1", label:
"special-model/v1" }];
+ searchReq.flush(searchResults);
+
+ expect(component.pagedModels.length).toBe(1);
+ expect(component.pagedModels[0].id).toBe("special-model/v1");
+ expect(component.searchLoading).toBe(false);
+ }));
+
+ it("should reset pagination to page 0 on search", () => {
+ initComponent("text-generation", buildModels(120));
+
+ component.nextPage();
+ expect(component.currentPage).toBe(1);
+
+ component.onSearchInput("model-1");
+ expect(component.currentPage).toBe(0);
+ });
+ });
+
+ // ── Task selection ──
+
+ describe("task selection", () => {
+ it("onTaskSelected should update selectedTaskTag", () => {
+ initComponent();
+
+ component.onTaskSelected("image-classification");
+
http.expectOne(`${API}/huggingface/models?task=image-classification`).flush(buildModels(2,
"img"));
+
+ expect(component.selectedTaskTag).toBe("image-classification");
+ });
+
+ it("onTaskSelected should load models for the new task", () => {
+ initComponent();
+
+ component.onTaskSelected("image-classification");
+ const req =
http.expectOne(`${API}/huggingface/models?task=image-classification`);
+ req.flush(buildModels(4, "img"));
+
+ expect(component.pagedModels.length).toBe(4);
+ });
+
+ it("onTaskSelected should clear search state", () => {
+ initComponent("text-generation", buildModels(5));
+
+ component.onSearchInput("model-0");
+ expect(component.searchText).toBe("model-0");
+
+ component.onTaskSelected("image-classification");
+
http.expectOne(`${API}/huggingface/models?task=image-classification`).flush([]);
+
+ expect(component.searchText).toBe("");
+ });
+
+ it("should persist task to model and form control", () => {
+ const { field, formGroup } = buildFieldWithFormGroup("text-generation");
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`)).flush([]);
+
+ component.onTaskSelected("image-classification");
+
http.expectOne(`${API}/huggingface/models?task=image-classification`).flush([]);
+
+ expect(formGroup.get("task")!.value).toBe("image-classification");
+ expect(field.model!["task"]).toBe("image-classification");
+ });
+
+ it("should restore task-scoped field state when switching back", () => {
+ const { field, formGroup } = buildFieldWithFormGroup("text-generation");
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`)).flush([]);
+
+ // Set a custom value while on text-generation
+ formGroup.get("systemPrompt")!.setValue("Custom prompt");
+
+ // Switch to image-classification
+ component.onTaskSelected("image-classification");
+
http.expectOne(`${API}/huggingface/models?task=image-classification`).flush([]);
+
+ // The systemPrompt should be reset (first visit defaults)
+ expect(formGroup.get("systemPrompt")!.value).toBe("You are a helpful
assistant.");
+
+ // Switch back to text-generation — should restore the custom prompt
+ component.onTaskSelected("text-generation");
+ expect(formGroup.get("systemPrompt")!.value).toBe("Custom prompt");
+ });
+
+ it("should read initial task tag from the form model", () => {
+ const { field } = buildFieldWithFormGroup("image-classification");
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+
http.expectOne(`${API}/huggingface/models?task=image-classification`).flush(buildModels(2,
"img"));
+
+ expect(component.selectedTaskTag).toBe("image-classification");
+ });
+ });
+
+ // ── Model selection ──
+
+ describe("model selection", () => {
+ it("onModelSelected should set the formControl value", () => {
+ const { field } = buildFieldWithFormGroup();
+ component.field = field;
+ fixture.detectChanges();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ http.expectOne(req =>
req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(3));
+
+ component.onModelSelected("model/model-1");
+ expect(field.formControl!.value).toBe("model/model-1");
+ });
+ });
+
+ // ── Cleanup ──
+
+ describe("cleanup", () => {
+ it("should clean up on destroy without errors", () => {
+ initComponent();
+ expect(() => component.ngOnDestroy()).not.toThrow();
+ });
+ });
+});
diff --git
a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
new file mode 100644
index 0000000000..f84d4f940f
--- /dev/null
+++
b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
@@ -0,0 +1,680 @@
+/**
+ * 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 { Component, OnInit, OnDestroy, ChangeDetectorRef } from
"@angular/core";
+import { CommonModule } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core";
+import { HttpClient } from "@angular/common/http";
+import { NzSelectModule } from "ng-zorro-antd/select";
+import { NzInputModule } from "ng-zorro-antd/input";
+import { NzSpinModule } from "ng-zorro-antd/spin";
+import { NzButtonModule } from "ng-zorro-antd/button";
+import { NzIconModule } from "ng-zorro-antd/icon";
+import { AppSettings } from "../../../common/app-setting";
+import { of, Subject, Subscription } from "rxjs";
+import { catchError, debounceTime, finalize, switchMap, takeUntil } from
"rxjs/operators";
+
+export interface HuggingFaceModelOption {
+ id: string;
+ label: string;
+ pipeline_tag?: string;
+ downloads?: number;
+ likes?: number;
+}
+
+export interface HuggingFaceTaskOption {
+ tag: string;
+ label: string;
+}
+
+// ── Static fallback task list (used when the dynamic fetch fails) ──
+export const STATIC_TASK_OPTIONS: HuggingFaceTaskOption[] = [
+ { tag: "text-generation", label: "Text Generation" },
+ { tag: "automatic-speech-recognition", label: "Automatic Speech Recognition"
},
+ { tag: "audio-classification", label: "Audio Classification" },
+ { tag: "text-classification", label: "Text Classification" },
+ { tag: "text-to-speech", label: "Text to Speech" },
+ { tag: "token-classification", label: "Token Classification" },
+ { tag: "question-answering", label: "Question Answering" },
+ { tag: "table-question-answering", label: "Table Question Answering" },
+ { tag: "zero-shot-classification", label: "Zero-Shot Classification" },
+ { tag: "translation", label: "Translation" },
+ { tag: "summarization", label: "Summarization" },
+ { tag: "feature-extraction", label: "Feature Extraction" },
+ { tag: "fill-mask", label: "Fill-Mask" },
+ { tag: "sentence-similarity", label: "Sentence Similarity" },
+ { tag: "text-ranking", label: "Text Ranking" },
+ { tag: "image-classification", label: "Image Classification" },
+ { tag: "object-detection", label: "Object Detection" },
+ { tag: "image-segmentation", label: "Image Segmentation" },
+ { tag: "image-to-text", label: "Image to Text" },
+ { tag: "visual-question-answering", label: "Visual Question Answering" },
+ { tag: "document-question-answering", label: "Document Question Answering" },
+ { tag: "zero-shot-image-classification", label: "Zero-Shot Image
Classification" },
+];
+
+const PAGE_SIZE = 50;
+
+const TRUNCATED_HEADER = "X-Texera-Truncated";
+
+// ── Module-level caches (reused across component instances) ──
+const allModelsByTag: Map<string, HuggingFaceModelOption[]> = new Map();
+const truncatedByTag: Set<string> = new Set();
+const inFlightByTag: Map<string, Subscription> = new Map();
+const errorByTag: Map<string, string> = new Map();
+
+let cachedTaskOptions: HuggingFaceTaskOption[] | null = null;
+let tasksFetchSubscription: Subscription | null = null;
+let tasksFetchError: string | null = null;
+
+/** Clear all cached data (useful for tests or manual invalidation). */
+export function invalidateHuggingFaceModelCache(): void {
+ allModelsByTag.clear();
+ truncatedByTag.clear();
+ errorByTag.clear();
+ inFlightByTag.forEach(sub => sub.unsubscribe());
+ inFlightByTag.clear();
+ cachedTaskOptions = null;
+ tasksFetchError = null;
+ tasksFetchSubscription?.unsubscribe();
+ tasksFetchSubscription = null;
+}
+
+@Component({
+ selector: "texera-hugging-face-model-select",
+ templateUrl: "./hugging-face.component.html",
+ styleUrls: ["hugging-face.component.scss"],
+ imports: [
+ CommonModule,
+ FormsModule,
+ NzSelectModule,
+ NzInputModule,
+ NzSpinModule,
+ NzButtonModule,
+ NzIconModule,
+ FormlyModule,
+ ],
+})
+export class HuggingFaceComponent extends FieldType<FieldTypeConfig>
implements OnInit, OnDestroy {
+ private readonly taskScopedKeys = [
+ "modelId",
+ "promptColumn",
+ "imageInput",
+ "audioInput",
+ "inputImageColumn",
+ "inputAudioColumn",
+ "candidateLabels",
+ "sentencesColumn",
+ "contextColumn",
+ "systemPrompt",
+ "maxNewTokens",
+ "temperature",
+ ] as const;
+ private readonly taskStateByTag = new Map<string, Partial<Record<(typeof
this.taskScopedKeys)[number], unknown>>>();
+ // ── Task state ──
+ taskOptions: HuggingFaceTaskOption[] = cachedTaskOptions ??
STATIC_TASK_OPTIONS;
+ selectedTaskTag = "text-generation";
+ tasksLoading = false;
+ tasksError: string | null = null;
+
+ // ── All models for the current task (fetched once from backend, cached) ──
+ private allModels: HuggingFaceModelOption[] = [];
+
+ // ── Displayed state ──
+ pagedModels: HuggingFaceModelOption[] = [];
+ currentPage = 0;
+ totalPages = 0;
+
+ loading = false;
+ errorMessage: string | null = null;
+
+ // ── Truncation notice ──
+ truncated = false;
+
+ // ── Search state ──
+ searchText = "";
+ searchLoading = false;
+ private filteredModels: HuggingFaceModelOption[] | null = null;
+ private readonly searchSubject$ = new Subject<string>();
+ private searchSubscription: Subscription | null = null;
+
+ private readonly destroy$ = new Subject<void>();
+ private subscription: Subscription | null = null;
+ private taskPollInterval: ReturnType<typeof setInterval> | null = null;
+ private modelPollInterval: ReturnType<typeof setInterval> | null = null;
+ private initTimeout: ReturnType<typeof setTimeout> | null = null;
+
+ constructor(
+ private http: HttpClient,
+ private cdr: ChangeDetectorRef
+ ) {
+ super();
+ }
+
+ ngOnInit(): void {
+ const savedTag = this.getCurrentTaskTag();
+ this.selectedTaskTag = savedTag ?? this.selectedTaskTag;
+ this.syncTaskSelection(this.selectedTaskTag, false);
+ this.loadTasks();
+ this.loadAllModels();
+ this.setupServerSearch();
+ // Formly can attach sibling controls after this field initializes.
+ // Re-sync once the control tree settles so a fresh operator starts in a
valid task state.
+ this.initTimeout = setTimeout(
+ () => this.syncTaskSelection(this.getCurrentTaskTag() ??
this.selectedTaskTag, false),
+ 0
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.subscription?.unsubscribe();
+ this.searchSubscription?.unsubscribe();
+ this.searchSubject$.complete();
+ if (this.taskPollInterval !== null) {
+ clearInterval(this.taskPollInterval);
+ }
+ if (this.modelPollInterval !== null) {
+ clearInterval(this.modelPollInterval);
+ }
+ if (this.initTimeout !== null) {
+ clearTimeout(this.initTimeout);
+ }
+ }
+
+ // ── Task loading ──
+
+ /**
+ * Fetch available pipeline tags from the backend, which proxies
HuggingFace's /api/tasks.
+ * Falls back to STATIC_TASK_OPTIONS if the fetch fails.
+ */
+ private loadTasks(): void {
+ // Already fetched and cached
+ if (cachedTaskOptions !== null) {
+ this.taskOptions = cachedTaskOptions;
+ return;
+ }
+
+ // Previous fetch errored — show static list, don't retry automatically
+ if (tasksFetchError !== null) {
+ this.tasksError = tasksFetchError;
+ this.taskOptions = STATIC_TASK_OPTIONS;
+ return;
+ }
+
+ // Another component instance already has a fetch in flight — wait for it
+ if (tasksFetchSubscription !== null) {
+ this.tasksLoading = true;
+ if (this.taskPollInterval !== null) clearInterval(this.taskPollInterval);
+ const poll = setInterval(() => {
+ if (cachedTaskOptions !== null || tasksFetchError !== null) {
+ clearInterval(poll);
+ this.taskPollInterval = null;
+ this.tasksLoading = false;
+ this.taskOptions = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+ if (tasksFetchError) this.tasksError = tasksFetchError;
+ this.cdr.detectChanges();
+ } else if (tasksFetchSubscription === null) {
+ // Fetch was canceled before populating caches; stop polling and
fall back.
+ clearInterval(poll);
+ this.taskPollInterval = null;
+ this.tasksLoading = false;
+ this.taskOptions = STATIC_TASK_OPTIONS;
+ this.cdr.detectChanges();
+ }
+ }, 200);
+ this.taskPollInterval = poll;
+ return;
+ }
+
+ this.tasksLoading = true;
+ this.tasksError = null;
+ this.cdr.detectChanges();
+
+ tasksFetchSubscription = this.http
+
.get<HuggingFaceTaskOption[]>(`${AppSettings.getApiEndpoint()}/huggingface/tasks`)
+ .pipe(
+ takeUntil(this.destroy$),
+ finalize(() => {
+ // If takeUntil fires before next/error, reset the module-level guard
+ // so the next component instance can start a fresh fetch.
+ if (cachedTaskOptions === null && tasksFetchError === null) {
+ tasksFetchSubscription = null;
+ }
+ })
+ )
+ .subscribe({
+ next: tasks => {
+ tasksFetchSubscription = null;
+ cachedTaskOptions = tasks.length > 0 ? tasks : STATIC_TASK_OPTIONS;
+ this.taskOptions = cachedTaskOptions;
+ this.tasksLoading = false;
+ this.cdr.detectChanges();
+ },
+ error: (err: unknown) => {
+ console.error("Failed to load HuggingFace tasks:", err);
+ tasksFetchSubscription = null;
+ tasksFetchError = "Could not load tasks from Hugging Face. Using
default list.";
+ this.tasksError = tasksFetchError;
+ this.taskOptions = STATIC_TASK_OPTIONS;
+ this.tasksLoading = false;
+ this.cdr.detectChanges();
+ },
+ });
+ }
+
+ retryTasksLoad(): void {
+ tasksFetchError = null;
+ this.tasksError = null;
+ this.loadTasks();
+ }
+
+ // ── Task selection ──
+
+ onTaskSelected(tag: string): void {
+ const previousTask = this.getCurrentTaskTag() ?? this.selectedTaskTag;
+ this.snapshotTaskState(previousTask);
+ this.syncTaskSelection(tag, true);
+ this.restoreTaskState(tag);
+ this.searchText = "";
+ this.filteredModels = null;
+ // Cancel any in-flight server search for the previous task
+ this.searchSubject$.next("");
+ this.loadAllModels();
+ }
+
+ // ── Data loading ──
+
+ /**
+ * Fetch ALL models for the selected task.
+ * The backend paginates through HF Hub internally and caches the result.
+ * The first request per task may be slow; subsequent requests are instant.
+ */
+ private loadAllModels(): void {
+ const tag = this.selectedTaskTag || "text-generation";
+
+ this.loading = false;
+ this.errorMessage = null;
+
+ // Fast path: cached on the frontend
+ if (allModelsByTag.has(tag)) {
+ this.allModels = allModelsByTag.get(tag)!;
+ this.truncated = truncatedByTag.has(tag);
+ this.goToPage(0);
+ return;
+ }
+
+ // Previous error
+ if (errorByTag.has(tag)) {
+ this.errorMessage = errorByTag.get(tag)!;
+ this.allModels = [];
+ this.pagedModels = [];
+ this.totalPages = 0;
+ return;
+ }
+
+ // Another instance is already fetching this task — wait for it
+ if (inFlightByTag.has(tag)) {
+ this.loading = true;
+ if (this.modelPollInterval !== null)
clearInterval(this.modelPollInterval);
+ const poll = setInterval(() => {
+ if (allModelsByTag.has(tag) || errorByTag.has(tag)) {
+ clearInterval(poll);
+ this.modelPollInterval = null;
+ this.loading = false;
+ if (allModelsByTag.has(tag)) {
+ this.allModels = allModelsByTag.get(tag)!;
+ this.truncated = truncatedByTag.has(tag);
+ this.goToPage(0);
+ } else {
+ this.errorMessage = errorByTag.get(tag)!;
+ this.cdr.detectChanges();
+ }
+ } else if (!inFlightByTag.has(tag)) {
+ // Fetch was canceled before populating caches; stop polling and
fall back.
+ clearInterval(poll);
+ this.modelPollInterval = null;
+ this.loading = false;
+ this.cdr.detectChanges();
+ }
+ }, 200);
+ this.modelPollInterval = poll;
+ return;
+ }
+
+ // Cancel previous
+ this.subscription?.unsubscribe();
+ this.subscription = null;
+
+ this.allModels = [];
+ this.pagedModels = [];
+ this.totalPages = 0;
+
+ // Show spinner immediately for the initial fetch — it can take a while
+ // as the backend pages through HF Hub for the first time.
+ this.loading = true;
+ this.cdr.detectChanges();
+
+ this.subscription = this.http
+ .get<HuggingFaceModelOption[]>(
+
`${AppSettings.getApiEndpoint()}/huggingface/models?task=${encodeURIComponent(tag)}`,
+ { observe: "response" }
+ )
+ .pipe(
+ takeUntil(this.destroy$),
+ finalize(() => {
+ // If takeUntil cancels before next/error fires, clear the in-flight
+ // guard so a later instance re-fetches instead of polling forever.
+ if (!allModelsByTag.has(tag) && !errorByTag.has(tag)) {
+ inFlightByTag.delete(tag);
+ }
+ })
+ )
+ .subscribe({
+ next: resp => {
+ const models = resp.body ?? [];
+ if (resp.headers.get(TRUNCATED_HEADER) === "true") {
+ truncatedByTag.add(tag);
+ }
+ allModelsByTag.set(tag, models);
+ inFlightByTag.delete(tag);
+ this.loading = false;
+ this.truncated = truncatedByTag.has(tag);
+ this.allModels = models;
+ this.goToPage(0);
+ },
+ error: (err: unknown) => {
+ console.error(`Failed to load HuggingFace models for task
'${tag}':`, err);
+ const msg = "Failed to load models. Click retry to try again.";
+ errorByTag.set(tag, msg);
+ inFlightByTag.delete(tag);
+ this.loading = false;
+ this.errorMessage = msg;
+ this.cdr.detectChanges();
+ },
+ });
+
+ inFlightByTag.set(tag, this.subscription);
+ }
+
+ // ── Pagination (client-side over the active list) ──
+
+ private get activeList(): HuggingFaceModelOption[] {
+ return this.filteredModels !== null ? this.filteredModels : this.allModels;
+ }
+
+ goToPage(page: number): void {
+ const list = this.activeList;
+ this.totalPages = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
+ this.currentPage = Math.min(page, this.totalPages - 1);
+ const start = this.currentPage * PAGE_SIZE;
+ this.pagedModels = list.slice(start, start + PAGE_SIZE);
+ this.cdr.detectChanges();
+ }
+
+ prevPage(): void {
+ if (this.currentPage > 0) {
+ this.goToPage(this.currentPage - 1);
+ }
+ }
+
+ nextPage(): void {
+ if (this.currentPage < this.totalPages - 1) {
+ this.goToPage(this.currentPage + 1);
+ }
+ }
+
+ get hasNextPage(): boolean {
+ return this.currentPage < this.totalPages - 1;
+ }
+
+ retryLoad(): void {
+ const tag = this.selectedTaskTag || "text-generation";
+ errorByTag.delete(tag);
+ this.loadAllModels();
+ }
+
+ // ── Search ──
+
+ private setupServerSearch(): void {
+ this.searchSubscription = this.searchSubject$
+ .pipe(
+ debounceTime(300),
+ switchMap(query => {
+ if (!query.trim()) {
+ this.searchLoading = false;
+ this.cdr.detectChanges();
+ return of(null);
+ }
+ const tag = this.selectedTaskTag || "text-generation";
+ this.searchLoading = true;
+ this.cdr.detectChanges();
+ return this.http
+ .get<
+ HuggingFaceModelOption[]
+
>(`${AppSettings.getApiEndpoint()}/huggingface/models?task=${encodeURIComponent(tag)}&search=${encodeURIComponent(query)}`)
+ .pipe(
+ catchError((err: unknown) => {
+ console.error("Server-side search failed:", err);
+ this.searchLoading = false;
+ this.cdr.detectChanges();
+ return of(null);
+ })
+ );
+ }),
+ takeUntil(this.destroy$)
+ )
+ .subscribe({
+ next: models => {
+ if (models === null) return;
+ this.searchLoading = false;
+ this.filteredModels = models;
+ this.goToPage(0);
+ },
+ });
+ }
+
+ onSearchInput(query: string): void {
+ this.searchText = query;
+ if (!query.trim()) {
+ this.filteredModels = null;
+ this.searchLoading = false;
+ // Cancel any in-flight server search via switchMap
+ this.searchSubject$.next("");
+ this.goToPage(0);
+ return;
+ }
+ if (this.truncated) {
+ // Server-side search — needed because local list is incomplete
+ this.searchSubject$.next(query);
+ } else {
+ // Local filter — full list is available
+ const lower = query.toLowerCase();
+ this.filteredModels = this.allModels.filter(m =>
m.id.toLowerCase().includes(lower));
+ this.goToPage(0);
+ }
+ }
+
+ clearSearch(): void {
+ this.searchText = "";
+ this.filteredModels = null;
+ this.searchLoading = false;
+ // Cancel any in-flight server search via switchMap
+ this.searchSubject$.next("");
+ this.goToPage(0);
+ }
+
+ get isSearching(): boolean {
+ return this.filteredModels !== null || this.searchLoading;
+ }
+
+ // ── Model selection ──
+
+ onModelSelected(modelId: string): void {
+ this.formControl.setValue(modelId);
+ }
+
+ // ── Private helpers ──
+
+ private getCurrentTaskTag(): string | undefined {
+ const fromModel = this.model?.task;
+ if (typeof fromModel === "string" && fromModel.trim().length > 0) {
+ return fromModel;
+ }
+ const fromParentControl = this.formControl?.parent?.get("task")?.value;
+ if (typeof fromParentControl === "string" &&
fromParentControl.trim().length > 0) {
+ return fromParentControl;
+ }
+ const fromFieldForm = this.field.form?.get("task")?.value;
+ if (typeof fromFieldForm === "string" && fromFieldForm.trim().length > 0) {
+ return fromFieldForm;
+ }
+ return undefined;
+ }
+
+ private persistTaskSelection(tag: string): void {
+ // 1. Update the backing model FIRST so expression functions read the new
value.
+ if (this.model) {
+ this.model.task = tag;
+ }
+
+ // 2. Update the hidden task form control. Using emitEvent: true (default)
+ // ensures formly picks up the change and re-evaluates all sibling
expressions.
+ const taskControlFromField = this.field.form?.get("task");
+ if (taskControlFromField) {
+ taskControlFromField.setValue(tag);
+ }
+
+ const taskControlFromParent = this.formControl?.parent?.get("task");
+ if (taskControlFromParent && taskControlFromParent !==
taskControlFromField) {
+ taskControlFromParent.setValue(tag);
+ }
+
+ // 3. Force formly to re-evaluate ALL field expressions (not just this
field's subtree).
+ // this.field is the modelId field; its parent covers all sibling
fields.
+ const rootField = this.field.parent ?? this.field;
+ this.field.options?.detectChanges?.(rootField);
+ }
+
+ private syncTaskSelection(tag: string, resetTaskSpecificFields: boolean):
void {
+ this.selectedTaskTag = tag;
+ if (resetTaskSpecificFields) {
+ this.resetTaskStateForFirstVisit(tag);
+ }
+ this.persistTaskSelection(tag);
+ this.refreshTaskScopedValidity();
+ }
+
+ private refreshTaskScopedValidity(): void {
+ const keys = [
+ "task",
+ "modelId",
+ "promptColumn",
+ "imageInput",
+ "audioInput",
+ "inputImageColumn",
+ "inputAudioColumn",
+ "candidateLabels",
+ "sentencesColumn",
+ "contextColumn",
+ "systemPrompt",
+ "maxNewTokens",
+ "temperature",
+ ];
+ for (const key of keys) {
+ const control = this.field.form?.get(key) ??
this.formControl?.parent?.get(key);
+ control?.updateValueAndValidity({ emitEvent: false });
+ }
+ this.field.form?.updateValueAndValidity({ emitEvent: false });
+ this.formControl?.parent?.updateValueAndValidity({ emitEvent: false });
+
+ // Emit a single value change after all fields are settled so the
+ // workflow action service picks up the new operator properties.
+ this.formControl?.parent?.updateValueAndValidity({ emitEvent: true });
+ }
+
+ private snapshotTaskState(tag: string): void {
+ if (!tag) {
+ return;
+ }
+ const snapshot: Partial<Record<(typeof this.taskScopedKeys)[number],
unknown>> = {};
+ for (const key of this.taskScopedKeys) {
+ snapshot[key] = this.readFieldValue(key);
+ }
+ this.taskStateByTag.set(tag, snapshot);
+ }
+
+ private restoreTaskState(tag: string): void {
+ const snapshot = this.taskStateByTag.get(tag);
+ if (!snapshot) {
+ return;
+ }
+ for (const key of this.taskScopedKeys) {
+ if (Object.prototype.hasOwnProperty.call(snapshot, key)) {
+ this.writeFieldValue(key, snapshot[key]);
+ }
+ }
+ this.refreshTaskScopedValidity();
+ }
+
+ private resetTaskStateForFirstVisit(tag: string): void {
+ if (this.taskStateByTag.has(tag)) {
+ return;
+ }
+ const defaults: Partial<Record<(typeof this.taskScopedKeys)[number],
unknown>> = {
+ modelId: "",
+ promptColumn: "",
+ imageInput: "",
+ audioInput: "",
+ inputImageColumn: "",
+ inputAudioColumn: "",
+ candidateLabels: "",
+ sentencesColumn: "",
+ contextColumn: "",
+ systemPrompt: "You are a helpful assistant.",
+ maxNewTokens: 256,
+ temperature: 0.7,
+ };
+ for (const key of this.taskScopedKeys) {
+ this.writeFieldValue(key, defaults[key] ?? "");
+ }
+ }
+
+ private readFieldValue(key: (typeof this.taskScopedKeys)[number]): unknown {
+ const control = this.field.form?.get(key) ??
this.formControl?.parent?.get(key);
+ if (control) {
+ return control.value;
+ }
+ return this.model?.[key];
+ }
+
+ private writeFieldValue(key: (typeof this.taskScopedKeys)[number], value:
unknown): void {
+ const control = this.field.form?.get(key) ??
this.formControl?.parent?.get(key);
+ if (control) {
+ control.setValue(value, { emitEvent: false });
+ control.markAsDirty();
+ control.updateValueAndValidity({ emitEvent: false });
+ }
+ if (this.model) {
+ (this.model as Record<string, unknown>)[key] = value;
+ }
+ }
+}