From 046734fd00a515ef028016614829eaa6f9a58aac Mon Sep 17 00:00:00 2001
From: Elliot <36275109+Falcons-Royale@users.noreply.github.com>
Date: Fri, 29 May 2026 12:43:36 -0700
Subject: [PATCH 01/57] feat(frontend): add HuggingFace task selector and model
browser component
Register the huggingface formly field type and declare HuggingFaceComponent
in AppModule. Provides a task dropdown, paginated model list with client-side
search, and per-task field state preservation when switching tasks.
---
frontend/src/app/app.module.ts | 2 +
.../src/app/common/formly/formly-config.ts | 2 +
.../hugging-face/hugging-face.component.html | 134 +++++
.../hugging-face/hugging-face.component.scss | 155 ++++++
.../hugging-face.component.spec.ts | 73 +++
.../hugging-face/hugging-face.component.ts | 525 ++++++++++++++++++
6 files changed, 891 insertions(+)
create mode 100644 frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
create mode 100644 frontend/src/app/workspace/component/hugging-face/hugging-face.component.scss
create mode 100644 frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
create mode 100644 frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 5af25b43866..bdebbfba2e5 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 ba80dc51f96..f385cf03591 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 00000000000..bd1fed4289e
--- /dev/null
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tasksError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading models...
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+ Selected:
+ {{ formControl.value }}
+
+
+
+
+
+ {{ isSearching ? 'No models found for "' + searchText + '".' : 'No models available.' }}
+
+
+
+
+ {{ model.id }}
+
+
+ {{ model.downloads | number }}
+
+
+ {{ model.likes | number }}
+
+
+
+
+
+
+
+
1" class="hf-pagination">
+
+ Page {{ currentPage + 1 }} of {{ totalPages }}
+
+
+
+
+
+
+
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 00000000000..70cf372296a
--- /dev/null
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.scss
@@ -0,0 +1,155 @@
+/**
+ * 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-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 00000000000..c60d92bc19b
--- /dev/null
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
@@ -0,0 +1,73 @@
+/**
+ * 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 {
+ HuggingFaceComponent,
+ HuggingFaceModelOption,
+ STATIC_TASK_OPTIONS,
+ invalidateHuggingFaceModelCache,
+} from "./hugging-face.component";
+
+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", () => {
+ // Just verify it doesn't throw — the function clears module-level Maps
+ 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);
+ });
+});
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 00000000000..9427fac8fea
--- /dev/null
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
@@ -0,0 +1,525 @@
+/**
+ * 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 { Subscription } from "rxjs";
+
+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" },
+];
+
+// Keep legacy export for any other code that imports it
+export const TASK_TAG_MAP: Record = {};
+for (const { tag, label } of STATIC_TASK_OPTIONS) {
+ TASK_TAG_MAP[label] = tag;
+}
+export const TASK_NAMES = STATIC_TASK_OPTIONS.map(t => t.label);
+
+const PAGE_SIZE = 50;
+
+// ── Module-level caches (reused across component instances) ──
+const allModelsByTag: Map = new Map();
+const inFlightByTag: Map = new Map();
+const errorByTag: Map = 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();
+ 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 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>>();
+ // ── 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;
+
+ // ── Search state (client-side filtering over ALL models) ──
+ searchText = "";
+ private filteredModels: HuggingFaceModelOption[] | null = null;
+
+ private subscription: Subscription | 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();
+ // 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.
+ setTimeout(() => this.syncTaskSelection(this.getCurrentTaskTag() ?? this.selectedTaskTag, false), 0);
+ }
+
+ ngOnDestroy(): void {
+ this.subscription?.unsubscribe();
+ }
+
+ // ── 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)
+ const poll = setInterval(() => {
+ if (cachedTaskOptions !== null || tasksFetchError !== null) {
+ clearInterval(poll);
+ 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(`${AppSettings.getApiEndpoint()}/huggingface/tasks`)
+ .subscribe({
+ next: tasks => {
+ tasksFetchSubscription = null;
+ cachedTaskOptions = tasks.length > 0 ? tasks : STATIC_TASK_OPTIONS;
+ this.taskOptions = cachedTaskOptions;
+ this.tasksLoading = false;
+ this.cdr.detectChanges();
+ },
+ error: err => {
+ 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.goToPage(0);
+ return;
+ }
+
+ // Previous error
+ if (errorByTag.has(tag)) {
+ this.errorMessage = errorByTag.get(tag)!;
+ this.allModels = [];
+ this.pagedModels = [];
+ this.totalPages = 0;
+ 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(
+ `${AppSettings.getApiEndpoint()}/huggingface/models?task=${encodeURIComponent(tag)}`
+ )
+ .subscribe({
+ next: models => {
+ allModelsByTag.set(tag, models);
+ inFlightByTag.delete(tag);
+ this.loading = false;
+ this.allModels = models;
+ this.goToPage(0);
+ },
+ error: err => {
+ 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 (client-side filter over ALL cached models) ──
+
+ onSearchInput(query: string): void {
+ this.searchText = query;
+ if (!query.trim()) {
+ this.filteredModels = null;
+ } else {
+ 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.goToPage(0);
+ }
+
+ get isSearching(): boolean {
+ return this.filteredModels !== null;
+ }
+
+ // ── 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> = {};
+ 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> = {
+ 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)[key] = value;
+ }
+ }
+}
From f933fbe3bf8193aa97c65cc7083b7864bcdf61b2 Mon Sep 17 00:00:00 2001
From: Elliot <36275109+Falcons-Royale@users.noreply.github.com>
Date: Mon, 8 Jun 2026 14:27:59 -0700
Subject: [PATCH 02/57] fix(frontend): add explicit type annotations to rxjs
error callbacks
The rxjs/no-implicit-any-catch ESLint rule requires explicit type
annotations on error callbacks in .subscribe() calls.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../component/hugging-face/hugging-face.component.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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
index 9427fac8fea..21a79b93bc3 100644
--- a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
@@ -213,7 +213,7 @@ export class HuggingFaceComponent extends FieldType implements
this.tasksLoading = false;
this.cdr.detectChanges();
},
- error: err => {
+ error: (err: unknown) => {
console.error("Failed to load HuggingFace tasks:", err);
tasksFetchSubscription = null;
tasksFetchError = "Could not load tasks from Hugging Face. Using default list.";
@@ -297,7 +297,7 @@ export class HuggingFaceComponent extends FieldType implements
this.allModels = models;
this.goToPage(0);
},
- error: err => {
+ 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);
From 1f5a0f4df57bca8338b6021c6e5a0746f586b4cf Mon Sep 17 00:00:00 2001
From: Elliot <36275109+Falcons-Royale@users.noreply.github.com>
Date: Mon, 8 Jun 2026 14:42:35 -0700
Subject: [PATCH 03/57] style(frontend): format HuggingFace component with
prettier
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../hugging-face/hugging-face.component.html | 118 +++++++++++++-----
.../hugging-face/hugging-face.component.ts | 22 +++-
2 files changed, 107 insertions(+), 33 deletions(-)
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
index bd1fed4289e..833231f3ab7 100644
--- a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
@@ -25,7 +25,7 @@
(ngModelChange)="onTaskSelected($event)"
nzPlaceHolder="Select a task"
[nzLoading]="tasksLoading"
- style="width: 100%; margin-bottom: 4px;">
+ style="width: 100%; margin-bottom: 4px">
-
+
{{ tasksError }}
-
-