You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@texera.apache.org by "juliethecao (via GitHub)" <gi...@apache.org> on 2026/06/13 05:22:30 UTC
[PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
juliethecao opened a new pull request, #5675:
URL: https://github.com/apache/texera/pull/5675
⚠️ This PR is stacked on #5568. Until that lands, the diff below may also include earlier PRs' property editor changes depending on which base GitHub is showing. The new code in this PR is the media-type detection helpers, inline media rendering in the result table and row detail modal, and the backend string truncation fix. Once #5568 merges and this PR is retargeted to main, the diff should auto-clean to the PR 8 result-media changes only.
### What changes were proposed in this PR?
Render image, audio, and video outputs from HuggingFace tasks inline in the workflow result panel instead of displaying raw data URLs as text.
**New file — `media-type.util.ts`:**
- `isImageUrl` — detects `data:image/` data URLs and common image extensions (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`)
- `isAudioUrl` — detects `data:audio/` data URLs and common audio extensions (`.mp3`, `.wav`, `.ogg`, `.m4a`, `.flac`)
- `isVideoUrl` — detects `data:video/` data URLs, common video extensions (`.mp4`, `.webm`, `.ogg`), and the `fal.media` CDN host used by fal.ai text-to-video outputs
**Changes to `result-table-frame.component.{ts,html}`:**
- Add `isImageCell` / `isAudioCell` / `isVideoCell` methods that delegate to the detection helpers
- Render `<img>`, `<audio controls>`, `<video controls>` tags conditionally in the table cell template; non-media cells fall through to existing text rendering unchanged
**Changes to `result-panel-modal.component.{ts,html}`:**
- Build `rowEntries` with per-field media metadata on modal open
- Render media fields inline in the row detail view with a copy-to-clipboard fallback for the raw URL
**Changes to `result-panel-model.component.scss`:**
- Add modal-toolbar and row-detail styles for media display
**New file — `media-type.util.spec.ts`:**
- 19 unit tests covering all three helpers across data URL prefixes, file extensions (including case-insensitivity and query strings), the `fal.media` CDN URL, cross-type rejection, and empty/plain-string inputs
### Any related issues, documentation, discussions?
- Tracking issue: #5674
- Closes: #5674
- Stacked on: #5568
- Parent issue: #5041
### How was this PR tested?
Unit tests in `media-type.util.spec.ts` (19 cases) and `result-table-frame.component.spec.ts`. Run with `ng test`.
### Was this PR authored or co-authored using generative AI tooling?
Co-authored with Claude Sonnet 4.6 in compliance with ASF guidelines
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "xuang7 (via GitHub)" <gi...@apache.org>.
xuang7 commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3599373348
##########
frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html:
##########
@@ -161,7 +161,40 @@ <h5 class="rightAlign"><span [innerHTML]="compare(column.header, 'other')"></spa
class="table-cell"
nzEllipsis
(click)="open(i, row)">
- <span class="cell-content">{{ column.getCell(row) }}</span>
+ <span class="cell-content">
+ <ng-container *ngIf="isVideoCell(column.getCell(row)); else checkAudio">
Review Comment:
Template method calls re-run on every change detection cycle, so each cell does up to 4 getCell calls plus several regex tests per cycle while the table is being refreshed during execution. Consider precomputing the media type when row data arrives, or using a pure pipe.
##########
frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts:
##########
@@ -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<string, unknown> } =
+ inject(NZ_MODAL_DATA);
+ readonly operatorId: string = this.modalData.operatorId;
+ rowIndex: number = this.modalData.rowIndex;
currentDisplayRowData: Record<string, unknown> = {};
constructor(
public modal: NzModalRef<any, number>,
+ 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;
Review Comment:
If the backend rejects proxying a media URL (e.g., due to the SSRF allowlist), the frontend falls back to loading the original URL directly. This still causes the user's browser to request the remote resource. On a proxy error, we could set isVideo, isImage, and isAudio to false so the entry falls back to the existing text view instead of loading the remote URL directly.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3603941291
##########
frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html:
##########
@@ -161,7 +161,40 @@ <h5 class="rightAlign"><span [innerHTML]="compare(column.header, 'other')"></spa
class="table-cell"
nzEllipsis
(click)="open(i, row)">
- <span class="cell-content">{{ column.getCell(row) }}</span>
+ <span class="cell-content">
+ <ng-container *ngIf="isVideoCell(column.getCell(row)); else checkAudio">
Review Comment:
- Added `cellMediaTypes[rowIndex][columnIndex]` grid, built once in `setupResultTable()` whenever new row data arrives
- Template now does a single O(1) [ngSwitch] lookup instead of 3 chained *ngIfs each calling isVideoCell/isAudioCell/isImageCell(column.getCell(row)), down from up to 4 getCell calls + 3 regex tests per cell per change-detection cycle to 1 lookup, with the regex work happening once per row on data arrival
##########
frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts:
##########
@@ -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<string, unknown> } =
+ inject(NZ_MODAL_DATA);
+ readonly operatorId: string = this.modalData.operatorId;
+ rowIndex: number = this.modalData.rowIndex;
currentDisplayRowData: Record<string, unknown> = {};
constructor(
public modal: NzModalRef<any, number>,
+ 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;
Review Comment:
- On a proxy fetch error, fetchBlobSrc no longer falls back to `entry.mediaSrc = remoteUrl`. It now clears isVideo/isImage/isAudio on the entry so it falls through to the existing text view
- Updated the corresponding spec test to assert the new fallback behavior
- Fixed one unrelated pre-existing test failure (bare filenames instead of full URLs) that surfaced while re-running the suite
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #5675:
URL: https://github.com/apache/texera/pull/5675#issuecomment-4697615007
<!-- texera-benchmarks-comment -->
## ⚠️ Benchmark changes need a look
🟢 7 better · 🔴 1 worse · ⚪ 7 noise (<±5%) · 0 without baseline
> CI benchmark results are noisy; treat <±5% as noise unless repeated.
[Dashboard](https://apache.github.io/texera/dev/bench/) · [Run](https://github.com/apache/texera/actions/runs/27457698220)
| | config | throughput | MB/s | latency | max Δ latest / 7d |
|---|---|---:|---:|---:|---:|
| 🔴 | bs=10 sw=10 sl=64 | 377 | 0.23 | 27,428/34,164/34,164 us | 🟢 -10.9% / 🔴 +13.4% |
| 🟢 | bs=100 sw=10 sl=64 | 825 | 0.504 | 118,766/141,695/141,695 us | 🟢 -18.2% / ⚪ within ±5% |
| ⚪ | bs=1000 sw=10 sl=64 | 936 | 0.571 | 1,067,547/1,115,678/1,115,678 us | ⚪ within ±5% / 🔴 -7.3% |
<details><summary>Baseline details</summary>
Latest main `7ae9b35` from 2026-06-13T00:44:28.840Z
| config | metric | PR | latest main | 7d avg | Δ latest | Δ 7d |
|---|---|---:|---:|---:|---:|---:|
| bs=10 sw=10 sl=64 | throughput | 377 tuples/sec | 394.18 tuples/sec | 400.34 tuples/sec | -4.4% | -5.8% |
| bs=10 sw=10 sl=64 | MB/s | 0.23 MB/s | 0.241 MB/s | 0.244 MB/s | -4.4% | -5.9% |
| bs=10 sw=10 sl=64 | p50 | 27,428 us | 24,805 us | 24,191 us | +10.6% | +13.4% |
| bs=10 sw=10 sl=64 | p95 | 34,164 us | 38,345 us | 36,018 us | -10.9% | -5.1% |
| bs=10 sw=10 sl=64 | p99 | 34,164 us | 38,345 us | 36,018 us | -10.9% | -5.1% |
| bs=100 sw=10 sl=64 | throughput | 825 tuples/sec | 729.17 tuples/sec | 867.19 tuples/sec | +13.1% | -4.9% |
| bs=100 sw=10 sl=64 | MB/s | 0.504 MB/s | 0.445 MB/s | 0.529 MB/s | +13.2% | -4.8% |
| bs=100 sw=10 sl=64 | p50 | 118,766 us | 133,219 us | 114,874 us | -10.8% | +3.4% |
| bs=100 sw=10 sl=64 | p95 | 141,695 us | 173,178 us | 142,264 us | -18.2% | -0.4% |
| bs=100 sw=10 sl=64 | p99 | 141,695 us | 173,178 us | 142,264 us | -18.2% | -0.4% |
| bs=1000 sw=10 sl=64 | throughput | 936 tuples/sec | 946.3 tuples/sec | 1,010 tuples/sec | -1.1% | -7.3% |
| bs=1000 sw=10 sl=64 | MB/s | 0.571 MB/s | 0.578 MB/s | 0.616 MB/s | -1.1% | -7.3% |
| bs=1000 sw=10 sl=64 | p50 | 1,067,547 us | 1,059,718 us | 995,071 us | +0.7% | +7.3% |
| bs=1000 sw=10 sl=64 | p95 | 1,115,678 us | 1,104,221 us | 1,046,228 us | +1.0% | +6.6% |
| bs=1000 sw=10 sl=64 | p99 | 1,115,678 us | 1,104,221 us | 1,046,228 us | +1.0% | +6.6% |
</details>
<details><summary>Raw CSV</summary>
```csv
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,530.31,200,128000,377,0.230,27428.19,34163.56,34163.56
1,100,10,64,20,2424.30,2000,1280000,825,0.504,118765.89,141695.07,141695.07
2,1000,10,64,20,21368.63,20000,12800000,936,0.571,1067546.95,1115678.24,1115678.24
```
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "xuang7 (via GitHub)" <gi...@apache.org>.
xuang7 merged PR #5675:
URL: https://github.com/apache/texera/pull/5675
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3454250510
##########
frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts:
##########
@@ -0,0 +1,637 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit, OnDestroy, ChangeDetectorRef } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core";
+import { HttpClient } from "@angular/common/http";
+import { NzSelectModule } from "ng-zorro-antd/select";
+import { NzInputModule } from "ng-zorro-antd/input";
+import { NzSpinModule } from "ng-zorro-antd/spin";
+import { NzButtonModule } from "ng-zorro-antd/button";
+import { NzIconModule } from "ng-zorro-antd/icon";
+import { AppSettings } from "../../../common/app-setting";
+import { Subject, Subscription } from "rxjs";
+import { debounceTime, finalize, switchMap, takeUntil } from "rxjs/operators";
+
+export interface HuggingFaceModelOption {
+ id: string;
+ label: string;
+ pipeline_tag?: string;
+ downloads?: number;
+ likes?: number;
+}
+
+export interface HuggingFaceTaskOption {
+ tag: string;
+ label: string;
+}
+
+// ── Static fallback task list (used when the dynamic fetch fails) ──
+export const STATIC_TASK_OPTIONS: HuggingFaceTaskOption[] = [
+ { tag: "text-generation", label: "Text Generation" },
+ { tag: "automatic-speech-recognition", label: "Automatic Speech Recognition" },
+ { tag: "audio-classification", label: "Audio Classification" },
+ { tag: "text-classification", label: "Text Classification" },
+ { tag: "text-to-speech", label: "Text to Speech" },
+ { tag: "token-classification", label: "Token Classification" },
+ { tag: "question-answering", label: "Question Answering" },
+ { tag: "table-question-answering", label: "Table Question Answering" },
+ { tag: "zero-shot-classification", label: "Zero-Shot Classification" },
+ { tag: "translation", label: "Translation" },
+ { tag: "summarization", label: "Summarization" },
+ { tag: "feature-extraction", label: "Feature Extraction" },
+ { tag: "fill-mask", label: "Fill-Mask" },
+ { tag: "sentence-similarity", label: "Sentence Similarity" },
+ { tag: "text-ranking", label: "Text Ranking" },
+ { tag: "image-classification", label: "Image Classification" },
+ { tag: "object-detection", label: "Object Detection" },
+ { tag: "image-segmentation", label: "Image Segmentation" },
+ { tag: "image-to-text", label: "Image to Text" },
+ { tag: "visual-question-answering", label: "Visual Question Answering" },
+ { tag: "document-question-answering", label: "Document Question Answering" },
+ { tag: "zero-shot-image-classification", label: "Zero-Shot Image Classification" },
+];
+
+const PAGE_SIZE = 50;
+
+const TRUNCATED_HEADER = "X-Texera-Truncated";
+
+// ── Module-level caches (reused across component instances) ──
+const allModelsByTag: Map<string, HuggingFaceModelOption[]> = new Map();
+const truncatedByTag: Set<string> = new Set();
+const inFlightByTag: Map<string, Subscription> = new Map();
+const errorByTag: Map<string, string> = new Map();
+
+let cachedTaskOptions: HuggingFaceTaskOption[] | null = null;
+let tasksFetchSubscription: Subscription | null = null;
+let tasksFetchError: string | null = null;
+
+/** Clear all cached data (useful for tests or manual invalidation). */
+export function invalidateHuggingFaceModelCache(): void {
+ allModelsByTag.clear();
+ truncatedByTag.clear();
+ errorByTag.clear();
+ inFlightByTag.forEach(sub => sub.unsubscribe());
+ inFlightByTag.clear();
+ cachedTaskOptions = null;
+ tasksFetchError = null;
+ tasksFetchSubscription?.unsubscribe();
+ tasksFetchSubscription = null;
+}
+
+@Component({
+ selector: "texera-hugging-face-model-select",
+ templateUrl: "./hugging-face.component.html",
+ styleUrls: ["hugging-face.component.scss"],
+ imports: [
+ CommonModule,
+ FormsModule,
+ NzSelectModule,
+ NzInputModule,
+ NzSpinModule,
+ NzButtonModule,
+ NzIconModule,
+ FormlyModule,
+ ],
+})
+export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements OnInit, OnDestroy {
+ private readonly taskScopedKeys = [
+ "modelId",
+ "promptColumn",
+ "imageInput",
+ "audioInput",
+ "inputImageColumn",
+ "inputAudioColumn",
+ "candidateLabels",
+ "sentencesColumn",
+ "contextColumn",
+ "systemPrompt",
+ "maxNewTokens",
+ "temperature",
+ ] as const;
+ private readonly taskStateByTag = new Map<string, Partial<Record<(typeof this.taskScopedKeys)[number], unknown>>>();
+ // ── Task state ──
+ taskOptions: HuggingFaceTaskOption[] = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+ selectedTaskTag = "text-generation";
+ tasksLoading = false;
+ tasksError: string | null = null;
+
+ // ── All models for the current task (fetched once from backend, cached) ──
+ private allModels: HuggingFaceModelOption[] = [];
+
+ // ── Displayed state ──
+ pagedModels: HuggingFaceModelOption[] = [];
+ currentPage = 0;
+ totalPages = 0;
+
+ loading = false;
+ errorMessage: string | null = null;
+
+ // ── Truncation notice ──
+ truncated = false;
+
+ // ── Search state ──
+ searchText = "";
+ searchLoading = false;
+ private filteredModels: HuggingFaceModelOption[] | null = null;
+ private readonly searchSubject$ = new Subject<string>();
+ private searchSubscription: Subscription | null = null;
+
+ private readonly destroy$ = new Subject<void>();
+ private subscription: Subscription | null = null;
+ private taskPollInterval: ReturnType<typeof setInterval> | null = null;
+ private modelPollInterval: ReturnType<typeof setInterval> | null = null;
+ private initTimeout: ReturnType<typeof setTimeout> | null = null;
+
+ constructor(
+ private http: HttpClient,
+ private cdr: ChangeDetectorRef
+ ) {
+ super();
+ }
+
+ ngOnInit(): void {
+ const savedTag = this.getCurrentTaskTag();
+ this.selectedTaskTag = savedTag ?? this.selectedTaskTag;
+ this.syncTaskSelection(this.selectedTaskTag, false);
+ this.loadTasks();
+ this.loadAllModels();
+ this.setupServerSearch();
+ // Formly can attach sibling controls after this field initializes.
+ // Re-sync once the control tree settles so a fresh operator starts in a valid task state.
+ this.initTimeout = setTimeout(
+ () => this.syncTaskSelection(this.getCurrentTaskTag() ?? this.selectedTaskTag, false),
+ 0
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.subscription?.unsubscribe();
+ this.searchSubscription?.unsubscribe();
+ this.searchSubject$.complete();
+ if (this.taskPollInterval !== null) {
+ clearInterval(this.taskPollInterval);
+ }
+ if (this.modelPollInterval !== null) {
+ clearInterval(this.modelPollInterval);
+ }
+ if (this.initTimeout !== null) {
+ clearTimeout(this.initTimeout);
+ }
+ }
+
+ // ── Task loading ──
+
+ /**
+ * Fetch available pipeline tags from the backend, which proxies HuggingFace's /api/tasks.
+ * Falls back to STATIC_TASK_OPTIONS if the fetch fails.
+ */
+ private loadTasks(): void {
+ // Already fetched and cached
+ if (cachedTaskOptions !== null) {
+ this.taskOptions = cachedTaskOptions;
+ return;
+ }
+
+ // Previous fetch errored — show static list, don't retry automatically
+ if (tasksFetchError !== null) {
+ this.tasksError = tasksFetchError;
+ this.taskOptions = STATIC_TASK_OPTIONS;
+ return;
+ }
+
+ // Another component instance already has a fetch in flight — wait for it
+ if (tasksFetchSubscription !== null) {
+ this.tasksLoading = true;
+ // Poll for completion (the module-level cache will be set when done)
+ this.taskPollInterval = setInterval(() => {
+ if (cachedTaskOptions !== null || tasksFetchError !== null) {
+ clearInterval(this.taskPollInterval!);
+ this.taskPollInterval = null;
+ this.tasksLoading = false;
+ this.taskOptions = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+ if (tasksFetchError) this.tasksError = tasksFetchError;
+ this.cdr.detectChanges();
+ }
+ }, 200);
+ return;
+ }
+
+ this.tasksLoading = true;
+ this.tasksError = null;
+ this.cdr.detectChanges();
+
+ tasksFetchSubscription = this.http
+ .get<HuggingFaceTaskOption[]>(`${AppSettings.getApiEndpoint()}/huggingface/tasks`)
+ .pipe(
+ takeUntil(this.destroy$),
+ finalize(() => {
+ // If takeUntil fires before next/error, reset the module-level guard
+ // so the next component instance can start a fresh fetch.
+ if (cachedTaskOptions === null && tasksFetchError === null) {
+ tasksFetchSubscription = null;
+ }
+ })
+ )
+ .subscribe({
Review Comment:
Fix: `takeUntil(this.destroy$)` is removed only from the shared tasks fetch.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3454561440
##########
common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/ImageTaskCodegen.scala:
##########
@@ -0,0 +1,166 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.amber.operator.huggingFace.codegen
+
+/**
+ * Codegen for the Hugging Face image-pipeline task family.
+ *
+ * Splits into two sub-families:
+ * - "image-only" tasks send raw image bytes as the request body and don't
+ * consume the prompt column: image-classification, object-detection,
+ * image-segmentation, image-to-text.
+ * - "image + prompt" tasks bundle a base64 image and a text prompt in a
+ * JSON payload: visual-question-answering, document-question-answering,
+ * zero-shot-image-classification, image-text-to-text, image-to-image.
+ *
+ * Per-row `current_image_bytes` is resolved upstream in
+ * [[PythonCodegenBase]]'s `process_table` (either from the operator's
+ * uploaded image or from `INPUT_IMAGE_COLUMN`). The image helpers
+ * (`_read_image_input`, `_compress_image_bytes`, `_image_input_as_base64`,
+ * `_read_binary_value`, `_looks_like_html`, `_html_to_image_bytes`,
+ * `_extract_json_arg`) live in PythonCodegenBase alongside the per-task
+ * tuples (`image_only_tasks`, `image_prompt_tasks`, `image_tasks`).
+ */
+object ImageTaskCodegen extends TaskCodegen {
+
+ /** Primary key for registration; the dispatcher maps every task in
+ * [[tasks]] to this codegen.
+ */
+ override val task: String = "image-classification"
+
+ /** All HF tasks routed through this codegen. */
+ override val tasks: Set[String] = Set(
+ // image-only
+ "image-classification",
+ "object-detection",
+ "image-segmentation",
+ "image-to-text",
+ // image + prompt
+ "visual-question-answering",
+ "document-question-answering",
+ "zero-shot-image-classification",
+ "image-text-to-text",
+ "image-to-image"
+ )
+
+ override def payloadPython(ctx: CodegenContext): String =
+ """ if task in image_only_tasks:
+ | payload = current_image_bytes
+ | use_raw_binary_body = True
+ | raw_binary_headers = image_headers
+ | elif task in ("visual-question-answering", "document-question-answering"):
+ | payload = {
+ | "inputs": {
+ | "image": self._image_input_as_base64(current_image_bytes),
+ | "question": prompt_value,
+ | }
+ | }
+ | elif task == "image-text-to-text":
+ | img_b64 = self._image_input_as_base64(current_image_bytes)
+ | payload = {
+ | "model": self.MODEL_ID,
+ | "messages": [{
+ | "role": "user",
+ | "content": [
+ | {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}},
+ | {"type": "text", "text": prompt_value if prompt_value else "Describe this image."},
+ | ],
+ | }],
+ | "max_tokens": self.MAX_NEW_TOKENS,
+ | }
+ | elif task == "image-to-image":
+ | payload = current_image_bytes
+ | 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()]
+ | 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."
+ | )
+ | payload = {
+ | "inputs": self._image_input_as_base64(current_image_bytes),
+ | "parameters": {"candidate_labels": labels},
+ | }
Review Comment:
Fix: already in place through rebase
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3608977313
##########
frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html:
##########
@@ -161,7 +161,38 @@ <h5 class="rightAlign"><span [innerHTML]="compare(column.header, 'other')"></spa
class="table-cell"
nzEllipsis
(click)="open(i, row)">
- <span class="cell-content">{{ column.getCell(row) }}</span>
+ <span class="cell-content">
+ <ng-container [ngSwitch]="getCellMediaType(i, columnIndex)">
Review Comment:
Changed `cellMediaTypes` from a `MediaCellType[][]` (indexed by row position) to a `Map<IndexableObject, MediaCellType[]>` keyed by the row object itself
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "chenlica (via GitHub)" <gi...@apache.org>.
chenlica commented on PR #5675:
URL: https://github.com/apache/texera/pull/5675#issuecomment-4911864986
@Ma77Ball Please do the review.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "codecov-commenter (via GitHub)" <gi...@apache.org>.
codecov-commenter commented on PR #5675:
URL: https://github.com/apache/texera/pull/5675#issuecomment-4697609605
## [Codecov](https://app.codecov.io/gh/apache/texera/pull/5675?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
:white_check_mark: All modified and coverable lines are covered by tests.
:white_check_mark: Project coverage is 52.22%. Comparing base ([`564ccdb`](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`a2f92c5`](https://app.codecov.io/gh/apache/texera/commit/a2f92c5057d7d3de5c5e46d54f2dbb03020cab9c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
:warning: Report is 37 commits behind head on main.
<details><summary>Additional details and impacted files</summary>
```diff
@@ Coverage Diff @@
## main #5675 +/- ##
============================================
+ Coverage 52.17% 52.22% +0.04%
- Complexity 2482 2483 +1
============================================
Files 1068 1068
Lines 41311 41305 -6
Branches 4439 4439
============================================
+ Hits 21556 21570 +14
+ Misses 18490 18468 -22
- Partials 1265 1267 +2
```
| [Flag](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | | *Carryforward flag |
|---|---|---|---|
| [access-control-service](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `71.42% <ø> (+6.81%)` | :arrow_up: | |
| [agent-service](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `33.76% <ø> (ø)` | | Carriedforward from [564ccdb](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [amber](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `53.28% <ø> (ø)` | | Carriedforward from [564ccdb](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [computing-unit-managing-service](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `1.65% <ø> (ø)` | | Carriedforward from [564ccdb](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [config-service](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `56.06% <ø> (ø)` | | Carriedforward from [564ccdb](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [file-service](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `38.32% <ø> (ø)` | | Carriedforward from [564ccdb](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [frontend](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `46.44% <ø> (+0.01%)` | :arrow_up: | Carriedforward from [564ccdb](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [pyamber](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `90.72% <ø> (ø)` | | Carriedforward from [564ccdb](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [python](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `90.75% <ø> (ø)` | | Carriedforward from [564ccdb](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [workflow-compiling-service](https://app.codecov.io/gh/apache/texera/pull/5675/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `58.69% <ø> (ø)` | | Carriedforward from [564ccdb](https://app.codecov.io/gh/apache/texera/commit/564ccdbfd51089f31dcfdcbdba9f5a872a2f8256?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
*This pull request uses carry forward flags. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) to find out more.
</details>
[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/apache/texera/pull/5675?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
<details><summary> :rocket: New features to boost your workflow: </summary>
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #5675:
URL: https://github.com/apache/texera/pull/5675#issuecomment-4771037136
<!-- texera-reviewer-suggestion -->
### Automated Reviewer Suggestions
Based on the `git blame` history of the changed files, we recommend the following reviewers:
- **Contributors with relevant context:** `@aglinxinyuan`, `@rbelavadi`, `@SarahAsad23`
You can notify them by mentioning `@aglinxinyuan`, `@rbelavadi`, `@SarahAsad23` in a comment.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3454268278
##########
frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts:
##########
@@ -42,29 +48,79 @@ 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, NgxJsonViewerModule],
})
export class RowModalComponent implements OnChanges {
+ rowEntries: { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] =
+ [];
// 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<string, unknown> } =
+ inject(NZ_MODAL_DATA);
+ readonly operatorId: string = this.modalData.operatorId;
+ rowIndex: number = this.modalData.rowIndex;
currentDisplayRowData: Record<string, unknown> = {};
constructor(
public modal: NzModalRef<any, number>,
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;
+ }
+
+ private resolveMediaSrc(value: string): string {
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
+ return value;
+ }
+ return `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(value)}`;
+ }
+
+ private buildRowEntries(
+ rowData: Record<string, unknown>
+ ): { 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);
+ return {
+ key,
+ value,
+ mediaSrc: this.resolveMediaSrc(value),
+ isVideo: typeof val === "string" && isVideoUrl(val),
+ isImage: typeof val === "string" && isImageUrl(val),
+ isAudio: typeof val === "string" && isAudioUrl(val),
+ };
+ });
Review Comment:
Fix: coalesced `JSON.stringify(val)` with `String(val)` as fallback to handle the undefined case
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "xuang7 (via GitHub)" <gi...@apache.org>.
xuang7 commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3487423681
##########
frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts:
##########
@@ -42,29 +47,79 @@ 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 {
+ rowEntries: { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] =
+ [];
// 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<string, unknown> } =
+ inject(NZ_MODAL_DATA);
+ readonly operatorId: string = this.modalData.operatorId;
+ rowIndex: number = this.modalData.rowIndex;
currentDisplayRowData: Record<string, unknown> = {};
constructor(
public modal: NzModalRef<any, number>,
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;
+ }
+
+ private resolveMediaSrc(value: string): string {
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
+ return value;
+ }
+ return `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(value)}`;
Review Comment:
Remote (http/https) media is routed through /huggingface/media-proxy and loaded via a native video, img, audio src. Since that endpoint inherits class-level @ RolesAllowed(REGULAR/ADMIN) and native tags don't carry the angular-jwt token, those requests will likely 401.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3493870723
##########
frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts:
##########
@@ -42,29 +47,79 @@ 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 {
+ rowEntries: { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] =
+ [];
// 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<string, unknown> } =
+ inject(NZ_MODAL_DATA);
+ readonly operatorId: string = this.modalData.operatorId;
+ rowIndex: number = this.modalData.rowIndex;
currentDisplayRowData: Record<string, unknown> = {};
constructor(
public modal: NzModalRef<any, number>,
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;
+ }
+
+ private resolveMediaSrc(value: string): string {
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
+ return value;
+ }
+ return `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(value)}`;
Review Comment:
Fixed. Remote URLs are fetched via HttpClient (which runs the JWT interceptor and adds Authorization: Bearer ...), converted to a blob URL, and the blob URL is bound to [src] instead. The native element never makes an authenticated request directly
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3444862464
##########
frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts:
##########
@@ -42,29 +48,79 @@ 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, NgxJsonViewerModule],
})
export class RowModalComponent implements OnChanges {
+ rowEntries: { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] =
+ [];
// 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<string, unknown> } =
+ inject(NZ_MODAL_DATA);
+ readonly operatorId: string = this.modalData.operatorId;
+ rowIndex: number = this.modalData.rowIndex;
currentDisplayRowData: Record<string, unknown> = {};
constructor(
public modal: NzModalRef<any, number>,
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;
+ }
+
+ private resolveMediaSrc(value: string): string {
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
+ return value;
+ }
+ return `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(value)}`;
+ }
+
+ private buildRowEntries(
+ rowData: Record<string, unknown>
+ ): { 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);
+ return {
+ key,
+ value,
+ mediaSrc: this.resolveMediaSrc(value),
+ isVideo: typeof val === "string" && isVideoUrl(val),
+ isImage: typeof val === "string" && isImageUrl(val),
+ isAudio: typeof val === "string" && isAudioUrl(val),
+ };
+ });
Review Comment:
`JSON.stringify(val)` can return `undefined` (e.g., when `val` is `undefined`), but `value` is treated as a `string` and passed into `resolveMediaSrc(value)` which calls `startsWith`. This can throw at runtime and also violates the declared `value: string` type. Coalesce to a real string for non-JSON-serializable values.
##########
frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts:
##########
@@ -0,0 +1,637 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit, OnDestroy, ChangeDetectorRef } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core";
+import { HttpClient } from "@angular/common/http";
+import { NzSelectModule } from "ng-zorro-antd/select";
+import { NzInputModule } from "ng-zorro-antd/input";
+import { NzSpinModule } from "ng-zorro-antd/spin";
+import { NzButtonModule } from "ng-zorro-antd/button";
+import { NzIconModule } from "ng-zorro-antd/icon";
+import { AppSettings } from "../../../common/app-setting";
+import { Subject, Subscription } from "rxjs";
+import { debounceTime, finalize, switchMap, takeUntil } from "rxjs/operators";
+
+export interface HuggingFaceModelOption {
+ id: string;
+ label: string;
+ pipeline_tag?: string;
+ downloads?: number;
+ likes?: number;
+}
+
+export interface HuggingFaceTaskOption {
+ tag: string;
+ label: string;
+}
+
+// ── Static fallback task list (used when the dynamic fetch fails) ──
+export const STATIC_TASK_OPTIONS: HuggingFaceTaskOption[] = [
+ { tag: "text-generation", label: "Text Generation" },
+ { tag: "automatic-speech-recognition", label: "Automatic Speech Recognition" },
+ { tag: "audio-classification", label: "Audio Classification" },
+ { tag: "text-classification", label: "Text Classification" },
+ { tag: "text-to-speech", label: "Text to Speech" },
+ { tag: "token-classification", label: "Token Classification" },
+ { tag: "question-answering", label: "Question Answering" },
+ { tag: "table-question-answering", label: "Table Question Answering" },
+ { tag: "zero-shot-classification", label: "Zero-Shot Classification" },
+ { tag: "translation", label: "Translation" },
+ { tag: "summarization", label: "Summarization" },
+ { tag: "feature-extraction", label: "Feature Extraction" },
+ { tag: "fill-mask", label: "Fill-Mask" },
+ { tag: "sentence-similarity", label: "Sentence Similarity" },
+ { tag: "text-ranking", label: "Text Ranking" },
+ { tag: "image-classification", label: "Image Classification" },
+ { tag: "object-detection", label: "Object Detection" },
+ { tag: "image-segmentation", label: "Image Segmentation" },
+ { tag: "image-to-text", label: "Image to Text" },
+ { tag: "visual-question-answering", label: "Visual Question Answering" },
+ { tag: "document-question-answering", label: "Document Question Answering" },
+ { tag: "zero-shot-image-classification", label: "Zero-Shot Image Classification" },
+];
+
+const PAGE_SIZE = 50;
+
+const TRUNCATED_HEADER = "X-Texera-Truncated";
+
+// ── Module-level caches (reused across component instances) ──
+const allModelsByTag: Map<string, HuggingFaceModelOption[]> = new Map();
+const truncatedByTag: Set<string> = new Set();
+const inFlightByTag: Map<string, Subscription> = new Map();
+const errorByTag: Map<string, string> = new Map();
+
+let cachedTaskOptions: HuggingFaceTaskOption[] | null = null;
+let tasksFetchSubscription: Subscription | null = null;
+let tasksFetchError: string | null = null;
+
+/** Clear all cached data (useful for tests or manual invalidation). */
+export function invalidateHuggingFaceModelCache(): void {
+ allModelsByTag.clear();
+ truncatedByTag.clear();
+ errorByTag.clear();
+ inFlightByTag.forEach(sub => sub.unsubscribe());
+ inFlightByTag.clear();
+ cachedTaskOptions = null;
+ tasksFetchError = null;
+ tasksFetchSubscription?.unsubscribe();
+ tasksFetchSubscription = null;
+}
+
+@Component({
+ selector: "texera-hugging-face-model-select",
+ templateUrl: "./hugging-face.component.html",
+ styleUrls: ["hugging-face.component.scss"],
+ imports: [
+ CommonModule,
+ FormsModule,
+ NzSelectModule,
+ NzInputModule,
+ NzSpinModule,
+ NzButtonModule,
+ NzIconModule,
+ FormlyModule,
+ ],
+})
+export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements OnInit, OnDestroy {
+ private readonly taskScopedKeys = [
+ "modelId",
+ "promptColumn",
+ "imageInput",
+ "audioInput",
+ "inputImageColumn",
+ "inputAudioColumn",
+ "candidateLabels",
+ "sentencesColumn",
+ "contextColumn",
+ "systemPrompt",
+ "maxNewTokens",
+ "temperature",
+ ] as const;
+ private readonly taskStateByTag = new Map<string, Partial<Record<(typeof this.taskScopedKeys)[number], unknown>>>();
+ // ── Task state ──
+ taskOptions: HuggingFaceTaskOption[] = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+ selectedTaskTag = "text-generation";
+ tasksLoading = false;
+ tasksError: string | null = null;
+
+ // ── All models for the current task (fetched once from backend, cached) ──
+ private allModels: HuggingFaceModelOption[] = [];
+
+ // ── Displayed state ──
+ pagedModels: HuggingFaceModelOption[] = [];
+ currentPage = 0;
+ totalPages = 0;
+
+ loading = false;
+ errorMessage: string | null = null;
+
+ // ── Truncation notice ──
+ truncated = false;
+
+ // ── Search state ──
+ searchText = "";
+ searchLoading = false;
+ private filteredModels: HuggingFaceModelOption[] | null = null;
+ private readonly searchSubject$ = new Subject<string>();
+ private searchSubscription: Subscription | null = null;
+
+ private readonly destroy$ = new Subject<void>();
+ private subscription: Subscription | null = null;
+ private taskPollInterval: ReturnType<typeof setInterval> | null = null;
+ private modelPollInterval: ReturnType<typeof setInterval> | null = null;
+ private initTimeout: ReturnType<typeof setTimeout> | null = null;
+
+ constructor(
+ private http: HttpClient,
+ private cdr: ChangeDetectorRef
+ ) {
+ super();
+ }
+
+ ngOnInit(): void {
+ const savedTag = this.getCurrentTaskTag();
+ this.selectedTaskTag = savedTag ?? this.selectedTaskTag;
+ this.syncTaskSelection(this.selectedTaskTag, false);
+ this.loadTasks();
+ this.loadAllModels();
+ this.setupServerSearch();
+ // Formly can attach sibling controls after this field initializes.
+ // Re-sync once the control tree settles so a fresh operator starts in a valid task state.
+ this.initTimeout = setTimeout(
+ () => this.syncTaskSelection(this.getCurrentTaskTag() ?? this.selectedTaskTag, false),
+ 0
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.subscription?.unsubscribe();
+ this.searchSubscription?.unsubscribe();
+ this.searchSubject$.complete();
+ if (this.taskPollInterval !== null) {
+ clearInterval(this.taskPollInterval);
+ }
+ if (this.modelPollInterval !== null) {
+ clearInterval(this.modelPollInterval);
+ }
+ if (this.initTimeout !== null) {
+ clearTimeout(this.initTimeout);
+ }
+ }
+
+ // ── Task loading ──
+
+ /**
+ * Fetch available pipeline tags from the backend, which proxies HuggingFace's /api/tasks.
+ * Falls back to STATIC_TASK_OPTIONS if the fetch fails.
+ */
+ private loadTasks(): void {
+ // Already fetched and cached
+ if (cachedTaskOptions !== null) {
+ this.taskOptions = cachedTaskOptions;
+ return;
+ }
+
+ // Previous fetch errored — show static list, don't retry automatically
+ if (tasksFetchError !== null) {
+ this.tasksError = tasksFetchError;
+ this.taskOptions = STATIC_TASK_OPTIONS;
+ return;
+ }
+
+ // Another component instance already has a fetch in flight — wait for it
+ if (tasksFetchSubscription !== null) {
+ this.tasksLoading = true;
+ // Poll for completion (the module-level cache will be set when done)
+ this.taskPollInterval = setInterval(() => {
+ if (cachedTaskOptions !== null || tasksFetchError !== null) {
+ clearInterval(this.taskPollInterval!);
+ this.taskPollInterval = null;
+ this.tasksLoading = false;
+ this.taskOptions = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+ if (tasksFetchError) this.tasksError = tasksFetchError;
+ this.cdr.detectChanges();
+ }
+ }, 200);
+ return;
+ }
+
+ this.tasksLoading = true;
+ this.tasksError = null;
+ this.cdr.detectChanges();
+
+ tasksFetchSubscription = this.http
+ .get<HuggingFaceTaskOption[]>(`${AppSettings.getApiEndpoint()}/huggingface/tasks`)
+ .pipe(
+ takeUntil(this.destroy$),
+ finalize(() => {
+ // If takeUntil fires before next/error, reset the module-level guard
+ // so the next component instance can start a fresh fetch.
+ if (cachedTaskOptions === null && tasksFetchError === null) {
+ tasksFetchSubscription = null;
+ }
+ })
+ )
+ .subscribe({
+ next: tasks => {
+ tasksFetchSubscription = null;
+ cachedTaskOptions = tasks.length > 0 ? tasks : STATIC_TASK_OPTIONS;
+ this.taskOptions = cachedTaskOptions;
+ this.tasksLoading = false;
+ this.cdr.detectChanges();
+ },
+ error: (err: unknown) => {
+ console.error("Failed to load HuggingFace tasks:", err);
+ tasksFetchSubscription = null;
+ tasksFetchError = "Could not load tasks from Hugging Face. Using default list.";
+ this.tasksError = tasksFetchError;
+ this.taskOptions = STATIC_TASK_OPTIONS;
+ this.tasksLoading = false;
+ this.cdr.detectChanges();
+ },
+ });
+ }
+
+ retryTasksLoad(): void {
+ tasksFetchError = null;
+ this.tasksError = null;
+ this.loadTasks();
+ }
+
+ // ── Task selection ──
+
+ onTaskSelected(tag: string): void {
+ const previousTask = this.getCurrentTaskTag() ?? this.selectedTaskTag;
+ this.snapshotTaskState(previousTask);
+ this.syncTaskSelection(tag, true);
+ this.restoreTaskState(tag);
+ this.searchText = "";
+ this.filteredModels = null;
+ this.loadAllModels();
+ }
+
+ // ── Data loading ──
+
+ /**
+ * Fetch ALL models for the selected task.
+ * The backend paginates through HF Hub internally and caches the result.
+ * The first request per task may be slow; subsequent requests are instant.
+ */
+ private loadAllModels(): void {
+ const tag = this.selectedTaskTag || "text-generation";
+
+ this.loading = false;
+ this.errorMessage = null;
+
+ // Fast path: cached on the frontend
+ if (allModelsByTag.has(tag)) {
+ this.allModels = allModelsByTag.get(tag)!;
+ this.truncated = truncatedByTag.has(tag);
+ this.goToPage(0);
+ return;
+ }
+
+ // Previous error
+ if (errorByTag.has(tag)) {
+ this.errorMessage = errorByTag.get(tag)!;
+ this.allModels = [];
+ this.pagedModels = [];
+ this.totalPages = 0;
+ return;
+ }
+
+ // Another instance is already fetching this task — wait for it
+ if (inFlightByTag.has(tag)) {
+ this.loading = true;
+ this.modelPollInterval = setInterval(() => {
+ if (allModelsByTag.has(tag) || errorByTag.has(tag)) {
+ clearInterval(this.modelPollInterval!);
+ this.modelPollInterval = null;
+ this.loading = false;
+ if (allModelsByTag.has(tag)) {
+ this.allModels = allModelsByTag.get(tag)!;
+ this.truncated = truncatedByTag.has(tag);
+ this.goToPage(0);
+ } else {
+ this.errorMessage = errorByTag.get(tag)!;
+ this.cdr.detectChanges();
+ }
+ }
+ }, 200);
+ return;
+ }
+
+ // Cancel previous
+ this.subscription?.unsubscribe();
+ this.subscription = null;
+
+ this.allModels = [];
+ this.pagedModels = [];
+ this.totalPages = 0;
+
+ // Show spinner immediately for the initial fetch — it can take a while
+ // as the backend pages through HF Hub for the first time.
+ this.loading = true;
+ this.cdr.detectChanges();
+
+ this.subscription = this.http
+ .get<
+ HuggingFaceModelOption[]
+ >(`${AppSettings.getApiEndpoint()}/huggingface/models?task=${encodeURIComponent(tag)}`)
+ .subscribe({
+ next: resp => {
+ const models = resp.body ?? [];
+ if (resp.headers.get(TRUNCATED_HEADER) === "true") {
+ truncatedByTag.add(tag);
+ }
+ allModelsByTag.set(tag, models);
+ inFlightByTag.delete(tag);
+ this.loading = false;
+ this.truncated = truncatedByTag.has(tag);
+ this.allModels = models;
+ this.goToPage(0);
+ },
+ error: (err: unknown) => {
+ console.error(`Failed to load HuggingFace models for task '${tag}':`, err);
+ const msg = "Failed to load models. Click retry to try again.";
+ errorByTag.set(tag, msg);
+ inFlightByTag.delete(tag);
+ this.loading = false;
+ this.errorMessage = msg;
+ this.cdr.detectChanges();
+ },
+ });
Review Comment:
`HttpClient.get<T>()` returns `T` by default, but this code treats the response as an `HttpResponse` (uses `resp.body` / `resp.headers`). This will not compile under `strict` TS and would break the truncation-header logic. Use `observe: "response"` (and optionally `finalize` to always clear `inFlightByTag`) so headers/body are available and `inFlightByTag` can't get stuck if the request is unsubscribed.
##########
common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDesc.scala:
##########
@@ -128,29 +128,15 @@ class HuggingFaceInferenceOpDesc extends PythonOperatorDescriptor {
private def codegenForTask(t: String): TaskCodegen =
registeredCodegens.getOrElse(t, TextGenCodegen)
- /**
- * The output column name to use in generated Python and in the output
- * schema. Falls back to the `"hf_response"` sentinel when the user
- * leaves the field null or blank.
- *
- * Shared between [[generatePythonCode]] and [[getOutputSchemas]] so the
- * two never drift apart (a divergence would cause the Python operator
- * to write to a column the schema didn't declare). Returns
- * [[EncodableString]] rather than `String` so the value flows into the
- * `pyb` template with the encoding annotation intact.
- */
- private def resolvedResultColumn: EncodableString =
- if (resultColumn == null || resultColumn.trim.isEmpty) "hf_response"
- else resultColumn
-
override def generatePythonCode(): String = {
val safeTask: EncodableString =
if (task == null || task.trim.isEmpty) "text-generation" else task
val safeModelId: EncodableString =
if (modelId == null) "" else modelId.trim
val safePromptCol: EncodableString =
if (promptColumn == null) "" else promptColumn
- val safeResultCol: EncodableString = resolvedResultColumn
+ val safeResultCol: EncodableString =
+ if (resultColumn == null || resultColumn.trim.isEmpty) "hf_response" else resultColumn
val safeSystemPrompt: EncodableString =
if (systemPrompt == null) "" else systemPrompt
Review Comment:
The operator currently only registers `TextGenCodegen` in `registeredCodegens`, so any non-text-generation task selected in the UI (image/audio/video/QA/etc.) will silently fall back to the text-generation payload/parse logic. Given this PR adds UI support for many tasks (and adds new `*Codegen` objects), those codegens also need to be wired into `registeredCodegens` (and imported) so the backend generates task-correct Python.
##########
common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/ImageTaskCodegen.scala:
##########
@@ -0,0 +1,166 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.amber.operator.huggingFace.codegen
+
+/**
+ * Codegen for the Hugging Face image-pipeline task family.
+ *
+ * Splits into two sub-families:
+ * - "image-only" tasks send raw image bytes as the request body and don't
+ * consume the prompt column: image-classification, object-detection,
+ * image-segmentation, image-to-text.
+ * - "image + prompt" tasks bundle a base64 image and a text prompt in a
+ * JSON payload: visual-question-answering, document-question-answering,
+ * zero-shot-image-classification, image-text-to-text, image-to-image.
+ *
+ * Per-row `current_image_bytes` is resolved upstream in
+ * [[PythonCodegenBase]]'s `process_table` (either from the operator's
+ * uploaded image or from `INPUT_IMAGE_COLUMN`). The image helpers
+ * (`_read_image_input`, `_compress_image_bytes`, `_image_input_as_base64`,
+ * `_read_binary_value`, `_looks_like_html`, `_html_to_image_bytes`,
+ * `_extract_json_arg`) live in PythonCodegenBase alongside the per-task
+ * tuples (`image_only_tasks`, `image_prompt_tasks`, `image_tasks`).
+ */
+object ImageTaskCodegen extends TaskCodegen {
+
+ /** Primary key for registration; the dispatcher maps every task in
+ * [[tasks]] to this codegen.
+ */
+ override val task: String = "image-classification"
+
+ /** All HF tasks routed through this codegen. */
+ override val tasks: Set[String] = Set(
+ // image-only
+ "image-classification",
+ "object-detection",
+ "image-segmentation",
+ "image-to-text",
+ // image + prompt
+ "visual-question-answering",
+ "document-question-answering",
+ "zero-shot-image-classification",
+ "image-text-to-text",
+ "image-to-image"
+ )
+
+ override def payloadPython(ctx: CodegenContext): String =
+ """ if task in image_only_tasks:
+ | payload = current_image_bytes
+ | use_raw_binary_body = True
+ | raw_binary_headers = image_headers
+ | elif task in ("visual-question-answering", "document-question-answering"):
+ | payload = {
+ | "inputs": {
+ | "image": self._image_input_as_base64(current_image_bytes),
+ | "question": prompt_value,
+ | }
+ | }
+ | elif task == "image-text-to-text":
+ | img_b64 = self._image_input_as_base64(current_image_bytes)
+ | payload = {
+ | "model": self.MODEL_ID,
+ | "messages": [{
+ | "role": "user",
+ | "content": [
+ | {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}},
+ | {"type": "text", "text": prompt_value if prompt_value else "Describe this image."},
+ | ],
+ | }],
+ | "max_tokens": self.MAX_NEW_TOKENS,
+ | }
+ | elif task == "image-to-image":
+ | payload = current_image_bytes
+ | 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()]
+ | 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."
+ | )
+ | payload = {
+ | "inputs": self._image_input_as_base64(current_image_bytes),
+ | "parameters": {"candidate_labels": labels},
+ | }
Review Comment:
`zero-shot-image-classification` still reuses `prompt_value` as the comma-separated candidate-label list (and the error message tells the user to put labels in the prompt column). In this PR, the UI/schema introduces a dedicated `candidateLabels` field (and other codegen, e.g. `QaRankingCodegen`, already uses `self.CANDIDATE_LABELS`). This mismatch will make the UI configuration misleading and can cause runtime failures even when `candidateLabels` is provided.
##########
frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts:
##########
@@ -42,29 +48,79 @@ 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, NgxJsonViewerModule],
})
Review Comment:
`NgxJsonViewerModule` is still listed in the component `imports`, but the template no longer uses `<ngx-json-viewer>`. Keeping unused standalone imports increases bundle size and can hide dead dependencies; it should be removed from `imports` (and then the TS import can be removed separately).
##########
frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html:
##########
@@ -161,7 +161,40 @@ <h5 class="rightAlign"><span [innerHTML]="compare(column.header, 'other')"></spa
class="table-cell"
nzEllipsis
(click)="open(i, row)">
- <span class="cell-content">{{ column.getCell(row) }}</span>
+ <span class="cell-content">
+ <ng-container *ngIf="isVideoCell(column.getCell(row)); else checkAudio">
+ <i
+ nz-icon
+ nzType="play-circle"
+ nzTheme="outline"
Review Comment:
The result-table cell template currently renders only an icon + "Play/View" text for media cells, but the PR description says media should be rendered inline in the result table via `<img>`, `<audio>`, and `<video>` tags. Either update the implementation to render the actual media elements in-table, or adjust the PR description/UX expectation if the intended behavior is "show label and open modal to view".
##########
frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts:
##########
@@ -0,0 +1,637 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit, OnDestroy, ChangeDetectorRef } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core";
+import { HttpClient } from "@angular/common/http";
+import { NzSelectModule } from "ng-zorro-antd/select";
+import { NzInputModule } from "ng-zorro-antd/input";
+import { NzSpinModule } from "ng-zorro-antd/spin";
+import { NzButtonModule } from "ng-zorro-antd/button";
+import { NzIconModule } from "ng-zorro-antd/icon";
+import { AppSettings } from "../../../common/app-setting";
+import { Subject, Subscription } from "rxjs";
+import { debounceTime, finalize, switchMap, takeUntil } from "rxjs/operators";
+
+export interface HuggingFaceModelOption {
+ id: string;
+ label: string;
+ pipeline_tag?: string;
+ downloads?: number;
+ likes?: number;
+}
+
+export interface HuggingFaceTaskOption {
+ tag: string;
+ label: string;
+}
+
+// ── Static fallback task list (used when the dynamic fetch fails) ──
+export const STATIC_TASK_OPTIONS: HuggingFaceTaskOption[] = [
+ { tag: "text-generation", label: "Text Generation" },
+ { tag: "automatic-speech-recognition", label: "Automatic Speech Recognition" },
+ { tag: "audio-classification", label: "Audio Classification" },
+ { tag: "text-classification", label: "Text Classification" },
+ { tag: "text-to-speech", label: "Text to Speech" },
+ { tag: "token-classification", label: "Token Classification" },
+ { tag: "question-answering", label: "Question Answering" },
+ { tag: "table-question-answering", label: "Table Question Answering" },
+ { tag: "zero-shot-classification", label: "Zero-Shot Classification" },
+ { tag: "translation", label: "Translation" },
+ { tag: "summarization", label: "Summarization" },
+ { tag: "feature-extraction", label: "Feature Extraction" },
+ { tag: "fill-mask", label: "Fill-Mask" },
+ { tag: "sentence-similarity", label: "Sentence Similarity" },
+ { tag: "text-ranking", label: "Text Ranking" },
+ { tag: "image-classification", label: "Image Classification" },
+ { tag: "object-detection", label: "Object Detection" },
+ { tag: "image-segmentation", label: "Image Segmentation" },
+ { tag: "image-to-text", label: "Image to Text" },
+ { tag: "visual-question-answering", label: "Visual Question Answering" },
+ { tag: "document-question-answering", label: "Document Question Answering" },
+ { tag: "zero-shot-image-classification", label: "Zero-Shot Image Classification" },
+];
+
+const PAGE_SIZE = 50;
+
+const TRUNCATED_HEADER = "X-Texera-Truncated";
+
+// ── Module-level caches (reused across component instances) ──
+const allModelsByTag: Map<string, HuggingFaceModelOption[]> = new Map();
+const truncatedByTag: Set<string> = new Set();
+const inFlightByTag: Map<string, Subscription> = new Map();
+const errorByTag: Map<string, string> = new Map();
+
+let cachedTaskOptions: HuggingFaceTaskOption[] | null = null;
+let tasksFetchSubscription: Subscription | null = null;
+let tasksFetchError: string | null = null;
+
+/** Clear all cached data (useful for tests or manual invalidation). */
+export function invalidateHuggingFaceModelCache(): void {
+ allModelsByTag.clear();
+ truncatedByTag.clear();
+ errorByTag.clear();
+ inFlightByTag.forEach(sub => sub.unsubscribe());
+ inFlightByTag.clear();
+ cachedTaskOptions = null;
+ tasksFetchError = null;
+ tasksFetchSubscription?.unsubscribe();
+ tasksFetchSubscription = null;
+}
+
+@Component({
+ selector: "texera-hugging-face-model-select",
+ templateUrl: "./hugging-face.component.html",
+ styleUrls: ["hugging-face.component.scss"],
+ imports: [
+ CommonModule,
+ FormsModule,
+ NzSelectModule,
+ NzInputModule,
+ NzSpinModule,
+ NzButtonModule,
+ NzIconModule,
+ FormlyModule,
+ ],
+})
+export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements OnInit, OnDestroy {
+ private readonly taskScopedKeys = [
+ "modelId",
+ "promptColumn",
+ "imageInput",
+ "audioInput",
+ "inputImageColumn",
+ "inputAudioColumn",
+ "candidateLabels",
+ "sentencesColumn",
+ "contextColumn",
+ "systemPrompt",
+ "maxNewTokens",
+ "temperature",
+ ] as const;
+ private readonly taskStateByTag = new Map<string, Partial<Record<(typeof this.taskScopedKeys)[number], unknown>>>();
+ // ── Task state ──
+ taskOptions: HuggingFaceTaskOption[] = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+ selectedTaskTag = "text-generation";
+ tasksLoading = false;
+ tasksError: string | null = null;
+
+ // ── All models for the current task (fetched once from backend, cached) ──
+ private allModels: HuggingFaceModelOption[] = [];
+
+ // ── Displayed state ──
+ pagedModels: HuggingFaceModelOption[] = [];
+ currentPage = 0;
+ totalPages = 0;
+
+ loading = false;
+ errorMessage: string | null = null;
+
+ // ── Truncation notice ──
+ truncated = false;
+
+ // ── Search state ──
+ searchText = "";
+ searchLoading = false;
+ private filteredModels: HuggingFaceModelOption[] | null = null;
+ private readonly searchSubject$ = new Subject<string>();
+ private searchSubscription: Subscription | null = null;
+
+ private readonly destroy$ = new Subject<void>();
+ private subscription: Subscription | null = null;
+ private taskPollInterval: ReturnType<typeof setInterval> | null = null;
+ private modelPollInterval: ReturnType<typeof setInterval> | null = null;
+ private initTimeout: ReturnType<typeof setTimeout> | null = null;
+
+ constructor(
+ private http: HttpClient,
+ private cdr: ChangeDetectorRef
+ ) {
+ super();
+ }
+
+ ngOnInit(): void {
+ const savedTag = this.getCurrentTaskTag();
+ this.selectedTaskTag = savedTag ?? this.selectedTaskTag;
+ this.syncTaskSelection(this.selectedTaskTag, false);
+ this.loadTasks();
+ this.loadAllModels();
+ this.setupServerSearch();
+ // Formly can attach sibling controls after this field initializes.
+ // Re-sync once the control tree settles so a fresh operator starts in a valid task state.
+ this.initTimeout = setTimeout(
+ () => this.syncTaskSelection(this.getCurrentTaskTag() ?? this.selectedTaskTag, false),
+ 0
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.subscription?.unsubscribe();
+ this.searchSubscription?.unsubscribe();
+ this.searchSubject$.complete();
+ if (this.taskPollInterval !== null) {
+ clearInterval(this.taskPollInterval);
+ }
+ if (this.modelPollInterval !== null) {
+ clearInterval(this.modelPollInterval);
+ }
+ if (this.initTimeout !== null) {
+ clearTimeout(this.initTimeout);
+ }
+ }
+
+ // ── Task loading ──
+
+ /**
+ * Fetch available pipeline tags from the backend, which proxies HuggingFace's /api/tasks.
+ * Falls back to STATIC_TASK_OPTIONS if the fetch fails.
+ */
+ private loadTasks(): void {
+ // Already fetched and cached
+ if (cachedTaskOptions !== null) {
+ this.taskOptions = cachedTaskOptions;
+ return;
+ }
+
+ // Previous fetch errored — show static list, don't retry automatically
+ if (tasksFetchError !== null) {
+ this.tasksError = tasksFetchError;
+ this.taskOptions = STATIC_TASK_OPTIONS;
+ return;
+ }
+
+ // Another component instance already has a fetch in flight — wait for it
+ if (tasksFetchSubscription !== null) {
+ this.tasksLoading = true;
+ // Poll for completion (the module-level cache will be set when done)
+ this.taskPollInterval = setInterval(() => {
+ if (cachedTaskOptions !== null || tasksFetchError !== null) {
+ clearInterval(this.taskPollInterval!);
+ this.taskPollInterval = null;
+ this.tasksLoading = false;
+ this.taskOptions = cachedTaskOptions ?? STATIC_TASK_OPTIONS;
+ if (tasksFetchError) this.tasksError = tasksFetchError;
+ this.cdr.detectChanges();
+ }
+ }, 200);
+ return;
+ }
+
+ this.tasksLoading = true;
+ this.tasksError = null;
+ this.cdr.detectChanges();
+
+ tasksFetchSubscription = this.http
+ .get<HuggingFaceTaskOption[]>(`${AppSettings.getApiEndpoint()}/huggingface/tasks`)
+ .pipe(
+ takeUntil(this.destroy$),
+ finalize(() => {
+ // If takeUntil fires before next/error, reset the module-level guard
+ // so the next component instance can start a fresh fetch.
+ if (cachedTaskOptions === null && tasksFetchError === null) {
+ tasksFetchSubscription = null;
+ }
+ })
+ )
+ .subscribe({
Review Comment:
`tasksFetchSubscription` is module-level/shared across component instances, but the request is canceled via `takeUntil(this.destroy$)`. If the initiating component is destroyed before the request completes, `cachedTaskOptions`/`tasksFetchError` remain null and any other instances currently polling will spin indefinitely. Since this is a shared fetch, it shouldn’t be tied to a single component’s lifecycle.
##########
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|ogg)(\?.*)?$/i) !== null ||
+ value.startsWith("data:video/") ||
+ value.startsWith("https://v3b.fal.media/files/")
+ );
Review Comment:
The helpers are not mutually exclusive for `.ogg` URLs: `isVideoUrl` and `isAudioUrl` both return true for `*.ogg`, so callers that check video first will render many Ogg Vorbis audio files as `<video>`. Since video Ogg is typically `.ogv`, it’s safer to treat `.ogg` as audio-only and use `.ogv` for video extension detection.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3444862483
##########
frontend/src/app/common/util/media-type.util.spec.ts:
##########
@@ -0,0 +1,128 @@
+/**
+ * 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.ogg")).toBe(true);
+ });
Review Comment:
This test currently asserts `.ogg` is a video URL. If `isVideoUrl` is adjusted to avoid conflicting with `isAudioUrl` (treat `.ogg` as audio, `.ogv` as video), update the test case accordingly so the helpers don’t classify the same extension as both audio and video.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "Ma77Ball (via GitHub)" <gi...@apache.org>.
Ma77Ball commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3576652624
##########
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/");
+}
Review Comment:
The extension checks match any bare string ending in a media-like token, so an ordinary text or filename cell such as `report.png` or `song.mp3` in a non-HuggingFace result column now renders as (usually broken) media across every result table. Tighten the extension match to require a real URL (http(s) or data:). This also broadens the hardcoded `v3b` fal subdomain, which can rotate. The 19 existing spec cases still pass.
```suggestion
export function isVideoUrl(value: string): boolean {
if (typeof value !== "string") return false;
return (
/^https?:\/\/.+\.(mp4|webm|ogv)(\?.*)?$/i.test(value) ||
value.startsWith("data:video/") ||
/^https:\/\/[a-z0-9-]+\.fal\.media\/files\//i.test(value)
);
}
export function isAudioUrl(value: string): boolean {
if (typeof value !== "string") return false;
return /^https?:\/\/.+\.(mp3|wav|ogg|m4a|flac)(\?.*)?$/i.test(value) || value.startsWith("data:audio/");
}
export function isImageUrl(value: string): boolean {
if (typeof value !== "string") return false;
return /^https?:\/\/.+\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i.test(value) || value.startsWith("data:image/");
}
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3454324739
##########
frontend/src/app/common/util/media-type.util.spec.ts:
##########
@@ -0,0 +1,128 @@
+/**
+ * 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.ogg")).toBe(true);
+ });
Review Comment:
Resolved from previous suggestion
##########
frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html:
##########
@@ -161,7 +161,40 @@ <h5 class="rightAlign"><span [innerHTML]="compare(column.header, 'other')"></spa
class="table-cell"
nzEllipsis
(click)="open(i, row)">
- <span class="cell-content">{{ column.getCell(row) }}</span>
+ <span class="cell-content">
+ <ng-container *ngIf="isVideoCell(column.getCell(row)); else checkAudio">
+ <i
+ nz-icon
+ nzType="play-circle"
+ nzTheme="outline"
Review Comment:
Fix: updated PR description regarding preview icon and render inline in the row detail modal
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3454567070
##########
common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDesc.scala:
##########
@@ -128,29 +128,15 @@ class HuggingFaceInferenceOpDesc extends PythonOperatorDescriptor {
private def codegenForTask(t: String): TaskCodegen =
registeredCodegens.getOrElse(t, TextGenCodegen)
- /**
- * The output column name to use in generated Python and in the output
- * schema. Falls back to the `"hf_response"` sentinel when the user
- * leaves the field null or blank.
- *
- * Shared between [[generatePythonCode]] and [[getOutputSchemas]] so the
- * two never drift apart (a divergence would cause the Python operator
- * to write to a column the schema didn't declare). Returns
- * [[EncodableString]] rather than `String` so the value flows into the
- * `pyb` template with the encoding annotation intact.
- */
- private def resolvedResultColumn: EncodableString =
- if (resultColumn == null || resultColumn.trim.isEmpty) "hf_response"
- else resultColumn
-
override def generatePythonCode(): String = {
val safeTask: EncodableString =
if (task == null || task.trim.isEmpty) "text-generation" else task
val safeModelId: EncodableString =
if (modelId == null) "" else modelId.trim
val safePromptCol: EncodableString =
if (promptColumn == null) "" else promptColumn
- val safeResultCol: EncodableString = resolvedResultColumn
+ val safeResultCol: EncodableString =
+ if (resultColumn == null || resultColumn.trim.isEmpty) "hf_response" else resultColumn
val safeSystemPrompt: EncodableString =
if (systemPrompt == null) "" else systemPrompt
Review Comment:
Fix: already in place through rebase
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "xuang7 (via GitHub)" <gi...@apache.org>.
xuang7 commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3608854636
##########
frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html:
##########
@@ -161,7 +161,38 @@ <h5 class="rightAlign"><span [innerHTML]="compare(column.header, 'other')"></spa
class="table-cell"
nzEllipsis
(click)="open(i, row)">
- <span class="cell-content">{{ column.getCell(row) }}</span>
+ <span class="cell-content">
+ <ng-container [ngSwitch]="getCellMediaType(i, columnIndex)">
Review Comment:
i is the index within basicTable.data, which is a page slice when isFrontPagination is true, but cellMediaTypes is indexed by position in the full currentResult. Maybe consider keying by the row object?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "xuang7 (via GitHub)" <gi...@apache.org>.
xuang7 commented on PR #5675:
URL: https://github.com/apache/texera/pull/5675#issuecomment-4907466453
Hi @Ma77Ball, could you review this PR when you get a chance?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on PR #5675:
URL: https://github.com/apache/texera/pull/5675#issuecomment-4836096884
@xuang7 Done. I have addressed the review comment, added test cases, and rebased/resolved conflicts. I will check back here again once the Codecov report gets officially updated to see if I need to make more test cases as needed.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3493870723
##########
frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts:
##########
@@ -42,29 +47,79 @@ 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 {
+ rowEntries: { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] =
+ [];
// 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<string, unknown> } =
+ inject(NZ_MODAL_DATA);
+ readonly operatorId: string = this.modalData.operatorId;
+ rowIndex: number = this.modalData.rowIndex;
currentDisplayRowData: Record<string, unknown> = {};
constructor(
public modal: NzModalRef<any, number>,
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;
+ }
+
+ private resolveMediaSrc(value: string): string {
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
+ return value;
+ }
+ return `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(value)}`;
Review Comment:
Remote URLs are fetched via HttpClient (which runs the JWT interceptor and adds Authorization: Bearer ...), converted to a blob URL, and the blob URL is bound to [src] instead. The native element never makes an authenticated request directly
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on code in PR #5675:
URL: https://github.com/apache/texera/pull/5675#discussion_r3454322993
##########
frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts:
##########
@@ -42,29 +48,79 @@ 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, NgxJsonViewerModule],
})
Review Comment:
Fix: removed `NgxJsonViewerModule` from both the imports array and the TS import
##########
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|ogg)(\?.*)?$/i) !== null ||
+ value.startsWith("data:video/") ||
+ value.startsWith("https://v3b.fal.media/files/")
+ );
Review Comment:
Fix: .ogg is now audio-only, .ogv is the video extension
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(frontend): add HuggingFace media output rendering in result panel [texera]
Posted by "juliethecao (via GitHub)" <gi...@apache.org>.
juliethecao commented on PR #5675:
URL: https://github.com/apache/texera/pull/5675#issuecomment-5012509420
PR description has been updated!
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org