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;
+    }
+  }
+}

Reply via email to