Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

529 lines
15 KiB
TypeScript

import type {
Workflow,
WFNode,
WFEdge,
NodeDataValue,
NodeStatus,
FileValue,
Port
} from "./workflow-types";
import { toLegacyShape } from "./workflow-migration";
import { topoSort } from "./workflow-graph";
type StatusCallback = (
nodeId: string,
status: NodeStatus,
error?: string,
errorType?: string
) => void;
type OutputCallback = (
nodeId: string,
portId: string,
value: NodeDataValue
) => void;
type ServerCallFn = (
spaceId: string,
endpoint: string,
argsJson: string
) => Promise<string>;
type ServerCallModelFn = (
modelId: string,
pipelineTag: string,
argsJson: string,
provider?: string
) => Promise<string>;
type ServerFetchDatasetFn = (
datasetId: string,
config: string,
split: string,
offset: string,
length: string
) => Promise<string>;
type ServerCallPyFn = (fnName: string, argsJson: string) => Promise<string>;
/**
* Browser-side streaming for text-generation tasks. Returns the final
* accumulated string. `onChunk` fires per delta so the executor can call
* `onOutput` incrementally and the UI updates live as tokens arrive.
*/
type StreamTextFn = (
modelId: string,
prompt: string,
provider: string | undefined,
signal: AbortSignal | undefined,
onChunk: (delta: string, accumulated: string) => void
) => Promise<string>;
function resolveInputs(
node: WFNode,
edges: WFEdge[],
dataMap: Record<string, Record<string, NodeDataValue>>
): Record<string, NodeDataValue> {
const resolved: Record<string, NodeDataValue> = {};
for (const port of node.inputs) {
const edge = edges.find(
(e) => e.to_node_id === node.id && e.to_port_id === port.id
);
if (edge) {
resolved[port.id] =
dataMap[edge.from_node_id]?.[edge.from_port_id] ?? null;
} else {
resolved[port.id] =
node.data?.[port.id] ?? (port.default_value as NodeDataValue) ?? null;
}
}
return resolved;
}
async function toGradioArg(value: NodeDataValue): Promise<unknown> {
if (value === null) return null;
if (typeof value === "string") return value;
if (typeof value === "number") return value;
if (typeof value === "boolean") return value;
if (Array.isArray(value)) return value;
const fileVal = value as FileValue;
// Blob URLs need to be uploaded to our Gradio server first
if (fileVal.url.startsWith("blob:") || fileVal.url.startsWith("data:")) {
try {
const response = await fetch(fileVal.url);
if (!response.ok)
throw new Error(`Blob fetch failed: ${response.status}`);
const blob = await response.blob();
const formData = new FormData();
formData.append("files", blob, fileVal.name || "file");
// Try /gradio_api/upload first, then /upload
for (const path of ["/gradio_api/upload", "/upload"]) {
const uploadRes = await fetch(path, { method: "POST", body: formData });
if (uploadRes.ok) {
const files = await uploadRes.json();
return { path: files[0], url: files[0] };
}
}
throw new Error("Upload failed");
} catch (err) {
console.error("[Executor] File upload error:", err);
throw new Error(
`Failed to upload file: ${err instanceof Error ? err.message : err}`
);
}
}
// Remote URLs can be passed directly
return { url: fileVal.url };
}
const MEDIA_PORT_TYPES = new Set([
"image",
"audio",
"video",
"file",
"gallery",
"model3d"
]);
function output_matches_port_type(item: unknown, portType: string): boolean {
if (item === null || item === undefined) return false;
if (MEDIA_PORT_TYPES.has(portType)) {
if (typeof item === "string") {
return /^(https?:|blob:|data:|\/)/.test(item);
}
return (
typeof item === "object" &&
("path" in (item as object) || "url" in (item as object))
);
}
if (portType === "text") return typeof item === "string";
if (portType === "number") return typeof item === "number";
if (portType === "boolean") return typeof item === "boolean";
if (portType === "json")
return typeof item === "object" || Array.isArray(item);
return true;
}
function pick_response_item(
port: { type: string; output_index?: number },
port_index: number,
output_data: unknown[],
total_ports: number
): unknown {
const primary =
typeof port.output_index === "number"
? output_data[port.output_index]
: total_ports === 1 && output_data.length > 1
? null
: output_data[port_index];
if (primary != null && output_matches_port_type(primary, port.type)) {
return primary;
}
const shape_match = output_data.find((item) =>
output_matches_port_type(item, port.type)
);
if (shape_match !== undefined) return shape_match;
return primary ?? output_data[0] ?? null;
}
function fromGradioOutput(result: unknown, portType: string): NodeDataValue {
if (result === null || result === undefined) return null;
if (
typeof result === "object" &&
!Array.isArray(result) &&
(result as Record<string, unknown>).__type__ === "update" &&
"value" in (result as Record<string, unknown>)
) {
return fromGradioOutput(
(result as Record<string, unknown>).value,
portType
);
}
if (Array.isArray(result)) {
if (result.length === 0) return null;
return fromGradioOutput(result[result.length - 1], portType);
}
if (typeof result === "number") return result;
if (typeof result === "boolean") return result;
if (typeof result === "string") {
if (
portType !== "text" &&
(result.startsWith("http://") ||
result.startsWith("https://") ||
result.startsWith("blob:") ||
result.startsWith("data:"))
) {
return {
name: "output",
url: result,
mime:
portType === "image"
? "image/png"
: portType === "audio"
? "audio/wav"
: "video/mp4"
} satisfies FileValue;
}
return result;
}
if (
typeof result === "object" &&
"url" in (result as Record<string, unknown>)
) {
const obj = result as Record<string, unknown>;
return {
name: (obj.orig_name as string) ?? "output",
url: obj.url as string,
mime: (obj.mime_type as string) ?? "application/octet-stream"
} satisfies FileValue;
}
return String(result);
}
export async function executeWorkflow(
workflow: Workflow,
onStatus: StatusCallback,
onOutput: OutputCallback,
signal?: AbortSignal,
serverCallSpace?: ServerCallFn,
serverCallModel?: ServerCallModelFn,
serverFetchDataset?: ServerFetchDatasetFn,
serverCallFn?: ServerCallPyFn,
stream_text_generation?: StreamTextFn
): Promise<void> {
const { nodes, edges } = toLegacyShape(workflow);
const dataMap: Record<string, Record<string, NodeDataValue>> = {};
const failed_nodes = new Map<string, string>();
function mark_node_failed(node: WFNode, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err);
const errorType = (err as { errorType?: string })?.errorType;
onStatus(node.id, "error", msg, errorType);
failed_nodes.set(node.id, node.label);
dataMap[node.id] = {};
for (const port of node.outputs) dataMap[node.id][port.id] = null;
}
function missing_input_message(node: WFNode, port: Port): string {
const edge = edges.find(
(e) => e.to_node_id === node.id && e.to_port_id === port.id
);
if (edge) {
const upstream = nodes.find((n) => n.id === edge.from_node_id);
if (upstream && failed_nodes.has(upstream.id)) {
return `"${port.label}" is missing — upstream node "${upstream.label}" failed`;
}
}
return `"${port.label}" is missing — an upstream node may have failed`;
}
// Seed input nodes (including component nodes with no incoming edges)
for (const node of nodes.filter((n) => {
if (n.kind === "input") return true;
if (n.kind === "component") {
return !edges.some((e) => e.to_node_id === n.id);
}
return false;
})) {
dataMap[node.id] = { ...(node.data ?? {}) };
}
// Build layers for parallel execution
const sorted = topoSort(nodes, edges);
const layers: WFNode[][] = [];
for (const node of sorted) {
const depDepth = edges
.filter((e) => e.to_node_id === node.id)
.map((e) =>
layers.findIndex((layer) => layer.some((n) => n.id === e.from_node_id))
)
.reduce((max, d) => Math.max(max, d), -1);
const layerIdx = depDepth + 1;
while (layers.length <= layerIdx) layers.push([]);
layers[layerIdx].push(node);
}
async function executeNode(node: WFNode): Promise<void> {
if (signal?.aborted) return;
// Component nodes with no incoming edges act as inputs
const isComponentInput =
node.kind === "component" && !edges.some((e) => e.to_node_id === node.id);
// Dataset operators take a single scalar "row_index" input. When wired,
// upstream supplies the offset; when unwired the inline default (set at
// picker time, edited on the node body) is used.
if (node.source === "dataset" && node.dataset_id) {
onStatus(node.id, "running");
try {
if (!serverFetchDataset)
throw new Error("Dataset fetch function not available");
const inputs = resolveInputs(node, edges, dataMap);
const raw = inputs["row_index"] ?? 0;
const n = typeof raw === "number" ? raw : Number(raw);
const offset = Math.max(0, Math.trunc(Number.isFinite(n) ? n : 0));
const resultJson = await serverFetchDataset(
node.dataset_id,
node.dataset_config ?? "default",
node.dataset_split ?? "train",
String(offset),
"1"
);
const result = JSON.parse(resultJson);
if (result.error) throw new Error(result.error);
const row = result.rows?.[0] ?? {};
dataMap[node.id] = {};
for (const port of node.outputs) {
const value = row[port.label] ?? null;
// Convert image/audio objects to file values
if (value && typeof value === "object" && "src" in value) {
const fileVal = {
name: port.label,
url: value.src,
mime: "application/octet-stream"
};
dataMap[node.id][port.id] = fileVal as NodeDataValue;
onOutput(node.id, port.id, fileVal as NodeDataValue);
} else {
dataMap[node.id][port.id] = value as NodeDataValue;
onOutput(node.id, port.id, value as NodeDataValue);
}
}
onStatus(node.id, "done");
} catch (err) {
mark_node_failed(node, err);
}
return;
}
if (node.kind === "input" || isComponentInput) {
onStatus(node.id, "done");
return;
}
// Component nodes with incoming edges act as outputs
const isComponentOutput =
node.kind === "component" && edges.some((e) => e.to_node_id === node.id);
if (node.kind === "output" || isComponentOutput) {
const inputs = resolveInputs(node, edges, dataMap);
const inputPort = node.inputs[0];
if (inputPort) {
const value = inputs[inputPort.id];
dataMap[node.id] = { [inputPort.id]: value };
onOutput(node.id, inputPort.id, value);
const outputPort = node.outputs[0];
if (outputPort) {
dataMap[node.id][outputPort.id] = value;
}
}
onStatus(node.id, "done");
return;
}
// Python function nodes (FnNode) call back to the Python server
if (node.source === "fn" && node.fn) {
onStatus(node.id, "running");
try {
if (!serverCallFn)
throw new Error("Python function call not available");
const inputs = resolveInputs(node, edges, dataMap);
for (const port of node.inputs) {
if (port.required && inputs[port.id] === null) {
throw new Error(missing_input_message(node, port));
}
}
const args = node.inputs.map((port) => inputs[port.id]);
const resultJson = await serverCallFn(node.fn, JSON.stringify(args));
const resultData = JSON.parse(resultJson);
if (
resultData &&
typeof resultData === "object" &&
"error" in resultData
) {
throw new Error(resultData.error);
}
dataMap[node.id] = {};
const outputData = Array.isArray(resultData)
? resultData
: [resultData];
node.outputs.forEach((port, i) => {
const value = fromGradioOutput(outputData[i] ?? null, port.type);
dataMap[node.id][port.id] = value;
onOutput(node.id, port.id, value);
});
onStatus(node.id, "done");
} catch (err) {
mark_node_failed(node, err);
}
return;
}
if (node.kind === "transform" && (node.space_id || node.model_id)) {
onStatus(node.id, "running");
try {
const inputs = resolveInputs(node, edges, dataMap);
for (const port of node.inputs) {
if (port.required && inputs[port.id] === null) {
throw new Error(missing_input_message(node, port));
}
}
const args = await Promise.all(
node.inputs.map((port) => toGradioArg(inputs[port.id]))
);
let resultJson: string;
if (node.source === "model" && node.model_id) {
if (!serverCallModel) {
throw new Error("Model call function not available");
}
// Prefer browser-side streaming for chat-completion-compatible
// text tasks so the UI receives tokens as they arrive. The
// Python path stays for every other task.
const tag = node.pipeline_tag ?? "text-generation";
const streamable =
(tag === "text-generation" ||
tag === "text2text-generation" ||
tag === "conversational") &&
!!stream_text_generation;
if (streamable) {
const prompt =
typeof args[0] === "string" ? args[0] : String(args[0] ?? "");
const outputPort = node.outputs[0];
const final = await stream_text_generation!(
node.model_id,
prompt,
node.provider,
signal,
(_delta, accumulated) => {
if (outputPort) onOutput(node.id, outputPort.id, accumulated);
}
);
resultJson = JSON.stringify([final]);
} else {
resultJson = await Promise.race([
serverCallModel(
node.model_id,
tag,
JSON.stringify(args),
node.provider
),
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error("Request timed out — model may be loading")
),
300000
)
)
]);
}
} else {
const endpointName = node.endpoint ?? "/predict";
if (!serverCallSpace) {
throw new Error("Server call function not available");
}
resultJson = await Promise.race([
serverCallSpace(node.space_id!, endpointName, JSON.stringify(args)),
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error("Request timed out — Space may be overloaded")
),
300000
)
)
]);
}
const resultData = JSON.parse(resultJson);
// Check for structured error from Python
if (
resultData &&
typeof resultData === "object" &&
"error" in resultData
) {
const suggestion = resultData.suggestion;
const errorType = resultData.error_type;
const title = resultData.title;
const rawMsg = title
? `${title}: ${resultData.error}`
: resultData.error;
const msg = suggestion || rawMsg;
const err = new Error(msg);
(err as any).errorType = errorType;
throw err;
}
dataMap[node.id] = {};
const outputData = Array.isArray(resultData)
? resultData
: [resultData];
node.outputs.forEach((port, i) => {
const raw = pick_response_item(
port,
i,
outputData,
node.outputs.length
);
const value = fromGradioOutput(raw, port.type);
dataMap[node.id][port.id] = value;
onOutput(node.id, port.id, value);
});
onStatus(node.id, "done");
} catch (err) {
mark_node_failed(node, err);
}
}
}
// Execute layers in parallel
for (const layer of layers) {
if (signal?.aborted) break;
await Promise.all(layer.map(executeNode));
}
}