diff --git a/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala b/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala index 8111e442901..70684730cfa 100644 --- a/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala +++ b/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala @@ -35,7 +35,7 @@ import java.util.Optional object UserAuthenticator extends Authenticator[JwtContext, SessionUser] with LazyLogging { override def authenticate(context: JwtContext): Optional[SessionUser] = { try { - Optional.of(JwtParser.claimsToSessionUser(context.getJwtClaims)) + JwtParser.claimsToOptionalSessionUser(context.getJwtClaims) } catch { case e: Exception => logger.error("Failed to authenticate the JwtContext", e) diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala index bb139e7093a..8a4225df21e 100644 --- a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala +++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala @@ -38,7 +38,7 @@ object JwtParser extends LazyLogging { /** Verify and parse a Bearer token string. */ def parseToken(token: String): Optional[SessionUser] = { try { - Optional.of(claimsToSessionUser(JwtAuth.jwtConsumer.processToClaims(token))) + claimsToOptionalSessionUser(JwtAuth.jwtConsumer.processToClaims(token)) } catch { case _: UnresolvableKeyException => logger.error("Invalid JWT Signature") @@ -49,6 +49,19 @@ object JwtParser extends LazyLogging { } } + /** Convert already-verified claims to a [[SessionUser]], returning empty when + * the required Texera custom claims are missing or malformed. + */ + def claimsToOptionalSessionUser(claims: JwtClaims): Optional[SessionUser] = { + try { + Optional.of(claimsToSessionUser(claims)) + } catch { + case e: IllegalArgumentException => + logger.error(s"Invalid JWT claims: ${e.getMessage}") + Optional.empty() + } + } + /** Build a [[SessionUser]] from already-verified claims. Used by both * [[parseToken]] (which verifies then calls this) and amber's * `UserAuthenticator` (which the toastshaman filter calls after its own @@ -59,8 +72,12 @@ object JwtParser extends LazyLogging { val email = claims.getClaimValue("email", classOf[String]) // jose4j returns Long after JSON round-trip but the original setClaim // call writes Integer; widen via Number to handle both cases. - val userId = claims.getClaimValue("userId", classOf[Number]).intValue() - val role = UserRoleEnum.valueOf(claims.getClaimValue("role").asInstanceOf[String]) + val userId = Option(claims.getClaimValue("userId", classOf[Number])) + .map(_.intValue()) + .getOrElse(throw new IllegalArgumentException("JWT claim 'userId' is required.")) + val roleName = Option(claims.getClaimValue("role", classOf[String])) + .getOrElse(throw new IllegalArgumentException("JWT claim 'role' is required.")) + val role = UserRoleEnum.valueOf(roleName) val googleId = claims.getClaimValue("googleId", classOf[String]) val googleAvatar = claims.getClaimValue("googleAvatar", classOf[String]) val user = new User( diff --git a/common/auth/src/test/scala/org/apache/texera/auth/JwtParserSpec.scala b/common/auth/src/test/scala/org/apache/texera/auth/JwtParserSpec.scala index dc91de4d645..aa2e0c0423c 100644 --- a/common/auth/src/test/scala/org/apache/texera/auth/JwtParserSpec.scala +++ b/common/auth/src/test/scala/org/apache/texera/auth/JwtParserSpec.scala @@ -74,6 +74,18 @@ class JwtParserSpec extends AnyFlatSpec with Matchers { u.getGoogleAvatar shouldBe "avatar-blob" } + it should "return empty when already-verified claims are missing userId" in { + val claims = buildClaims() + claims.unsetClaim("userId") + JwtParser.claimsToOptionalSessionUser(claims).isPresent shouldBe false + } + + it should "return empty when already-verified claims are missing role" in { + val claims = buildClaims() + claims.unsetClaim("role") + JwtParser.claimsToOptionalSessionUser(claims).isPresent shouldBe false + } + "JwtParser.parseToken" should "return empty on a structurally invalid token" in { JwtParser.parseToken("not-a-real-jwt").isPresent shouldBe false } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/ImageTaskCodegen.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/ImageTaskCodegen.scala index c5c4a2669c4..5a5ee0a937e 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/ImageTaskCodegen.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/ImageTaskCodegen.scala @@ -90,26 +90,16 @@ object ImageTaskCodegen extends TaskCodegen { | use_raw_binary_body = True | raw_binary_headers = image_headers | elif task == "zero-shot-image-classification": - | # Zero-shot requires the caller to supply candidate labels. - | # We reuse the prompt column as a comma-separated label list so - | # the task is shippable without a dedicated operator field. - | # TODO: replace with a first-class `candidateLabels` field once - | # the property panel supports task-specific inputs. - | # - | # Fail fast if usable labels can't be derived. Both modes lead to - | # a meaningless inference call: - | # 1. Empty prompt column -> labels = [] - | # The HF API rejects candidate_labels: [] with an opaque 400. - | # 2. Missing prompt column -> upstream sets prompt_value - | # to the fallback "What is shown in this image?", which has - | # no comma, so labels collapses to a single nonsense entry. - | # Zero-shot classification needs >= 2 candidate labels to be - | # meaningful — surface a configuration error in both cases. - | labels = [s.strip() for s in prompt_value.split(",") if s.strip()] + | # Prefer the dedicated candidateLabels property; fall back to + | # the prompt column for backward compatibility. + | label_source = (self.CANDIDATE_LABELS or "").strip() if self.CANDIDATE_LABELS else "" + | if not label_source and prompt_value: + | label_source = prompt_value + | labels = [s.strip() for s in label_source.split(",") if s.strip()] | if len(labels) < 2: | raise ValueError( | "zero-shot-image-classification requires at least 2 candidate " - | "labels: provide a comma-separated list in the prompt column." + | "labels: provide a comma-separated list in the Candidate Labels field." | ) | payload = { | "inputs": self._image_input_as_base64(current_image_bytes), diff --git a/frontend/src/app/common/util/media-type.util.spec.ts b/frontend/src/app/common/util/media-type.util.spec.ts new file mode 100644 index 00000000000..457f05a39d4 --- /dev/null +++ b/frontend/src/app/common/util/media-type.util.spec.ts @@ -0,0 +1,148 @@ +/** + * 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 { isAudioUrl, isImageUrl, isVideoUrl } from "./media-type.util"; + +describe("isImageUrl", () => { + it("should return true for data:image/ data URLs", () => { + expect(isImageUrl("data:image/png;base64,abc123")).toBe(true); + expect(isImageUrl("data:image/jpeg;base64,abc123")).toBe(true); + expect(isImageUrl("data:image/webp;base64,abc123")).toBe(true); + }); + + it("should return true for common image file extensions", () => { + expect(isImageUrl("https://example.com/photo.png")).toBe(true); + expect(isImageUrl("https://example.com/photo.jpg")).toBe(true); + expect(isImageUrl("https://example.com/photo.jpeg")).toBe(true); + expect(isImageUrl("https://example.com/photo.gif")).toBe(true); + expect(isImageUrl("https://example.com/photo.webp")).toBe(true); + }); + + it("should be case-insensitive for extensions", () => { + expect(isImageUrl("https://example.com/photo.PNG")).toBe(true); + expect(isImageUrl("https://example.com/photo.JPG")).toBe(true); + }); + + it("should return true for URLs with query strings", () => { + expect(isImageUrl("https://example.com/photo.png?v=1")).toBe(true); + }); + + it("should return false for audio and video URLs", () => { + expect(isImageUrl("data:audio/mp3;base64,abc")).toBe(false); + expect(isImageUrl("data:video/mp4;base64,abc")).toBe(false); + expect(isImageUrl("https://example.com/clip.mp4")).toBe(false); + }); + + it("should return false for plain text strings", () => { + expect(isImageUrl("hello world")).toBe(false); + expect(isImageUrl("")).toBe(false); + }); +}); + +describe("isAudioUrl", () => { + it("should return true for data:audio/ data URLs", () => { + expect(isAudioUrl("data:audio/mp3;base64,abc123")).toBe(true); + expect(isAudioUrl("data:audio/wav;base64,abc123")).toBe(true); + }); + + it("should return true for common audio file extensions", () => { + expect(isAudioUrl("https://example.com/clip.mp3")).toBe(true); + expect(isAudioUrl("https://example.com/clip.wav")).toBe(true); + expect(isAudioUrl("https://example.com/clip.ogg")).toBe(true); + expect(isAudioUrl("https://example.com/clip.m4a")).toBe(true); + expect(isAudioUrl("https://example.com/clip.flac")).toBe(true); + }); + + it("should be case-insensitive for extensions", () => { + expect(isAudioUrl("https://example.com/clip.MP3")).toBe(true); + expect(isAudioUrl("https://example.com/clip.WAV")).toBe(true); + }); + + it("should return true for URLs with query strings", () => { + expect(isAudioUrl("https://example.com/clip.mp3?token=xyz")).toBe(true); + }); + + it("should return false for image and video URLs", () => { + expect(isAudioUrl("data:image/png;base64,abc")).toBe(false); + expect(isAudioUrl("data:video/mp4;base64,abc")).toBe(false); + expect(isAudioUrl("https://example.com/photo.png")).toBe(false); + }); + + it("should return false for plain text strings", () => { + expect(isAudioUrl("hello world")).toBe(false); + expect(isAudioUrl("")).toBe(false); + }); +}); + +describe("isVideoUrl", () => { + it("should return true for data:video/ data URLs", () => { + expect(isVideoUrl("data:video/mp4;base64,abc123")).toBe(true); + expect(isVideoUrl("data:video/webm;base64,abc123")).toBe(true); + }); + + it("should return true for common video file extensions", () => { + expect(isVideoUrl("https://example.com/clip.mp4")).toBe(true); + expect(isVideoUrl("https://example.com/clip.webm")).toBe(true); + expect(isVideoUrl("https://example.com/clip.ogv")).toBe(true); + }); + + it("should return true for fal.media CDN URLs", () => { + expect(isVideoUrl("https://v3b.fal.media/files/abc123/output.mp4")).toBe(true); + }); + + it("should be case-insensitive for extensions", () => { + expect(isVideoUrl("https://example.com/clip.MP4")).toBe(true); + expect(isVideoUrl("https://example.com/clip.WEBM")).toBe(true); + }); + + it("should return true for URLs with query strings", () => { + expect(isVideoUrl("https://example.com/clip.mp4?t=5")).toBe(true); + }); + + it("should return false for image and audio URLs", () => { + expect(isVideoUrl("data:image/png;base64,abc")).toBe(false); + expect(isVideoUrl("data:audio/mp3;base64,abc")).toBe(false); + expect(isVideoUrl("https://example.com/photo.jpg")).toBe(false); + }); + + it("should return false for plain text strings", () => { + expect(isVideoUrl("hello world")).toBe(false); + expect(isVideoUrl("")).toBe(false); + }); + + it("should return false for non-string types", () => { + expect(isVideoUrl(null as unknown as string)).toBe(false); + expect(isVideoUrl(undefined as unknown as string)).toBe(false); + expect(isVideoUrl(42 as unknown as string)).toBe(false); + }); +}); + +describe("non-string type guard (shared)", () => { + it("isAudioUrl should return false for non-string types", () => { + expect(isAudioUrl(null as unknown as string)).toBe(false); + expect(isAudioUrl(undefined as unknown as string)).toBe(false); + expect(isAudioUrl(true as unknown as string)).toBe(false); + }); + + it("isImageUrl should return false for non-string types", () => { + expect(isImageUrl(null as unknown as string)).toBe(false); + expect(isImageUrl(undefined as unknown as string)).toBe(false); + expect(isImageUrl([] as unknown as string)).toBe(false); + }); +}); diff --git a/frontend/src/app/common/util/media-type.util.ts b/frontend/src/app/common/util/media-type.util.ts new file mode 100644 index 00000000000..d60446573a8 --- /dev/null +++ b/frontend/src/app/common/util/media-type.util.ts @@ -0,0 +1,37 @@ +/** + * 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. + */ + +export function isVideoUrl(value: string): boolean { + if (typeof value !== "string") return false; + return ( + value.match(/\.(mp4|webm|ogv)(\?.*)?$/i) !== null || + value.startsWith("data:video/") || + value.startsWith("https://v3b.fal.media/files/") + ); +} + +export function isAudioUrl(value: string): boolean { + if (typeof value !== "string") return false; + return value.match(/\.(mp3|wav|ogg|m4a|flac)(\?.*)?$/i) !== null || value.startsWith("data:audio/"); +} + +export function isImageUrl(value: string): boolean { + if (typeof value !== "string") return false; + return value.match(/\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i) !== null || value.startsWith("data:image/"); +} diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts index bb7ebeb619a..5b1cb5561fa 100644 --- a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts +++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts @@ -17,10 +17,11 @@ * under the License. */ -import { TestBed } from "@angular/core/testing"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { FormControl } from "@angular/forms"; import { FieldTypeConfig } from "@ngx-formly/core"; +import { By } from "@angular/platform-browser"; import { AppSettings } from "../../../common/app-setting"; import { HuggingFaceAudioUploadComponent } from "./hugging-face-audio-upload.component"; @@ -121,6 +122,21 @@ describe("HuggingFaceAudioUploadComponent", () => { formControl.setValue(" "); expect(component.previewSrc).toBe(""); }); + + it("should return localPreviewUrl when a file has been selected but upload is in progress", async () => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview"); + vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined); + + const file = new File(["audio"], "clip.wav", { type: "audio/wav" }); + const uploadPromise = component.onFileSelected(makeFileEvent(file)); + + expect(component.previewSrc).toBe("blob:fake-preview"); + + httpTestingController + .expectOne(r => r.url.includes("/huggingface/upload-audio")) + .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); + await uploadPromise; + }); }); // ── File upload ── @@ -239,6 +255,43 @@ describe("HuggingFaceAudioUploadComponent", () => { req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); await uploadPromise; }); + + it("should discard stale upload response when cleared during upload", async () => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview"); + vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined); + + const file = new File(["audio"], "clip.wav", { type: "audio/wav" }); + const { event, input } = makeFileEventWithInput(file); + const uploadPromise = component.onFileSelected(event); + + component.clearAudio(input); + + httpTestingController + .expectOne(r => r.url.includes("/huggingface/upload-audio")) + .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); + await uploadPromise; + + expect(formControl.value).toBe(""); + expect(component.fileName).toBe(""); + }); + + it("should discard stale upload error when cleared during upload", async () => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview"); + vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined); + + const file = new File(["audio"], "clip.wav", { type: "audio/wav" }); + const { event, input } = makeFileEventWithInput(file); + const uploadPromise = component.onFileSelected(event); + + component.clearAudio(input); + + httpTestingController + .expectOne(r => r.url.includes("/huggingface/upload-audio")) + .error(new ProgressEvent("error")); + await uploadPromise; + + expect(component.errorMessage).toBe(""); + }); }); // ── clearAudio ── @@ -433,6 +486,22 @@ describe("HuggingFaceAudioUploadComponent", () => { expect(() => component.ngOnDestroy()).not.toThrow(); }); + it("should revoke localPreviewUrl on destroy when upload preview exists", async () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview"); + + const file = new File(["audio"], "clip.wav", { type: "audio/wav" }); + const uploadPromise = component.onFileSelected(makeFileEvent(file)); + + httpTestingController + .expectOne(r => r.url.includes("/huggingface/upload-audio")) + .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); + await uploadPromise; + + component.ngOnDestroy(); + expect(revokeSpy).toHaveBeenCalledWith("blob:fake-preview"); + }); + it("should revoke localPreviewUrl on destroy", async () => { const blobUrl = "blob:http://localhost/local-preview"; vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl); @@ -481,4 +550,119 @@ describe("HuggingFaceAudioUploadComponent", () => { httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); }); }); + + // ── Template rendering ── + + describe("template rendering", () => { + let templateFixture: ComponentFixture; + let templateComponent: HuggingFaceAudioUploadComponent; + + beforeEach(() => { + templateFixture = TestBed.createComponent(HuggingFaceAudioUploadComponent); + templateComponent = templateFixture.componentInstance; + const fc = new FormControl(""); + templateComponent.field = { formControl: fc, key: "audioInput", model: {} } as unknown as FieldTypeConfig; + }); + + it("should render the file input", () => { + templateFixture.detectChanges(); + expect(templateFixture.debugElement.query(By.css("input[type='file']"))).toBeTruthy(); + }); + + it("should not render the preview section when there is no audio", () => { + templateFixture.detectChanges(); + expect(templateFixture.debugElement.query(By.css(".hf-audio-preview"))).toBeNull(); + }); + + it("should render the preview section when formControl has a data:audio value", () => { + templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); + templateFixture.detectChanges(); + expect(templateFixture.debugElement.query(By.css(".hf-audio-preview"))).toBeTruthy(); + expect(templateFixture.debugElement.query(By.css("audio"))).toBeTruthy(); + }); + + it("should bind audio [src] to previewSrc for a data:audio value", () => { + templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); + templateFixture.detectChanges(); + const audio = templateFixture.debugElement.query(By.css("audio")).nativeElement as HTMLAudioElement; + expect(audio.src).toContain("data:audio/wav"); + }); + + it("should show fileName in the preview meta", () => { + templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); + templateFixture.detectChanges(); // ngOnInit runs here + templateComponent.fileName = "clip.wav"; + templateFixture.detectChanges(); + const span = templateFixture.debugElement.query(By.css(".hf-audio-meta span")); + expect((span.nativeElement as HTMLElement).textContent?.trim()).toBe("clip.wav"); + }); + + it("should fall back to 'Selected audio' when fileName is empty and preview is shown", () => { + templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); + templateComponent.fileName = ""; + templateFixture.detectChanges(); + const span = templateFixture.debugElement.query(By.css(".hf-audio-meta span")); + expect((span.nativeElement as HTMLElement).textContent?.trim()).toBe("Selected audio"); + }); + + it("should show the Uploading status span when isUploading is true and preview is visible", () => { + templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); + templateComponent.isUploading = true; + templateFixture.detectChanges(); + const status = templateFixture.debugElement.query(By.css(".hf-audio-status")); + expect(status).toBeTruthy(); + expect((status.nativeElement as HTMLElement).textContent?.trim()).toBe("Uploading..."); + }); + + it("should hide the Uploading status span when isUploading is false", () => { + templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); + templateComponent.isUploading = false; + templateFixture.detectChanges(); + expect(templateFixture.debugElement.query(By.css(".hf-audio-status"))).toBeNull(); + }); + + it("should disable the Clear button when isUploading is true", () => { + templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); + templateComponent.isUploading = true; + templateFixture.detectChanges(); + const btn = templateFixture.debugElement.query(By.css("button[nz-button]")).nativeElement as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it("should enable the Clear button when isUploading is false", () => { + templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); + templateComponent.isUploading = false; + templateFixture.detectChanges(); + const btn = templateFixture.debugElement.query(By.css("button[nz-button]")).nativeElement as HTMLButtonElement; + expect(btn.disabled).toBe(false); + }); + + it("should disable the file input when isUploading is true", () => { + templateComponent.isUploading = true; + templateFixture.detectChanges(); + const input = templateFixture.debugElement.query(By.css("input[type='file']")).nativeElement as HTMLInputElement; + expect(input.disabled).toBe(true); + }); + + it("should show the error message when errorMessage is set", () => { + templateComponent.errorMessage = "Could not upload this audio file."; + templateFixture.detectChanges(); + const errorEl = templateFixture.debugElement.query(By.css(".hf-audio-error")); + expect(errorEl).toBeTruthy(); + expect((errorEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Could not upload this audio file."); + }); + + it("should not render the error div when errorMessage is empty", () => { + templateFixture.detectChanges(); + expect(templateFixture.debugElement.query(By.css(".hf-audio-error"))).toBeNull(); + }); + + it("should call clearAudio when the Clear button is clicked", () => { + const clearSpy = vi.spyOn(templateComponent, "clearAudio"); + templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); + templateFixture.detectChanges(); + templateFixture.debugElement.query(By.css("button[nz-button]")).triggerEventHandler("click", null); + expect(clearSpy).toHaveBeenCalled(); + }); + }); }); 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 b44624721ac..777111cc3ea 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 @@ -69,13 +69,8 @@ (ngModelChange)="onSearchInput($event)" /> - { initComponent(); expect(() => component.ngOnDestroy()).not.toThrow(); }); + + it("should clear taskPollInterval on destroy", fakeAsync(() => { + // Create a second fixture so the first one is still fetching tasks when this one inits + invalidateHuggingFaceModelCache(); + const fixture2 = TestBed.createComponent(HuggingFaceComponent); + const component2 = fixture2.componentInstance; + const { field: field2 } = buildFieldWithFormGroup("text-generation"); + component2.field = field2; + fixture2.detectChanges(); // triggers ngOnInit → loadTasks() (sets tasksFetchSubscription) + http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("")); + + // Now init our main component — tasks in flight, so it enters poll path + const { field } = buildFieldWithFormGroup("text-generation"); + component.field = field; + fixture.detectChanges(); + flushIconRequests(); + + // comp enters poll path for tasks — taskPollInterval is set + expect((component as any).taskPollInterval).not.toBeNull(); + + // Destroy while poll is still running + expect(() => component.ngOnDestroy()).not.toThrow(); + + // Clean up fixture2 + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.match(req => req.url.startsWith(`${API}/huggingface/models`)).forEach(req => req.flush([])); + fixture2.destroy(); + discardPeriodicTasks(); + })); + }); + + // ── Cached error states ── + + describe("cached error states", () => { + it("should show cached tasks error for a second component without re-fetching", () => { + const { field: field1 } = buildFieldWithFormGroup(); + component.field = field1; + fixture.detectChanges(); + flushIconRequests(); + + // comp1: tasks error, models succeed → models are cached + http.expectOne(`${API}/huggingface/tasks`).error(new ProgressEvent("error")); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush([]); + + expect(component.tasksError).toBeTruthy(); + + const fixture2 = TestBed.createComponent(HuggingFaceComponent); + const component2 = fixture2.componentInstance; + const { field: field2 } = buildFieldWithFormGroup(); + component2.field = field2; + fixture2.detectChanges(); + flushIconRequests(); + + // Tasks error cached (no new tasks request); models cached (no new model request) + + expect(component2.tasksError).toBeTruthy(); + expect(component2.taskOptions).toEqual(STATIC_TASK_OPTIONS); + expect(component2.tasksLoading).toBe(false); + + fixture2.destroy(); + }); + + it("should show cached model error for a second component without re-fetching models", () => { + const { field: field1 } = buildFieldWithFormGroup("text-generation"); + component.field = field1; + fixture.detectChanges(); + flushIconRequests(); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).error(new ProgressEvent("error")); + + expect(component.errorMessage).toBeTruthy(); + + const fixture2 = TestBed.createComponent(HuggingFaceComponent); + const component2 = fixture2.componentInstance; + const { field: field2 } = buildFieldWithFormGroup("text-generation"); + component2.field = field2; + fixture2.detectChanges(); + flushIconRequests(); + + // Tasks cached; model error cached — no new HTTP requests at all + expect(component2.errorMessage).toBeTruthy(); + expect(component2.loading).toBe(false); + expect(component2.pagedModels.length).toBe(0); + + fixture2.destroy(); + }); + }); + + // ── Polling ── + + describe("polling", () => { + it("should enter model poll path (loading=true) when another instance is already fetching models", fakeAsync(() => { + // comp1 fetches tasks first, but leaves models in flight + const { field: field1 } = buildFieldWithFormGroup("text-generation"); + component.field = field1; + fixture.detectChanges(); + flushIconRequests(); + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + // models still in flight for comp1 + + // comp2: tasks cached, but models are in-flight → enters model poll path + const fixture2 = TestBed.createComponent(HuggingFaceComponent); + const component2 = fixture2.componentInstance; + const { field: field2 } = buildFieldWithFormGroup("text-generation"); + component2.field = field2; + fixture2.detectChanges(); + http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("")); + + expect(component2.loading).toBe(true); // poll path entered + + // Clean up: flush comp1's pending model request, discard comp2's poll + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(3)); + fixture2.destroy(); + discardPeriodicTasks(); + })); + + it("should enter task poll path (tasksLoading=true) when another instance is already fetching tasks", fakeAsync(() => { + // comp1 starts fetching tasks — leave in flight + const { field: field1 } = buildFieldWithFormGroup("text-generation"); + component.field = field1; + fixture.detectChanges(); + flushIconRequests(); + // tasks and models both in flight for comp1 + + // comp2: tasksFetchSubscription not null → enters task poll path + const fixture2 = TestBed.createComponent(HuggingFaceComponent); + const component2 = fixture2.componentInstance; + const { field: field2 } = buildFieldWithFormGroup("text-generation"); + component2.field = field2; + fixture2.detectChanges(); + http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("")); + + expect(component2.tasksLoading).toBe(true); // poll path entered + + // Clean up: flush comp1's pending requests, discard comp2's polls + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.match(req => req.url.startsWith(`${API}/huggingface/models`)).forEach(req => req.flush([])); + fixture2.destroy(); + discardPeriodicTasks(); + })); + + it("should clear error and re-fetch models on retryLoad", () => { + initComponent(); + + component.onTaskSelected("image-classification"); + http.expectOne(`${API}/huggingface/models?task=image-classification`).error(new ProgressEvent("error")); + + expect(component.errorMessage).toBeTruthy(); + + component.retryLoad(); + http.expectOne(`${API}/huggingface/models?task=image-classification`).flush(buildModels(4, "img")); + + expect(component.errorMessage).toBeNull(); + expect(component.pagedModels.length).toBe(4); + }); }); }); 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 f84d4f940f6..f634c66a2a1 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 @@ -252,15 +252,13 @@ export class HuggingFaceComponent extends FieldType implements tasksFetchSubscription = this.http .get(`${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; } }) ) + // eslint-disable-next-line rxjs-angular/prefer-takeuntil .subscribe({ next: tasks => { tasksFetchSubscription = null; @@ -296,8 +294,6 @@ export class HuggingFaceComponent extends FieldType implements this.restoreTaskState(tag); this.searchText = ""; this.filteredModels = null; - // Cancel any in-flight server search for the previous task - this.searchSubject$.next(""); this.loadAllModels(); } @@ -379,14 +375,8 @@ export class HuggingFaceComponent extends FieldType implements { 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); - } - }) + finalize(() => inFlightByTag.delete(tag)), + takeUntil(this.destroy$) ) .subscribe({ next: resp => { @@ -395,7 +385,6 @@ export class HuggingFaceComponent extends FieldType implements truncatedByTag.add(tag); } allModelsByTag.set(tag, models); - inFlightByTag.delete(tag); this.loading = false; this.truncated = truncatedByTag.has(tag); this.allModels = models; @@ -405,7 +394,6 @@ export class HuggingFaceComponent extends FieldType implements 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(); @@ -459,11 +447,6 @@ export class HuggingFaceComponent extends FieldType implements .pipe( debounceTime(300), switchMap(query => { - if (!query.trim()) { - this.searchLoading = false; - this.cdr.detectChanges(); - return of(null); - } const tag = this.selectedTaskTag || "text-generation"; this.searchLoading = true; this.cdr.detectChanges(); @@ -497,8 +480,6 @@ export class HuggingFaceComponent extends FieldType implements if (!query.trim()) { this.filteredModels = null; this.searchLoading = false; - // Cancel any in-flight server search via switchMap - this.searchSubject$.next(""); this.goToPage(0); return; } @@ -517,8 +498,6 @@ export class HuggingFaceComponent extends FieldType implements this.searchText = ""; this.filteredModels = null; this.searchLoading = false; - // Cancel any in-flight server search via switchMap - this.searchSubject$.next(""); this.goToPage(0); } diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.html b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.html index 3cdd88911af..de255386aef 100644 --- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.html +++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.html @@ -103,6 +103,88 @@ *ngIf="formlyFields && formlyFormGroup" [formGroup]="formlyFormGroup" class="property-editor-form"> +
+

Task Preview:

+
+ + + Hugging Face task preview + +
+
{{ preview.title }}
+
{{ preview.body }}
+
+ {{ preview.outputBody }} +
+
+
+ +
+
{{ preview.title }}
+
+ {{ preview.inputLabel }} + + {{ preview.outputLabel }} +
+
+ {{ preview.body }} +
+
+ {{ preview.outputBody }} +
+
+ {{ pill }} +
+
+
+
{ expect(descEl).toBeNull(); }); }); + + // ── HuggingFace task-aware visibility tests ── + + it("should return null huggingFaceTaskPreview for non-HF operators", () => { + workflowActionService.addOperator(mockScanPredicate, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, mockScanPredicate.operatorID, true), + }); + fixture.detectChanges(); + expect(component.huggingFaceTaskPreview).toBeNull(); + }); + + it("should return a task preview for HuggingFace operator with a known task", () => { + workflowActionService.addOperator(mockHuggingFacePredicate, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, mockHuggingFacePredicate.operatorID, true), + }); + fixture.detectChanges(); + const preview = component.huggingFaceTaskPreview; + expect(preview).toBeTruthy(); + expect(preview!.kind).toBe("text"); + expect(preview!.title).toBe("Text generation preview"); + }); + + it("should return a fallback preview for HuggingFace operator with an unknown task", () => { + const hfPredicate = { + ...cloneDeep(mockHuggingFacePredicate), + operatorProperties: { task: "some-unknown-task", modelId: "" }, + }; + workflowActionService.addOperator(hfPredicate, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, hfPredicate.operatorID, true), + }); + fixture.detectChanges(); + const preview = component.huggingFaceTaskPreview; + expect(preview).toBeTruthy(); + expect(preview!.kind).toBe("text"); + expect(preview!.title).toBe("Some Unknown Task"); + }); + + it("should return image kind preview for image-classification task", () => { + const hfPredicate = { + ...cloneDeep(mockHuggingFacePredicate), + operatorProperties: { task: "image-classification", modelId: "" }, + }; + workflowActionService.addOperator(hfPredicate, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, hfPredicate.operatorID, true), + }); + fixture.detectChanges(); + const preview = component.huggingFaceTaskPreview; + expect(preview).toBeTruthy(); + expect(preview!.kind).toBe("image"); + }); + + it("should return audio kind preview for text-to-speech task", () => { + const hfPredicate = { + ...cloneDeep(mockHuggingFacePredicate), + operatorProperties: { task: "text-to-speech", modelId: "" }, + }; + workflowActionService.addOperator(hfPredicate, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, hfPredicate.operatorID, true), + }); + fixture.detectChanges(); + const preview = component.huggingFaceTaskPreview; + expect(preview).toBeTruthy(); + expect(preview!.kind).toBe("audio"); + }); + + it("should return video kind preview for text-to-video task", () => { + const hfPredicate = { + ...cloneDeep(mockHuggingFacePredicate), + operatorProperties: { task: "text-to-video", modelId: "" }, + }; + workflowActionService.addOperator(hfPredicate, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, hfPredicate.operatorID, true), + }); + fixture.detectChanges(); + const preview = component.huggingFaceTaskPreview; + expect(preview).toBeTruthy(); + expect(preview!.kind).toBe("video"); + }); + + it("should return null preview when HuggingFace task is empty", () => { + const hfPredicate = { ...cloneDeep(mockHuggingFacePredicate), operatorProperties: { task: "", modelId: "" } }; + workflowActionService.addOperator(hfPredicate, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, hfPredicate.operatorID, true), + }); + fixture.detectChanges(); + expect(component.huggingFaceTaskPreview).toBeNull(); + }); + + // ── HuggingFace field visibility and validator tests ── + + function getHfField(key: string): FormlyFieldConfig | undefined { + return component.formlyFields?.[0]?.fieldGroup?.find(f => f.key === key); + } + + let currentTask: string = ""; + + let hfOperatorCounter = 0; + + function initHfOperator(task: string): void { + currentTask = task; + hfOperatorCounter++; + const pred = { + ...cloneDeep(mockHuggingFacePredicate), + operatorID: `hf-test-${hfOperatorCounter}`, + operatorProperties: { task, modelId: "org/model" }, + }; + workflowActionService.addOperator(pred, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, pred.operatorID, true), + }); + fixture.detectChanges(); + } + + function evalHide(field: FormlyFieldConfig | undefined): boolean { + if (!field || !field.expressions) return false; + const hideFn = (field.expressions as Record)["hide"]; + if (!hideFn) return !!field.hide; + // Provide model context so getSelectedTask can find the task + const fieldWithModel = { ...field, model: { task: currentTask } } as FormlyFieldConfig; + return hideFn(fieldWithModel); + } + + it("should hide imageInput for text-generation task", () => { + initHfOperator("text-generation"); + expect(evalHide(getHfField("imageInput"))).toBe(true); + }); + + it("should show imageInput for image-classification task", () => { + initHfOperator("image-classification"); + expect(evalHide(getHfField("imageInput"))).toBe(false); + }); + + it("should hide audioInput for text-generation task", () => { + initHfOperator("text-generation"); + expect(evalHide(getHfField("audioInput"))).toBe(true); + }); + + it("should show audioInput for automatic-speech-recognition task", () => { + initHfOperator("automatic-speech-recognition"); + expect(evalHide(getHfField("audioInput"))).toBe(false); + }); + + it("should hide promptColumn for image-only tasks", () => { + initHfOperator("image-classification"); + expect(evalHide(getHfField("promptColumn"))).toBe(true); + }); + + it("should hide promptColumn for audio-only tasks", () => { + initHfOperator("automatic-speech-recognition"); + expect(evalHide(getHfField("promptColumn"))).toBe(true); + }); + + it("should show promptColumn for text-generation task", () => { + initHfOperator("text-generation"); + expect(evalHide(getHfField("promptColumn"))).toBe(false); + }); + + it("should show systemPrompt only for text-generation", () => { + initHfOperator("text-generation"); + expect(evalHide(getHfField("systemPrompt"))).toBe(false); + + initHfOperator("image-classification"); + expect(evalHide(getHfField("systemPrompt"))).toBe(true); + }); + + it("should show contextColumn only for question-answering", () => { + initHfOperator("question-answering"); + expect(evalHide(getHfField("contextColumn"))).toBe(false); + + initHfOperator("text-generation"); + expect(evalHide(getHfField("contextColumn"))).toBe(true); + }); + + it("should show candidateLabels only for classification tasks", () => { + initHfOperator("zero-shot-classification"); + expect(evalHide(getHfField("candidateLabels"))).toBe(false); + + initHfOperator("text-generation"); + expect(evalHide(getHfField("candidateLabels"))).toBe(true); + }); + + it("requiredPromptColumn validator should pass when not a prompt-required task", () => { + initHfOperator("image-classification"); + const field = getHfField("promptColumn"); + const validator = field?.validators?.["requiredPromptColumn"]; + expect(validator).toBeDefined(); + const mockField = { ...field, model: { task: "image-classification", promptColumn: "" } } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(true); + }); + + it("requiredPromptColumn validator should fail when prompt-required task has no column", () => { + initHfOperator("text-generation"); + const field = getHfField("promptColumn"); + const validator = field?.validators?.["requiredPromptColumn"]; + expect(validator).toBeDefined(); + const mockField = { ...field, model: { task: "text-generation", promptColumn: "" } } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(false); + }); + + it("requiredImageInput validator should pass when not an image task", () => { + initHfOperator("text-generation"); + const field = getHfField("imageInput"); + const validator = field?.validators?.["requiredImageInput"]; + expect(validator).toBeDefined(); + const mockField = { ...field, model: { task: "text-generation", imageInput: "" } } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(true); + }); + + it("requiredAudioInput validator should pass when not an audio task", () => { + initHfOperator("text-generation"); + const field = getHfField("audioInput"); + const validator = field?.validators?.["requiredAudioInput"]; + expect(validator).toBeDefined(); + const mockField = { ...field, model: { task: "text-generation", audioInput: "" } } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(true); + }); + + // ── Additional field visibility tests ── + + it("should show sentencesColumn only for sentence-similarity and text-ranking", () => { + initHfOperator("sentence-similarity"); + expect(evalHide(getHfField("sentencesColumn"))).toBe(false); + + initHfOperator("text-ranking"); + expect(evalHide(getHfField("sentencesColumn"))).toBe(false); + + initHfOperator("text-generation"); + expect(evalHide(getHfField("sentencesColumn"))).toBe(true); + }); + + it("should show inputImageColumn for image tasks", () => { + initHfOperator("image-classification"); + expect(evalHide(getHfField("inputImageColumn"))).toBe(false); + + initHfOperator("text-generation"); + expect(evalHide(getHfField("inputImageColumn"))).toBe(true); + }); + + it("should show inputAudioColumn for audio tasks", () => { + initHfOperator("automatic-speech-recognition"); + expect(evalHide(getHfField("inputAudioColumn"))).toBe(false); + + initHfOperator("text-generation"); + expect(evalHide(getHfField("inputAudioColumn"))).toBe(true); + }); + + it("should hide maxNewTokens and temperature for non-text-generation tasks", () => { + initHfOperator("image-classification"); + expect(evalHide(getHfField("maxNewTokens"))).toBe(true); + expect(evalHide(getHfField("temperature"))).toBe(true); + + initHfOperator("text-generation"); + expect(evalHide(getHfField("maxNewTokens"))).toBe(false); + expect(evalHide(getHfField("temperature"))).toBe(false); + }); + + it("should show candidateLabels for zero-shot-image-classification", () => { + initHfOperator("zero-shot-image-classification"); + expect(evalHide(getHfField("candidateLabels"))).toBe(false); + }); + + // ── Additional validator edge-case tests ── + + it("requiredPromptColumn validator should pass when prompt-required task has a column", () => { + initHfOperator("text-generation"); + const field = getHfField("promptColumn"); + const validator = field?.validators?.["requiredPromptColumn"]; + const mockField = { ...field, model: { task: "text-generation", promptColumn: "text_col" } } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(true); + }); + + it("requiredImageInput validator should fail when image task has no image and no column", () => { + initHfOperator("image-classification"); + const field = getHfField("imageInput"); + const validator = field?.validators?.["requiredImageInput"]; + const mockField = { + ...field, + model: { task: "image-classification", imageInput: "", inputImageColumn: "" }, + } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(false); + }); + + it("requiredImageInput validator should pass when image task has inputImageColumn set", () => { + initHfOperator("image-classification"); + const field = getHfField("imageInput"); + const validator = field?.validators?.["requiredImageInput"]; + const mockField = { + ...field, + model: { task: "image-classification", imageInput: "", inputImageColumn: "img_col" }, + } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(true); + }); + + it("requiredImageInput validator should pass when image task has image uploaded", () => { + initHfOperator("image-classification"); + const field = getHfField("imageInput"); + const validator = field?.validators?.["requiredImageInput"]; + const mockField = { + ...field, + model: { task: "image-classification", imageInput: "/tmp/img.png", inputImageColumn: "" }, + } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(true); + }); + + it("requiredAudioInput validator should fail when audio task has no audio and no column", () => { + initHfOperator("automatic-speech-recognition"); + const field = getHfField("audioInput"); + const validator = field?.validators?.["requiredAudioInput"]; + const mockField = { + ...field, + model: { task: "automatic-speech-recognition", audioInput: "", inputAudioColumn: "" }, + } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(false); + }); + + it("requiredAudioInput validator should pass when audio task has inputAudioColumn set", () => { + initHfOperator("automatic-speech-recognition"); + const field = getHfField("audioInput"); + const validator = field?.validators?.["requiredAudioInput"]; + const mockField = { + ...field, + model: { task: "automatic-speech-recognition", audioInput: "", inputAudioColumn: "audio_col" }, + } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(true); + }); + + it("requiredAudioInput validator should pass when audio task has audio uploaded", () => { + initHfOperator("automatic-speech-recognition"); + const field = getHfField("audioInput"); + const validator = field?.validators?.["requiredAudioInput"]; + const mockField = { + ...field, + model: { task: "automatic-speech-recognition", audioInput: "/tmp/clip.wav", inputAudioColumn: "" }, + } as FormlyFieldConfig; + expect(validator!.expression(null as any, mockField)).toBe(true); + }); + + // ── HuggingFace task preview additional tests ── + + it("should return image kind preview for visual-question-answering task", () => { + const hfPredicate = { + ...cloneDeep(mockHuggingFacePredicate), + operatorProperties: { task: "visual-question-answering", modelId: "" }, + }; + workflowActionService.addOperator(hfPredicate, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, hfPredicate.operatorID, true), + }); + fixture.detectChanges(); + const preview = component.huggingFaceTaskPreview; + expect(preview).toBeTruthy(); + expect(preview!.kind).toBe("image"); + }); + + it("should return text kind preview for question-answering task", () => { + const hfPredicate = { + ...cloneDeep(mockHuggingFacePredicate), + operatorProperties: { task: "question-answering", modelId: "" }, + }; + workflowActionService.addOperator(hfPredicate, mockPoint); + component.ngOnChanges({ + currentOperatorId: new SimpleChange(undefined, hfPredicate.operatorID, true), + }); + fixture.detectChanges(); + const preview = component.huggingFaceTaskPreview; + expect(preview).toBeTruthy(); + expect(preview!.kind).toBe("text"); + }); + + it("should hide the task field for HuggingFace operators", () => { + initHfOperator("text-generation"); + const taskField = getHfField("task"); + expect(taskField?.hide).toBe(true); + }); + + // ── Real template rendering ── + // A nested describe that resets the module so overrideComponent is not applied, + // allowing the actual HTML template to render. + + describe("real template rendering", () => { + let realFixture: ComponentFixture; + let realComponent: OperatorPropertyEditFrameComponent; + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + providers: [ + WorkflowActionService, + { provide: OperatorMetadataService, useClass: StubOperatorMetadataService }, + { provide: ComputingUnitStatusService, useClass: MockComputingUnitStatusService }, + DatePipe, + ...commonTestProviders, + ], + imports: [ + OperatorPropertyEditFrameComponent, + BrowserAnimationsModule, + FormsModule, + FormlyModule.forRoot(TEXERA_FORMLY_CONFIG), + FormlyNgZorroAntdModule, + ReactiveFormsModule, + HttpClientTestingModule, + ], + }).compileComponents(); + + realFixture = TestBed.createComponent(OperatorPropertyEditFrameComponent); + realComponent = realFixture.componentInstance; + }); + + it("should render the title section when editingTitle is false and formTitle is set", () => { + realComponent.editingTitle = false; + realComponent.formTitle = "My Operator"; + realFixture.detectChanges(); + const titleEl = realFixture.debugElement.query(By.css("#formly-title")); + expect(titleEl).toBeTruthy(); + const h3 = realFixture.debugElement.query(By.css("h3.texera-workspace-property-editor-title")); + expect(h3).toBeTruthy(); + expect((h3.nativeElement as HTMLElement).textContent?.trim()).toBe("My Operator"); + }); + + it("should not render the h3 title when formTitle is undefined", () => { + realComponent.editingTitle = false; + realComponent.formTitle = undefined; + realFixture.detectChanges(); + expect(realFixture.debugElement.query(By.css("h3.texera-workspace-property-editor-title"))).toBeNull(); + }); + + it("should disable the edit button when interactive is false", () => { + realComponent.editingTitle = false; + realComponent.interactive = false; + realFixture.detectChanges(); + const btn = realFixture.debugElement.query(By.css("#formly-title button")).nativeElement as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it("should enable the edit button when interactive is true", () => { + realComponent.editingTitle = false; + realComponent.interactive = true; + realFixture.detectChanges(); + const btn = realFixture.debugElement.query(By.css("#formly-title button")).nativeElement as HTMLButtonElement; + expect(btn.disabled).toBe(false); + }); + + it("should hide the title section when editingTitle is true", () => { + realComponent.editingTitle = true; + realFixture.detectChanges(); + expect(realFixture.debugElement.query(By.css("#formly-title"))).toBeNull(); + }); + + it("should hide the customName div when editingTitle is false", () => { + realComponent.editingTitle = false; + realFixture.detectChanges(); + const customName = realFixture.debugElement.query(By.css("#customName")); + expect(customName).toBeTruthy(); + expect((customName.nativeElement as HTMLElement).hidden).toBe(true); + }); + + it("should show the customName div when editingTitle is true", () => { + realComponent.editingTitle = true; + realFixture.detectChanges(); + const customName = realFixture.debugElement.query(By.css("#customName")); + expect(customName).toBeTruthy(); + expect((customName.nativeElement as HTMLElement).hidden).toBe(false); + }); + + it("should show the PythonLambdaFunction icon when currentOperatorId includes PythonLambdaFunction", () => { + realComponent.editingTitle = false; + realComponent.currentOperatorId = "PythonLambdaFunction-abc"; + realFixture.detectChanges(); + expect(realFixture.debugElement.query(By.css(".question-circle-button"))).toBeTruthy(); + }); + + it("should not show the PythonLambdaFunction icon for other operator types", () => { + realComponent.editingTitle = false; + realComponent.currentOperatorId = "ScanSource-abc"; + realFixture.detectChanges(); + expect(realFixture.debugElement.query(By.css(".question-circle-button"))).toBeNull(); + }); + + it("should not render the form section when formlyFields is undefined", () => { + realFixture.detectChanges(); + expect(realFixture.debugElement.query(By.css(".property-editor-form"))).toBeNull(); + }); + + it("should render the operator version span", () => { + realComponent.operatorVersion = "v1.2.3"; + realFixture.detectChanges(); + const versionEl = realFixture.debugElement.query(By.css(".operator-version span")); + expect(versionEl).toBeTruthy(); + expect((versionEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Operator Version: v1.2.3"); + }); + + // ── HF task preview template nodes ── + // formlyFields=[] and formlyFormGroup=new FormGroup({}) satisfy the outer + // *ngIf without triggering Formly rendering; the getter is mocked directly. + + function setupPreview(preview: object): void { + vi.spyOn(realComponent, "huggingFaceTaskPreview", "get").mockReturnValue(preview as any); + realComponent.formlyFields = []; + realComponent.formlyFormGroup = new FormGroup({}); + realComponent.formData = {}; + realFixture.detectChanges(); + } + + it("should not render the HF preview card when huggingFaceTaskPreview is null", () => { + vi.spyOn(realComponent, "huggingFaceTaskPreview", "get").mockReturnValue(null); + realComponent.formlyFields = []; + realComponent.formlyFormGroup = new FormGroup({}); + realComponent.formData = {}; + realFixture.detectChanges(); + expect(realFixture.debugElement.query(By.css(".hf-task-preview"))).toBeNull(); + }); + + it("should render a video element for kind='video'", () => { + setupPreview({ kind: "video", title: "Video preview", assetSrc: "assets/sample.mp4" }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview"))).toBeTruthy(); + expect(realFixture.debugElement.query(By.css("video.hf-task-preview-media"))).toBeTruthy(); + }); + + it("should bind [src] and [muted] on the video element", () => { + setupPreview({ kind: "video", title: "Video preview", assetSrc: "assets/sample.mp4" }); + const video = realFixture.debugElement.query(By.css("video")).nativeElement as HTMLVideoElement; + expect(video.muted).toBe(true); + }); + + it("should render an img element for kind='image'", () => { + setupPreview({ kind: "image", title: "Image preview", assetSrc: "assets/sample.png" }); + expect(realFixture.debugElement.query(By.css("img.hf-task-preview-media"))).toBeTruthy(); + }); + + it("should render an audio element for kind='audio'", () => { + setupPreview({ kind: "audio", title: "Audio preview", assetSrc: "assets/sample.mp3" }); + expect(realFixture.debugElement.query(By.css("audio.hf-task-preview-audio"))).toBeTruthy(); + }); + + it("should render the text surface for kind='text'", () => { + setupPreview({ kind: "text", title: "Text preview", body: "Some body" }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-text-surface"))).toBeTruthy(); + const titleEl = realFixture.debugElement.query(By.css(".hf-task-preview-text-title")); + expect((titleEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Text preview"); + const bodyEl = realFixture.debugElement.query(By.css(".hf-task-preview-text-body")); + expect((bodyEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Some body"); + }); + + it("should render outputBody in the text surface when present", () => { + setupPreview({ kind: "text", title: "T", body: "B", outputBody: "Output" }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-text-output"))).toBeTruthy(); + }); + + it("should not render outputBody in the text surface when absent", () => { + setupPreview({ kind: "text", title: "T", body: "B" }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-text-output"))).toBeNull(); + }); + + it("should render the flow section when inputLabel and outputLabel are set", () => { + setupPreview({ kind: "image", title: "T", inputLabel: "Input", outputLabel: "Output" }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-flow"))).toBeTruthy(); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-arrow"))).toBeTruthy(); + const chips = realFixture.debugElement.queryAll(By.css(".hf-task-preview-chip")); + expect(chips.length).toBeGreaterThanOrEqual(2); + }); + + it("should not render the flow section when inputLabel and outputLabel are absent", () => { + setupPreview({ kind: "image", title: "T" }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-flow"))).toBeNull(); + }); + + it("should render the description for non-text kinds with body", () => { + setupPreview({ kind: "image", title: "T", body: "Some description" }); + const desc = realFixture.debugElement.query(By.css(".hf-task-preview-description")); + expect(desc).toBeTruthy(); + expect((desc.nativeElement as HTMLElement).textContent?.trim()).toBe("Some description"); + }); + + it("should not render the description for kind='text'", () => { + setupPreview({ kind: "text", title: "T", body: "B" }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-description"))).toBeNull(); + }); + + it("should render outputBody in meta for non-text kinds", () => { + setupPreview({ kind: "image", title: "T", outputBody: "Result" }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-output"))).toBeTruthy(); + }); + + it("should not render outputBody in meta for kind='text'", () => { + setupPreview({ kind: "text", title: "T", body: "B", outputBody: "Result" }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-output"))).toBeNull(); + }); + + it("should render pills when pills array is non-empty", () => { + setupPreview({ kind: "text", title: "T", pills: ["NLP", "Classification"] }); + const pillsContainer = realFixture.debugElement.query(By.css(".hf-task-preview-pills")); + expect(pillsContainer).toBeTruthy(); + const pills = realFixture.debugElement.queryAll(By.css(".hf-task-preview-pill")); + expect(pills.length).toBe(2); + expect((pills[0].nativeElement as HTMLElement).textContent?.trim()).toBe("NLP"); + }); + + it("should not render pills when pills array is empty", () => { + setupPreview({ kind: "text", title: "T", pills: [] }); + expect(realFixture.debugElement.query(By.css(".hf-task-preview-pills"))).toBeNull(); + }); + }); }); diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts index 2512fecdacb..1d86a61ae72 100644 --- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts +++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts @@ -61,7 +61,7 @@ import * as Y from "yjs"; import { OperatorSchema } from "src/app/workspace/types/operator-schema.interface"; import { AttributeType, PortSchema } from "../../../types/workflow-compiling.interface"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; -import { NgIf } from "@angular/common"; +import { NgFor, NgIf, NgSwitch, NgSwitchCase } from "@angular/common"; import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; import { NzButtonComponent } from "ng-zorro-antd/button"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; @@ -100,6 +100,9 @@ Quill.register("modules/cursors", QuillCursors); styleUrls: ["./operator-property-edit-frame.component.scss"], imports: [ NgIf, + NgFor, + NgSwitch, + NgSwitchCase, NzSpaceCompactItemDirective, NzButtonComponent, ɵNzTransitionPatchDirective, @@ -167,6 +170,273 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On // used to tear down subscriptions that takeUntil(teardownObservable) private teardownObservable: Subject = new Subject(); + readonly huggingFaceTaskPreviewSamples: Record< + string, + { + kind: "image" | "video" | "audio" | "text"; + inputLabel?: string; + outputLabel?: string; + title?: string; + body?: string; + outputBody?: string; + pills?: string[]; + assetSrc?: string; + } + > = { + "text-to-image": { + kind: "image", + inputLabel: "Text prompt", + outputLabel: "Generated image", + title: "Comic-style city action scene", + body: "Prompt becomes a generated image preview.", + assetSrc: "assets/sample-image.png", + }, + "image-to-image": { + kind: "image", + inputLabel: "Source image", + outputLabel: "Edited image", + title: "Image transformation preview", + body: "Image input produces a modified image result.", + assetSrc: "assets/sample-image.png", + }, + "text-to-video": { + kind: "video", + inputLabel: "Text prompt", + outputLabel: "Generated video", + title: "Prompt-based motion preview", + body: "Prompt becomes a generated video clip.", + assetSrc: "assets/sample-video.mp4", + }, + "image-to-video": { + kind: "video", + inputLabel: "Source image", + outputLabel: "Animated clip", + title: "Image animation preview", + body: "Image input becomes a short generated video.", + assetSrc: "assets/sample-video.mp4", + }, + "text-to-speech": { + kind: "audio", + inputLabel: "Text input", + outputLabel: "Spoken audio", + title: "Speech synthesis preview", + body: "Text becomes an audio clip the user can play back.", + assetSrc: "assets/sample-audio.wav", + }, + "automatic-speech-recognition": { + kind: "audio", + inputLabel: "Audio input", + outputLabel: "Transcript text", + title: "Speech-to-text preview", + body: "Uploaded audio is transcribed into plain text.", + assetSrc: "assets/sample-audio.wav", + }, + "audio-classification": { + kind: "audio", + inputLabel: "Audio input", + outputLabel: "Labels and scores", + title: "Audio tagging preview", + body: "Uploaded audio returns classification labels.", + assetSrc: "assets/sample-audio.wav", + }, + "image-text-to-text": { + kind: "image", + inputLabel: "Image + text prompt", + outputLabel: "Generated text", + title: "Image-text-to-text preview", + body: "The model reads an image and a text prompt to produce a response.", + outputBody: "The image shows a superhero leaping across rooftops at sunset.", + assetSrc: "assets/sample-image.png", + }, + "image-classification": { + kind: "image", + inputLabel: "Image input", + outputLabel: "Predicted labels", + title: "Image classification preview", + body: "The model assigns labels such as superhero, city, or action scene.", + assetSrc: "assets/sample-image.png", + pills: ["superhero", "cityscape", "action"], + }, + "object-detection": { + kind: "image", + inputLabel: "Image input", + outputLabel: "Detected objects", + title: "Object detection preview", + body: "The model returns detected objects and bounding boxes.", + assetSrc: "assets/sample-image.png", + pills: ["person", "building", "sky"], + }, + "image-segmentation": { + kind: "image", + inputLabel: "Image input", + outputLabel: "Segmented regions", + title: "Segmentation preview", + body: "The model separates the image into labeled regions.", + assetSrc: "assets/sample-image.png", + pills: ["foreground", "background", "subject"], + }, + "image-to-text": { + kind: "image", + inputLabel: "Image input", + outputLabel: "Caption text", + title: "Captioning preview", + body: "The model turns an uploaded image into a textual description.", + outputBody: "A superhero leaps above a dense downtown skyline at sunset.", + assetSrc: "assets/sample-image.png", + }, + "visual-question-answering": { + kind: "image", + inputLabel: "Image + question", + outputLabel: "Answer text", + title: "Visual question answering preview", + body: "The model reads the image and answers the user question.", + outputBody: "Spider-Man is jumping over a city skyline.", + assetSrc: "assets/sample-image.png", + }, + "document-question-answering": { + kind: "image", + inputLabel: "Document image + question", + outputLabel: "Answer text", + title: "Document QA preview", + body: "The model extracts answers from a document image.", + outputBody: "Invoice total: $248.90", + assetSrc: "assets/sample-image.png", + }, + "zero-shot-image-classification": { + kind: "image", + inputLabel: "Image + candidate labels", + outputLabel: "Ranked labels", + title: "Zero-shot image labeling preview", + body: "Candidate labels are scored against the uploaded image.", + assetSrc: "assets/sample-image.png", + pills: ["superhero", "sports", "travel"], + }, + "text-generation": { + kind: "text", + inputLabel: "Prompt", + outputLabel: "Generated text", + title: "Text generation preview", + body: "Write a short action scene set above a crowded city skyline.", + outputBody: "The hero vaulted between rooftops as the city lights came alive below.", + }, + "text-classification": { + kind: "text", + inputLabel: "Text input", + outputLabel: "Predicted label", + title: "Text classification preview", + body: "This launch update sounds confident and customer-focused.", + pills: ["positive", "announcement"], + }, + "token-classification": { + kind: "text", + inputLabel: "Text input", + outputLabel: "Tagged spans", + title: "Token classification preview", + body: "Peter Parker visited New York yesterday.", + pills: ["Peter Parker: PERSON", "New York: LOCATION"], + }, + "question-answering": { + kind: "text", + inputLabel: "Question + context", + outputLabel: "Answer span", + title: "Question answering preview", + body: "Question: Who led the launch?\nContext: Maya led the launch while Jordan handled analytics.", + outputBody: "Maya", + }, + "table-question-answering": { + kind: "text", + inputLabel: "Question + table", + outputLabel: "Answer", + title: "Table QA preview", + body: "Question: Which month had the highest revenue?", + outputBody: "March", + }, + "zero-shot-classification": { + kind: "text", + inputLabel: "Text + candidate labels", + outputLabel: "Ranked labels", + title: "Zero-shot classification preview", + body: "We need to accelerate onboarding for enterprise customers.", + pills: ["business", "operations", "support"], + }, + translation: { + kind: "text", + inputLabel: "Source text", + outputLabel: "Translated text", + title: "Translation preview", + body: "Good morning, thanks for joining the call.", + outputBody: "Buenos dias, gracias por unirte a la llamada.", + }, + summarization: { + kind: "text", + inputLabel: "Long text", + outputLabel: "Summary", + title: "Summarization preview", + body: "A long project update is compressed into a short summary.", + outputBody: "The team shipped the release, fixed two regressions, and started the next milestone.", + }, + "feature-extraction": { + kind: "text", + inputLabel: "Text input", + outputLabel: "Embedding/vector output", + title: "Feature extraction preview", + body: "Input text is converted into a numeric representation.", + pills: ["0.12", "-0.08", "0.44", "..."], + }, + "fill-mask": { + kind: "text", + inputLabel: "Masked sentence", + outputLabel: "Top completions", + title: "Fill-mask preview", + body: "The hero saved the [MASK].", + pills: ["city", "day", "crowd"], + }, + "sentence-similarity": { + kind: "text", + inputLabel: "Source + candidate sentences", + outputLabel: "Similarity scores", + title: "Sentence similarity preview", + body: "Compare one sentence against several alternatives.", + pills: ["0.93", "0.61", "0.22"], + }, + "text-ranking": { + kind: "text", + inputLabel: "Query + candidate texts", + outputLabel: "Ranked results", + title: "Text ranking preview", + body: "Candidate passages are ordered by relevance to the query.", + pills: ["doc_2", "doc_5", "doc_1"], + }, + }; + + get huggingFaceTaskPreview(): { + kind: "image" | "video" | "audio" | "text"; + inputLabel?: string; + outputLabel?: string; + title?: string; + body?: string; + outputBody?: string; + pills?: string[]; + assetSrc?: string; + } | null { + if (!this.isHuggingFaceOperator()) { + return null; + } + const task = this.formData?.["task"]; + if (typeof task !== "string" || task.trim().length === 0) { + return null; + } + return ( + this.huggingFaceTaskPreviewSamples[task] ?? { + kind: "text", + inputLabel: "Task input", + outputLabel: "Task output", + title: this.formatTaskTitle(task), + body: "This task transforms the provided input into a model response.", + } + ); + } + constructor( private formlyJsonschema: FormlyJsonschema, private workflowActionService: WorkflowActionService, @@ -237,6 +507,20 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On }); } + private isHuggingFaceOperator(): boolean { + if (!this.currentOperatorId) return false; + const graph = this.workflowActionService.getTexeraGraph(); + if (!graph.hasOperator(this.currentOperatorId)) return false; + return graph.getOperator(this.currentOperatorId).operatorType === "HuggingFace"; + } + + private formatTaskTitle(task: string): string { + return task + .split("-") + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + } + async ngOnDestroy() { // await this.checkAndSavePreset(); this.teardownObservable.complete(); @@ -541,6 +825,203 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On mappedField.type = "inputautocomplete"; } + if (mappedField.key === "huggingFaceModel") { + mappedField.type = "huggingface"; + } + + if (mappedField.key === "modelId" && this.currentOperatorSchema?.operatorType === "HuggingFace") { + mappedField.type = "huggingface"; + } + + if (mappedField.key === "task" && this.currentOperatorSchema?.operatorType === "HuggingFace") { + mappedField.hide = true; + } + + // ── Dynamic field visibility for HuggingFace based on selected task ── + if (this.currentOperatorSchema?.operatorType === "HuggingFace" && typeof mappedField.key === "string") { + const hfKey = mappedField.key; + const imageOnlyTasks = ["image-classification", "object-detection", "image-segmentation", "image-to-text"]; + const imageInputTasks = [ + ...imageOnlyTasks, + "visual-question-answering", + "document-question-answering", + "zero-shot-image-classification", + "image-text-to-text", + "image-to-image", + ]; + const audioInputTasks = ["automatic-speech-recognition", "audio-classification"]; + const promptRequiredTasks = [ + "text-generation", + "text-classification", + "token-classification", + "question-answering", + "table-question-answering", + "zero-shot-classification", + "translation", + "summarization", + "feature-extraction", + "fill-mask", + "sentence-similarity", + "text-ranking", + "visual-question-answering", + "document-question-answering", + "zero-shot-image-classification", + ]; + const getSelectedTask = (field: FormlyFieldConfig): string | undefined => { + const fromForm = field.form?.get("task")?.value ?? field.formControl?.parent?.get("task")?.value; + if (typeof fromForm === "string" && fromForm.trim().length > 0) { + return fromForm; + } + const fromModel = field.model?.task; + if (typeof fromModel === "string" && fromModel.trim().length > 0) { + return fromModel; + } + return undefined; + }; + if (hfKey === "imageInput") { + mappedField.type = "huggingface-image-upload"; + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t === undefined || !imageInputTasks.includes(t); + }, + }; + mappedField.validators = { + ...mappedField.validators, + requiredImageInput: { + expression: (_control: AbstractControl, field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + if (t === undefined || !imageInputTasks.includes(t)) { + return true; + } + const inputImageCol = field.model?.inputImageColumn; + if (typeof inputImageCol === "string" && inputImageCol.trim().length > 0) { + return true; + } + const value = field.formControl?.value ?? field.model?.imageInput; + return typeof value === "string" && value.trim().length > 0; + }, + message: () => "Upload an image or select an Input Image Column for this task.", + }, + }; + mappedField.validation = { + ...mappedField.validation, + show: true, + }; + } + if (hfKey === "audioInput") { + mappedField.type = "huggingface-audio-upload"; + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t === undefined || !audioInputTasks.includes(t); + }, + }; + mappedField.validators = { + ...mappedField.validators, + requiredAudioInput: { + expression: (_control: AbstractControl, field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + if (t === undefined || !audioInputTasks.includes(t)) { + return true; + } + const inputAudioCol = field.model?.inputAudioColumn; + if (typeof inputAudioCol === "string" && inputAudioCol.trim().length > 0) { + return true; + } + const value = field.formControl?.value ?? field.model?.audioInput; + return typeof value === "string" && value.trim().length > 0; + }, + message: () => "Upload audio or select an Input Audio Column for this task.", + }, + }; + mappedField.validation = { + ...mappedField.validation, + show: true, + }; + } + if (hfKey === "inputImageColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t === undefined || !imageInputTasks.includes(t); + }, + }; + } + if (hfKey === "inputAudioColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t === undefined || !audioInputTasks.includes(t); + }, + }; + } + if (hfKey === "promptColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t !== undefined && (imageOnlyTasks.includes(t) || audioInputTasks.includes(t)); + }, + }; + mappedField.validators = { + ...mappedField.validators, + requiredPromptColumn: { + expression: (_control: AbstractControl, field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + if (t === undefined || !promptRequiredTasks.includes(t)) { + return true; + } + const value = field.formControl?.value ?? field.model?.promptColumn; + return typeof value === "string" && value.trim().length > 0; + }, + message: () => "Select a prompt column for this task.", + }, + }; + mappedField.validation = { + ...mappedField.validation, + show: true, + }; + } + if (["systemPrompt", "maxNewTokens", "temperature"].includes(hfKey)) { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t !== "text-generation"; + }, + }; + } + if (hfKey === "contextColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => getSelectedTask(field) !== "question-answering", + }; + } + if (hfKey === "candidateLabels") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t !== "zero-shot-classification" && t !== "zero-shot-image-classification"; + }, + }; + } + if (hfKey === "sentencesColumn") { + mappedField.expressions = { + ...mappedField.expressions, + hide: (field: FormlyFieldConfig) => { + const t = getSelectedTask(field); + return t !== "sentence-similarity" && t !== "text-ranking"; + }, + }; + } + } + if (mappedField.key === "datasetVersionPath") { mappedField.type = "datasetversionselector"; } diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html index 5f8fbe4674a..71e45335917 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html +++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html @@ -18,7 +18,56 @@ --> diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts index 8d7e1cc8f99..9a251277236 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts +++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts @@ -22,11 +22,15 @@ import { RowModalComponent } from "./result-panel-modal.component"; import { PanelResizeService } from "../../service/workflow-result/panel-resize/panel-resize.service"; import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service"; import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { of } from "rxjs"; +import { AppSettings } from "../../../common/app-setting"; +import { NotificationService } from "../../../common/service/notification/notification.service"; describe("RowModalComponent", () => { let component: RowModalComponent; let fixture: ComponentFixture; + let httpMock: HttpTestingController; const mockTupleResult = { tuple: { id: "123", value: "test_data" } }; const workflowResultServiceSpy = { @@ -41,7 +45,7 @@ describe("RowModalComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [RowModalComponent], + imports: [RowModalComponent, HttpClientTestingModule], providers: [ { provide: NZ_MODAL_DATA, useValue: { operatorId: "op-1", rowIndex: 3 } }, { provide: NzModalRef, useValue: { getConfig: () => ({}), close: vi.fn() } }, @@ -49,6 +53,12 @@ describe("RowModalComponent", () => { { provide: PanelResizeService, useValue: resizeServiceSpy }, ], }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); }); beforeEach(() => { @@ -65,4 +75,164 @@ describe("RowModalComponent", () => { component.ngOnChanges(); expect(component.currentDisplayRowData).toEqual(mockTupleResult.tuple); }); + + it("should use data URL directly without fetching for base64 media", () => { + const dataUrl = "data:image/png;base64,abc123"; + (component as any).buildRowEntries({ img: dataUrl }); + httpMock.expectNone(`${AppSettings.getApiEndpoint()}/huggingface/media-proxy`); + const entry = (component as any).buildRowEntries({ img: dataUrl })[0]; + expect(entry.mediaSrc).toBe(dataUrl); + expect(entry.isImage).toBe(true); + }); + + it("should fetch remote image URL via media-proxy and set blob URL on success", () => { + const createObjectURLSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-url"); + const remoteUrl = "https://example.com/photo.png"; + const entries = (component as any).buildRowEntries({ img: remoteUrl }); + const entry = entries[0]; + + expect(entry.mediaSrc).toBe(""); + expect(entry.isImage).toBe(true); + + const req = httpMock.expectOne( + `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}` + ); + req.flush(new Blob(["fake"], { type: "image/png" })); + + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(entry.mediaSrc).toBe("blob:fake-url"); + createObjectURLSpy.mockRestore(); + }); + + it("should fall back to raw URL when media-proxy request fails", () => { + const remoteUrl = "https://example.com/clip.mp4"; + const entries = (component as any).buildRowEntries({ vid: remoteUrl }); + const entry = entries[0]; + + const req = httpMock.expectOne( + `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}` + ); + req.error(new ProgressEvent("error")); + + expect(entry.mediaSrc).toBe(remoteUrl); + }); + + it("should not fetch media-proxy for non-media remote URLs", () => { + const remoteUrl = "https://example.com/some-text-value"; + (component as any).buildRowEntries({ text: remoteUrl }); + httpMock.expectNone(`${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`); + }); + + it("should revoke blob URLs on destroy", () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL"); + (component as any).allocatedBlobUrls.push("blob:url-1", "blob:url-2"); + component.ngOnDestroy(); + expect(revokeSpy).toHaveBeenCalledWith("blob:url-1"); + expect(revokeSpy).toHaveBeenCalledWith("blob:url-2"); + revokeSpy.mockRestore(); + }); + + it("prettyRowJson should return pretty-printed JSON of currentDisplayRowData", () => { + component.currentDisplayRowData = { name: "test", value: 42 }; + expect(component.prettyRowJson).toBe(JSON.stringify({ name: "test", value: 42 }, null, 2)); + }); + + it("trackByEntryKey should return the entry key", () => { + expect(component.trackByEntryKey(0, { key: "myKey" })).toBe("myKey"); + expect(component.trackByEntryKey(5, { key: "another" })).toBe("another"); + }); + + it("should JSON-stringify non-string values in buildRowEntries", () => { + const entries = (component as any).buildRowEntries({ count: 42, arr: [1, 2, 3] }); + expect(entries[0].value).toBe("42"); + expect(entries[0].isImage).toBe(false); + expect(entries[0].isVideo).toBe(false); + expect(entries[0].isAudio).toBe(false); + expect(entries[1].value).toBe("[1,2,3]"); + expect(entries[1].mediaSrc).toBe("[1,2,3]"); + }); + + it("should not update row data when tuple is null", () => { + vi.spyOn(workflowResultServiceSpy, "getPaginatedResultService").mockReturnValueOnce({ + selectTuple: vi.fn().mockReturnValue(of({ tuple: null })), + } as any); + component.currentDisplayRowData = { existing: "data" }; + component.ngOnChanges(); + expect(component.currentDisplayRowData).toEqual({ existing: "data" }); + }); + + it("should call notificationService.success on successful clipboard copy", async () => { + const notifService = TestBed.inject(NotificationService); + const successSpy = vi.spyOn(notifService, "success").mockImplementation(() => undefined as any); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + writable: true, + }); + component.copyText("hello world"); + await new Promise(r => setTimeout(r, 0)); + expect(successSpy).toHaveBeenCalledWith("Copied to clipboard"); + successSpy.mockRestore(); + }); + + it("should call notificationService.error on clipboard copy failure", async () => { + const notifService = TestBed.inject(NotificationService); + const errorSpy = vi.spyOn(notifService, "error").mockImplementation(() => undefined as any); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) }, + configurable: true, + writable: true, + }); + component.copyText("hello world"); + await new Promise(r => setTimeout(r, 0)); + expect(errorSpy).toHaveBeenCalledWith("Failed to copy"); + errorSpy.mockRestore(); + }); +}); + +describe("RowModalComponent (with pre-loaded rowData)", () => { + let component: RowModalComponent; + let httpMock: HttpTestingController; + + const rowData = { id: "123", imgUrl: "data:image/png;base64,abc" }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RowModalComponent, HttpClientTestingModule], + providers: [ + { provide: NZ_MODAL_DATA, useValue: { operatorId: "op-2", rowIndex: 0, rowData } }, + { provide: NzModalRef, useValue: { getConfig: () => ({}), close: vi.fn() } }, + { + provide: WorkflowResultService, + useValue: { getPaginatedResultService: vi.fn().mockReturnValue(null) }, + }, + { provide: PanelResizeService, useValue: { pageSize: 10 } }, + ], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + const fixture = TestBed.createComponent(RowModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it("should initialize currentDisplayRowData from rowData in modal data", () => { + expect(component.currentDisplayRowData).toEqual(rowData); + }); + + it("should build rowEntries immediately for data URL images in rowData", () => { + const imageEntry = component.rowEntries.find(e => e.key === "imgUrl"); + expect(imageEntry?.isImage).toBe(true); + expect(imageEntry?.mediaSrc).toBe(rowData.imgUrl); + }); + + it("should build rowEntries for all fields in rowData", () => { + expect(component.rowEntries.length).toBe(2); + const idEntry = component.rowEntries.find(e => e.key === "id"); + expect(idEntry?.value).toBe("123"); + }); }); diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts index 278a01dd5a7..45c87964ffe 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts +++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts @@ -17,12 +17,18 @@ * under the License. */ -import { Component, inject, OnChanges } from "@angular/core"; +import { Component, inject, OnChanges, OnDestroy } from "@angular/core"; +import { CommonModule } from "@angular/common"; import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal"; +import { NzButtonModule } from "ng-zorro-antd/button"; +import { NzIconModule } from "ng-zorro-antd/icon"; +import { HttpClient } from "@angular/common/http"; import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { PanelResizeService } from "../../service/workflow-result/panel-resize/panel-resize.service"; -import { NgxJsonViewerModule } from "ngx-json-viewer"; +import { NotificationService } from "../../../common/service/notification/notification.service"; +import { isAudioUrl, isVideoUrl, isImageUrl } from "src/app/common/util/media-type.util"; +import { AppSettings } from "../../../common/app-setting"; /** * @@ -42,29 +48,102 @@ import { NgxJsonViewerModule } from "ngx-json-viewer"; selector: "texera-row-modal-content", templateUrl: "./result-panel-modal.component.html", styleUrls: ["./result-panel-model.component.scss"], - imports: [NgxJsonViewerModule], + imports: [CommonModule, NzButtonModule, NzIconModule], }) -export class RowModalComponent implements OnChanges { +export class RowModalComponent implements OnChanges, OnDestroy { + rowEntries: { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] = + []; + private readonly allocatedBlobUrls: string[] = []; // Index of current displayed row in currentResult - readonly operatorId: string = inject(NZ_MODAL_DATA).operatorId; - rowIndex: number = inject(NZ_MODAL_DATA).rowIndex; + private readonly modalData: { operatorId: string; rowIndex: number; rowData?: Record } = + inject(NZ_MODAL_DATA); + readonly operatorId: string = this.modalData.operatorId; + rowIndex: number = this.modalData.rowIndex; currentDisplayRowData: Record = {}; constructor( public modal: NzModalRef, + private http: HttpClient, private workflowResultService: WorkflowResultService, - private resizeService: PanelResizeService + private resizeService: PanelResizeService, + private notificationService: NotificationService ) { + if (this.modalData.rowData) { + this.currentDisplayRowData = this.modalData.rowData; + this.rowEntries = this.buildRowEntries(this.currentDisplayRowData); + } this.ngOnChanges(); } + get prettyRowJson(): string { + return JSON.stringify(this.currentDisplayRowData, null, 2); + } + + copyText(text: string): void { + navigator.clipboard.writeText(text).then( + () => this.notificationService.success("Copied to clipboard"), + () => this.notificationService.error("Failed to copy") + ); + } + ngOnChanges(): void { this.workflowResultService .getPaginatedResultService(this.operatorId) ?.selectTuple(this.rowIndex, this.resizeService.pageSize) .pipe(untilDestroyed(this)) .subscribe(res => { - this.currentDisplayRowData = res.tuple; + if (res?.tuple) { + this.currentDisplayRowData = res.tuple; + this.rowEntries = this.buildRowEntries(this.currentDisplayRowData); + } + }); + } + + trackByEntryKey(_index: number, entry: { key: string }): string { + return entry.key; + } + + ngOnDestroy(): void { + for (const url of this.allocatedBlobUrls) { + URL.revokeObjectURL(url); + } + } + + private fetchBlobSrc(entry: { mediaSrc: string }, remoteUrl: string): void { + const proxyUrl = `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`; + this.http + .get(proxyUrl, { responseType: "blob" }) + .pipe(untilDestroyed(this)) + .subscribe({ + next: blob => { + const blobUrl = URL.createObjectURL(blob); + this.allocatedBlobUrls.push(blobUrl); + entry.mediaSrc = blobUrl; + }, + error: () => { + entry.mediaSrc = remoteUrl; + }, }); } + + private buildRowEntries( + rowData: Record + ): { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] { + return Object.entries(rowData).map(([key, val]) => { + const value = typeof val === "string" ? val : JSON.stringify(val) ?? String(val); + const isRemote = value.startsWith("http://") || value.startsWith("https://"); + const entry = { + key, + value, + mediaSrc: isRemote ? "" : value, + isVideo: typeof val === "string" && isVideoUrl(val), + isImage: typeof val === "string" && isImageUrl(val), + isAudio: typeof val === "string" && isAudioUrl(val), + }; + if (isRemote && (entry.isVideo || entry.isImage || entry.isAudio)) { + this.fetchBlobSrc(entry, value); + } + return entry; + }); + } } diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss b/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss index eb5ff4dece7..aafefae2d36 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss +++ b/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss @@ -23,3 +23,54 @@ height: 100%; width: 100%; } + +.modal-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} + +.row-detail-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.row-detail-item { + border: 1px solid #d9d9d9; + border-radius: 4px; + padding: 12px; + background: #fff; +} + +.row-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.row-detail-key { + font-weight: 600; + word-break: break-word; +} + +.row-detail-pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: monospace; + font-size: 12px; + line-height: 1.5; +} + +.row-detail-value { + max-height: none; + overflow: visible; + padding: 8px; + border: 1px solid #d9d9d9; + border-radius: 4px; + background: #fafafa; + user-select: text; +} diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html index 5400d978ee3..9313dbb12ec 100644 --- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html @@ -161,7 +161,40 @@
- {{ column.getCell(row) }} + + + + Play Video + + + + + Play Audio + + + + + + View Image + + + {{ column.getCell(row) }} +