diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index df9fb6f..c09044c 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -205,12 +205,20 @@ export type FlowValueKind = { multiple: boolean; }; +/** Client-facing hints for a file input. The server validates the actual bytes again. */ +export type FileInputConstraints = { + acceptedMediaTypes?: string[]; + maxFiles?: number | null; + maxTotalBytes?: number | null; +}; + export type FlowPort = { name: string; type: string; multiple: boolean; valueKinds?: FlowValueKind[]; valueSchema?: Record | null; + fileConstraints?: FileInputConstraints | null; }; export type FlowBlockConnection = { diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index 37182a4..93ec750 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -1,4 +1,12 @@ -import { FlowBlockConnection, FlowData, FlowNode, FlowNodeDependency, FlowPort, LLMDescriptor } from './flow'; +import { + FileInputConstraints, + FlowBlockConnection, + FlowData, + FlowNode, + FlowNodeDependency, + FlowPort, + LLMDescriptor +} from './flow'; import { BiasExecutionContext } from './bias-impact'; export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUSPENDED' | 'SUCCESS' | 'ERROR' | 'CANCELLED'; @@ -151,6 +159,7 @@ export type TaskExecutionGlobalInputDescriptor = { description?: string | null; cleanupPolicy?: string | null; multiple?: boolean; + fileConstraints?: FileInputConstraints | null; }; export type TaskExecutionStep = { diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html index a944d4e..9145b84 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html @@ -207,9 +207,13 @@ + @if (fileInputHint(executionInput); as hint) { +
{{ hint }}
+ } } @else if (isMultipleInput(executionInput)) { @if (itemsOpen(executionInput)) {
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts index f194667..a139204 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts @@ -1,7 +1,12 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; -import { EditableExecutionInput, TaskExecutionInputsPanelComponent, parseJsonArrayInput } from './task-execution-inputs-panel'; +import { + EditableExecutionInput, + TaskExecutionInputsPanelComponent, + parseJsonArrayInput, + validateFileSelection +} from './task-execution-inputs-panel'; function makeInput(overrides: Partial = {}): EditableExecutionInput { const input: EditableExecutionInput = { @@ -50,6 +55,55 @@ async function build(inputs: EditableExecutionInput[], options: { describe('TaskExecutionInputsPanelComponent', () => { afterEach(() => TestBed.resetTestingModule()); + it('validates MCP image constraints before upload', () => { + const imageInput = makeInput({ + type: 'FILE', + multiple: true, + fileConstraints: { + acceptedMediaTypes: ['image/png', 'image/jpeg', 'image/webp'], + maxFiles: 4, + maxTotalBytes: 15_000_000 + } + }); + const png = new File(['image'], 'reference.png', { type: 'image/png' }); + const gif = new File(['image'], 'reference.gif', { type: 'image/gif' }); + + expect(validateFileSelection(imageInput, [png])).toBeNull(); + expect(validateFileSelection(imageInput, [png, png, png, png, png])).toContain('At most 4 files'); + expect(validateFileSelection(imageInput, [gif])).toContain('PNG, JPEG, WebP'); + }); + + it('validates MCP PDF aggregate budget before upload', () => { + const documentInput = makeInput({ + type: 'FILE', + multiple: true, + fileConstraints: { + acceptedMediaTypes: ['application/pdf'], + maxFiles: 4, + maxTotalBytes: 32_000_000 + } + }); + const tooLarge = new File([new Uint8Array(32_000_001)], 'specification.pdf', { type: 'application/pdf' }); + + expect(validateFileSelection(documentInput, [tooLarge])).toContain('32,000,000 bytes'); + }); + + it('renders the MIME filter and server-provided upload hint', async () => { + const fixture = await build([makeInput({ + type: 'FILE', + multiple: true, + fileConstraints: { + acceptedMediaTypes: ['application/pdf'], + maxFiles: 4, + maxTotalBytes: 32_000_000 + } + })]); + + const picker = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement; + expect(picker.accept).toBe('application/pdf'); + expect(fixture.nativeElement.textContent).toContain('Allowed: PDF; up to 4 files; total up to 32,000,000 bytes.'); + }); + it('counts every input, node ones included, since all of them are required', async () => { // A step without its manual input never reaches READY, so a node input blocks the start just // as a global one does and must be part of the tally. diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts index 6905a40..329f9f9 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts @@ -8,6 +8,7 @@ import { ModalShellComponent } from '@shared/modal-shell/modal-shell'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TaskExecutionAuthorizationRequirement } from '@models/task-execution'; +import { FileInputConstraints } from '@models/flow'; export type JsonArrayParseResult = | { values: string[]; error: null } @@ -72,6 +73,7 @@ export type EditableExecutionInput = { subtitle: string; type: string; multiple: boolean; + fileConstraints?: FileInputConstraints | null; value: string | string[]; /** * Whether a value is actually stored in the execution - unsaved edits do not count. Node inputs @@ -80,6 +82,68 @@ export type EditableExecutionInput = { provided: boolean; }; +const FILE_EXTENSION_BY_MEDIA_TYPE: Record = { + 'image/png': ['.png'], + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/webp': ['.webp'], + 'application/pdf': ['.pdf'] +}; + +function formatBytes(bytes: number): string { + return `${new Intl.NumberFormat('en-US').format(bytes)} bytes`; +} + +function mediaTypeLabel(mediaTypes: string[]): string { + const labels = mediaTypes.map((mediaType) => { + if (mediaType === 'image/png') return 'PNG'; + if (mediaType === 'image/jpeg') return 'JPEG'; + if (mediaType === 'image/webp') return 'WebP'; + if (mediaType === 'application/pdf') return 'PDF'; + return mediaType; + }); + return labels.join(', '); +} + +function inferredMediaType(file: File, acceptedMediaTypes: string[]): string | null { + const browserType = file.type.trim().toLowerCase(); + if (acceptedMediaTypes.includes(browserType)) return browserType; + const lowerName = file.name.toLowerCase(); + return acceptedMediaTypes.find((mediaType) => + (FILE_EXTENSION_BY_MEDIA_TYPE[mediaType] ?? []).some((extension) => lowerName.endsWith(extension))) ?? null; +} + +/** + * Validates an upload before it crosses the network. This mirrors the public constraints on the + * descriptor; the MCP executor still validates byte signatures and totals authoritatively. + */ +export function validateFileSelection(input: EditableExecutionInput, files: File[]): string | null { + if (!files.length) return 'Choose at least one file.'; + if (!input.multiple && files.length !== 1) return 'This input accepts one file.'; + + const constraints = input.fileConstraints; + if (!constraints) return null; + const maxFiles = input.multiple + ? constraints.maxFiles ?? Number.MAX_SAFE_INTEGER + : 1; + if (files.length > maxFiles) { + return `At most ${maxFiles} ${maxFiles === 1 ? 'file is' : 'files are'} allowed.`; + } + + const acceptedMediaTypes = constraints.acceptedMediaTypes ?? []; + for (const file of files) { + if (file.size === 0) return `${file.name} is empty.`; + if (acceptedMediaTypes.length && !inferredMediaType(file, acceptedMediaTypes)) { + return `${file.name} must be ${mediaTypeLabel(acceptedMediaTypes)}.`; + } + } + + const totalBytes = files.reduce((sum, file) => sum + file.size, 0); + if (constraints.maxTotalBytes != null && totalBytes > constraints.maxTotalBytes) { + return `The selected files total ${formatBytes(totalBytes)}; the limit is ${formatBytes(constraints.maxTotalBytes)}.`; + } + return null; +} + @Component({ selector: 'app-task-execution-inputs-panel', imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule, ModalShellComponent], @@ -309,6 +373,22 @@ export class TaskExecutionInputsPanelComponent { return input.multiple; } + fileAccept(input: EditableExecutionInput): string | null { + const mediaTypes = input.fileConstraints?.acceptedMediaTypes ?? []; + return mediaTypes.length ? mediaTypes.join(',') : null; + } + + fileInputHint(input: EditableExecutionInput): string | null { + const constraints = input.fileConstraints; + if (!constraints) return null; + const parts: string[] = []; + const mediaTypes = constraints.acceptedMediaTypes ?? []; + if (mediaTypes.length) parts.push(`Allowed: ${mediaTypeLabel(mediaTypes)}`); + if (constraints.maxFiles != null) parts.push(`up to ${constraints.maxFiles} files`); + if (constraints.maxTotalBytes != null) parts.push(`total up to ${formatBytes(constraints.maxTotalBytes)}`); + return parts.length ? `${parts.join('; ')}.` : null; + } + textValues(input: EditableExecutionInput): string[] { if (Array.isArray(input.value)) { return input.value; diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 5229630..028d992 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -32,6 +32,7 @@ import { ExecutionVaultCredential, LlmProviderCapability } from '@models/llm-pro import { VaultSecret } from '@models/assistant'; import { EditableExecutionInput, + validateFileSelection, InputCopySource, TaskExecutionInputsPanelComponent } from '@shared/task-execution-inputs-panel/task-execution-inputs-panel'; @@ -814,6 +815,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { subtitle: inputName, type: String(descriptor?.kind ?? 'TEXT').toUpperCase(), multiple: Boolean(descriptor?.multiple), + fileConstraints: descriptor?.fileConstraints ?? null, // The backend's own answer, and it reports what is stored - so an unsaved edit does not // make an input look satisfied. provided: !missingGlobalInputNames.has(inputName), @@ -843,6 +845,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { subtitle: inputName, type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(), multiple: Boolean(input.descriptor?.multiple), + fileConstraints: input.descriptor?.fileConstraints ?? null, // No per-input flag from the backend here, so judge the stored value, ignoring pending. provided: hasStoredValue(rawValue), value: pendingValue ?? normalizeEditableInputValue(rawValue, Boolean(input.descriptor?.multiple)) @@ -1265,6 +1268,12 @@ export class TaskExecutionViewerComponent implements OnDestroy { const executionId = this.execution()?.id; if (!executionId || !files.length) return; + const validationError = validateFileSelection(input, files); + if (validationError) { + this.setInputError(input.key, validationError); + return; + } + this.setInputSaving(input.key, true); const request$ = input.scope === 'global' ? (