Validate MCP file uploads in execution UI
This commit is contained in:
parent
86e61690bb
commit
81f2a890f2
|
|
@ -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<string, unknown> | null;
|
||||
fileConstraints?: FileInputConstraints | null;
|
||||
};
|
||||
|
||||
export type FlowBlockConnection = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -207,9 +207,13 @@
|
|||
<input
|
||||
type="file"
|
||||
class="block w-full text-sm text-slate-700"
|
||||
[attr.accept]="fileAccept(executionInput)"
|
||||
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
|
||||
[disabled]="readOnly()"
|
||||
(change)="onFileInputChange(executionInput, $event)" />
|
||||
@if (fileInputHint(executionInput); as hint) {
|
||||
<div class="mt-1 text-[11px] text-slate-500">{{ hint }}</div>
|
||||
}
|
||||
} @else if (isMultipleInput(executionInput)) {
|
||||
@if (itemsOpen(executionInput)) {
|
||||
<div class="inputs-panel-items">
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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.
|
||||
|
|
|
|||
|
|
@ -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<string, string[]> = {
|
||||
'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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue