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