Copilot commented on code in PR #5567:
URL: https://github.com/apache/texera/pull/5567#discussion_r3444862235


##########
frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts:
##########
@@ -0,0 +1,648 @@
+/**
+ * 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 { Subject, Subscription } from "rxjs";
+import { 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;
+      // Poll for completion (the module-level cache will be set when done)
+      this.taskPollInterval = setInterval(() => {
+        if (cachedTaskOptions !== null || tasksFetchError !== null) {
+          clearInterval(this.taskPollInterval!);
+          this.taskPollInterval = null;
+          this.tasksLoading = false;
+          this.taskOptions = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+          if (tasksFetchError) this.tasksError = tasksFetchError;
+          this.cdr.detectChanges();
+        }
+      }, 200);
+      return;
+    }

Review Comment:
   The polling loop for an in-flight tasks fetch can run indefinitely if the 
instance that started the shared `tasksFetchSubscription` gets destroyed 
(takeUntil triggers) before setting `cachedTaskOptions`/`tasksFetchError`. In 
that case `tasksFetchSubscription` becomes null but the waiting instance keeps 
polling forever.
   
   Consider also breaking out when `tasksFetchSubscription` is reset and 
triggering a fresh fetch, so the UI doesn't get stuck in a loading state.



##########
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-tasks-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(null)"></i>

Review Comment:
   Clearing the selected model sets the FormControl value to `null`, but 
elsewhere this component and related form fields appear to use empty strings as 
the "unset" value. Using `null` can introduce inconsistent types and may break 
required/JSON-schema validation or downstream code expecting a string.



##########
frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.ts:
##########
@@ -0,0 +1,156 @@
+/**
+ * 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, OnDestroy, OnInit } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { FieldType, FieldTypeConfig } from "@ngx-formly/core";
+import { HttpClient } from "@angular/common/http";
+import { NzButtonModule } from "ng-zorro-antd/button";
+import { firstValueFrom } from "rxjs";
+import { AppSettings } from "../../../common/app-setting";
+
+interface HuggingFaceAudioUploadResponse {
+  path: string;
+  fileName: string;
+}
+
+@Component({
+  selector: "texera-hugging-face-audio-upload",
+  templateUrl: "./hugging-face-audio-upload.component.html",
+  styleUrls: ["./hugging-face-audio-upload.component.scss"],
+  imports: [CommonModule, NzButtonModule],
+})
+export class HuggingFaceAudioUploadComponent extends 
FieldType<FieldTypeConfig> implements OnInit, OnDestroy {
+  fileName = "";
+  errorMessage = "";
+  isUploading = false;
+  private localPreviewUrl = "";
+
+  ngOnInit(): void {
+    if (typeof this.formControl.value === "string" && 
this.formControl.value.trim().length > 0) {
+      this.fileName = this.getDisplayName(this.formControl.value);
+    }
+  }
+
+  constructor(private http: HttpClient) {
+    super();
+  }
+
+  get previewSrc(): string {
+    if (this.localPreviewUrl) {
+      return this.localPreviewUrl;
+    }
+    const value = this.formControl.value;
+    if (typeof value !== "string" || value.trim().length === 0) {
+      return "";
+    }
+    if (value.startsWith("data:audio/")) {
+      return value;
+    }
+    return 
`${AppSettings.getApiEndpoint()}/huggingface/audio-preview?path=${encodeURIComponent(value)}`;
+  }
+
+  ngOnDestroy(): void {
+    this.revokePreviewUrl();
+  }
+
+  async onFileSelected(event: Event): Promise<void> {
+    if (this.isUploading) {
+      return;
+    }
+    this.errorMessage = "";
+    const input = event.target as HTMLInputElement;
+    const file = input.files?.[0];
+
+    if (!file) {
+      return;
+    }
+    if (!file.type.startsWith("audio/")) {
+      this.errorMessage = "Choose an audio file.";
+      input.value = "";
+      return;
+    }
+    this.revokePreviewUrl();
+    this.localPreviewUrl = URL.createObjectURL(file);
+    this.isUploading = true;
+
+    try {
+      const response = await firstValueFrom(
+        this.http.post<HuggingFaceAudioUploadResponse>(
+          
`${AppSettings.getApiEndpoint()}/huggingface/upload-audio?filename=${encodeURIComponent(file.name)}`,
+          file,
+          {
+            headers: {
+              "Content-Type": "application/octet-stream",
+            },
+          }
+        )
+      );
+      this.fileName = response.fileName || file.name;
+      this.formControl.setValue(response.path);
+      if (typeof this.key === "string" && this.model) {
+        this.model[this.key] = response.path;
+      }
+      this.formControl.markAsDirty();
+      this.formControl.markAsTouched();
+      this.formControl.updateValueAndValidity();
+    } catch {

Review Comment:
   There is a race if the user clicks Clear while an upload is in-flight: the 
POST still resolves and `onFileSelected` will overwrite the cleared form 
control with the stale `response.path`. Since `clearAudio()` revokes the 
preview URL, you can use the preview URL as a simple in-flight token and ignore 
the response when the selection has changed.



##########
frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts:
##########
@@ -0,0 +1,648 @@
+/**
+ * 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 { Subject, Subscription } from "rxjs";
+import { 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;
+      // Poll for completion (the module-level cache will be set when done)
+      this.taskPollInterval = setInterval(() => {
+        if (cachedTaskOptions !== null || tasksFetchError !== null) {
+          clearInterval(this.taskPollInterval!);
+          this.taskPollInterval = null;
+          this.tasksLoading = false;
+          this.taskOptions = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+          if (tasksFetchError) this.tasksError = tasksFetchError;
+          this.cdr.detectChanges();
+        }
+      }, 200);
+      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;
+    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;
+      this.modelPollInterval = setInterval(() => {
+        if (allModelsByTag.has(tag) || errorByTag.has(tag)) {
+          clearInterval(this.modelPollInterval!);
+          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();
+          }
+        }
+      }, 200);
+      return;
+    }

Review Comment:
   The models polling loop can also run indefinitely if the original in-flight 
request for `tag` is cancelled (e.g., the component that initiated it is 
destroyed) before it populates `allModelsByTag`/`errorByTag`. `finalize` 
deletes `inFlightByTag[tag]`, but the waiting instance only checks the caches 
and will keep polling forever.
   
   Stopping polling when `inFlightByTag` is cleared and re-issuing the fetch 
avoids a stuck spinner and interval leak.



##########
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-tasks-error">
+    <span class="error-text">{{ tasksError }}</span>

Review Comment:
   The template uses `class="hf-tasks-error"`, but the stylesheet defines 
`.hf-error` / `.hf-loading` / etc. Since `.hf-tasks-error` isn't styled, the 
task-fetch error block won't match the rest of the component's error UI.



##########
frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts:
##########
@@ -0,0 +1,648 @@
+/**
+ * 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 { Subject, Subscription } from "rxjs";
+import { 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;
+      // Poll for completion (the module-level cache will be set when done)
+      this.taskPollInterval = setInterval(() => {
+        if (cachedTaskOptions !== null || tasksFetchError !== null) {
+          clearInterval(this.taskPollInterval!);
+          this.taskPollInterval = null;
+          this.tasksLoading = false;
+          this.taskOptions = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+          if (tasksFetchError) this.tasksError = tasksFetchError;
+          this.cdr.detectChanges();
+        }
+      }, 200);
+      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;
+    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;
+      this.modelPollInterval = setInterval(() => {
+        if (allModelsByTag.has(tag) || errorByTag.has(tag)) {
+          clearInterval(this.modelPollInterval!);
+          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();
+          }
+        }
+      }, 200);
+      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 => {
+          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)}`
+          );
+        }),
+        takeUntil(this.destroy$)
+      )
+      .subscribe({
+        next: models => {
+          this.searchLoading = false;
+          this.filteredModels = models;
+          this.goToPage(0);
+        },
+        error: (err: unknown) => {
+          console.error("Server-side search failed:", err);
+          this.searchLoading = false;
+          this.cdr.detectChanges();
+        },
+      });

Review Comment:
   `setupServerSearch()` subscribes to `searchSubject$`, but if any HTTP 
request errors, the subscription terminates (RxJS error completes the stream). 
After the first backend hiccup, further searches will stop working.
   
   Re-subscribing on error (or catching errors inside the pipe) keeps the 
search feature functional after transient failures.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to