feat(frontend): Refactor RunBlock components for improved output handling

- Removed the `getBlockLabel` function from `helpers.tsx` to streamline code.
- Introduced `getAccordionMeta` function in `helpers.tsx` to enhance output metadata retrieval for RunBlock.
- Updated `RunBlock.tsx` to utilize the new `getAccordionMeta` function, improving the display logic for different output types.
- Added new components (`BlockOutputCard`, `SetupRequirementsCard`, `ErrorCard`) to encapsulate output rendering, enhancing code organization and readability.

These changes improve the clarity and maintainability of the RunBlock component, providing a better user experience through more structured output handling.
This commit is contained in:
abhi1992002
2026-02-09 11:44:31 +05:30
parent 1181dd9fae
commit ee6eef66cf
6 changed files with 562 additions and 264 deletions

View File

@@ -3,13 +3,8 @@
import type { ToolUIPart } from "ai";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
ChatCredentialsSetup,
type CredentialInfo,
} from "@/components/contextual/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
import {
formatMaybeJson,
getAccordionMeta,
getAnimationText,
getRunBlockToolOutput,
isRunBlockBlockOutput,
@@ -18,6 +13,9 @@ import {
ToolIcon,
type RunBlockToolOutput,
} from "./helpers";
import { BlockOutputCard } from "./components/BlockOutputCard/BlockOutputCard";
import { SetupRequirementsCard } from "./components/SetupRequirementsCard/SetupRequirementsCard";
import { ErrorCard } from "./components/ErrorCard/ErrorCard";
export interface RunBlockToolPart {
type: string;
@@ -31,165 +29,8 @@ interface Props {
part: RunBlockToolPart;
}
function getAccordionMeta(output: RunBlockToolOutput): {
badgeText: string;
title: string;
description?: string;
} {
if (isRunBlockBlockOutput(output)) {
const keys = Object.keys(output.outputs ?? {});
return {
badgeText: "Run block",
title: output.block_name,
description:
keys.length > 0
? `${keys.length} output key${keys.length === 1 ? "" : "s"}`
: output.message,
};
}
if (isRunBlockSetupRequirementsOutput(output)) {
const missingCredsCount = Object.keys(
(output.setup_info.user_readiness?.missing_credentials ?? {}) as Record<
string,
unknown
>,
).length;
return {
badgeText: "Run block",
title: output.setup_info.agent_name,
description:
missingCredsCount > 0
? `Missing ${missingCredsCount} credential${missingCredsCount === 1 ? "" : "s"}`
: output.message,
};
}
return { badgeText: "Run block", title: "Error" };
}
function coerceMissingCredentials(
rawMissingCredentials: unknown,
): CredentialInfo[] {
const missing =
rawMissingCredentials && typeof rawMissingCredentials === "object"
? (rawMissingCredentials as Record<string, unknown>)
: {};
const validTypes = new Set([
"api_key",
"oauth2",
"user_password",
"host_scoped",
]);
const results: CredentialInfo[] = [];
Object.values(missing).forEach((value) => {
if (!value || typeof value !== "object") return;
const cred = value as Record<string, unknown>;
const provider =
typeof cred.provider === "string" ? cred.provider.trim() : "";
if (!provider) return;
const providerName =
typeof cred.provider_name === "string" && cred.provider_name.trim()
? cred.provider_name.trim()
: provider.replace(/_/g, " ");
const title =
typeof cred.title === "string" && cred.title.trim()
? cred.title.trim()
: providerName;
const types =
Array.isArray(cred.types) && cred.types.length > 0
? cred.types
: typeof cred.type === "string"
? [cred.type]
: [];
const credentialTypes = types
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(
(t): t is "api_key" | "oauth2" | "user_password" | "host_scoped" =>
validTypes.has(t),
);
if (credentialTypes.length === 0) return;
const scopes = Array.isArray(cred.scopes)
? cred.scopes.filter((s): s is string => typeof s === "string")
: undefined;
const item: CredentialInfo = {
provider,
providerName,
credentialTypes,
title,
};
if (scopes && scopes.length > 0) {
item.scopes = scopes;
}
results.push(item);
});
return results;
}
function coerceExpectedInputs(rawInputs: unknown): Array<{
name: string;
title: string;
type: string;
description?: string;
required: boolean;
}> {
if (!Array.isArray(rawInputs)) return [];
const results: Array<{
name: string;
title: string;
type: string;
description?: string;
required: boolean;
}> = [];
rawInputs.forEach((value, index) => {
if (!value || typeof value !== "object") return;
const input = value as Record<string, unknown>;
const name =
typeof input.name === "string" && input.name.trim()
? input.name.trim()
: `input-${index}`;
const title =
typeof input.title === "string" && input.title.trim()
? input.title.trim()
: name;
const type = typeof input.type === "string" ? input.type : "unknown";
const description =
typeof input.description === "string" && input.description.trim()
? input.description.trim()
: undefined;
const required = Boolean(input.required);
const item: {
name: string;
title: string;
type: string;
description?: string;
required: boolean;
} = { name, title, type, required };
if (description) item.description = description;
results.push(item);
});
return results;
}
export function RunBlockTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
@@ -204,12 +45,6 @@ export function RunBlockTool({ part }: Props) {
isRunBlockSetupRequirementsOutput(output) ||
isRunBlockErrorOutput(output));
function handleAllCredentialsComplete() {
onSend(
"I've configured the required credentials. Please re-run the block now.",
);
}
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -225,99 +60,13 @@ export function RunBlockTool({ part }: Props) {
{...getAccordionMeta(output)}
defaultExpanded={isRunBlockSetupRequirementsOutput(output)}
>
{isRunBlockBlockOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{Object.entries(output.outputs ?? {}).map(([key, items]) => (
<div key={key} className="rounded-2xl border bg-background p-3">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-foreground">
{key}
</p>
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{items.length} item{items.length === 1 ? "" : "s"}
</span>
</div>
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{formatMaybeJson(items.slice(0, 3))}
</pre>
</div>
))}
</div>
)}
{isRunBlockBlockOutput(output) && <BlockOutputCard output={output} />}
{isRunBlockSetupRequirementsOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{coerceMissingCredentials(
output.setup_info.user_readiness?.missing_credentials,
).length > 0 && (
<ChatCredentialsSetup
credentials={coerceMissingCredentials(
output.setup_info.user_readiness?.missing_credentials,
)}
agentName={output.setup_info.agent_name}
message={output.message}
onAllCredentialsComplete={handleAllCredentialsComplete}
onCancel={() => {}}
/>
)}
{coerceExpectedInputs(
(output.setup_info.requirements as Record<string, unknown>)
?.inputs,
).length > 0 && (
<div className="rounded-2xl border bg-background p-3">
<p className="text-xs font-medium text-foreground">
Expected inputs
</p>
<div className="mt-2 grid gap-2">
{coerceExpectedInputs(
(
output.setup_info.requirements as Record<
string,
unknown
>
)?.inputs,
).map((input) => (
<div key={input.name} className="rounded-xl border p-2">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-foreground">
{input.title}
</p>
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{input.required ? "Required" : "Optional"}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{input.name} {input.type}
{input.description ? `${input.description}` : ""}
</p>
</div>
))}
</div>
</div>
)}
</div>
<SetupRequirementsCard output={output} />
)}
{isRunBlockErrorOutput(output) && (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.error)}
</pre>
)}
{output.details && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.details)}
</pre>
)}
</div>
)}
{isRunBlockErrorOutput(output) && <ErrorCard output={output} />}
</ToolAccordion>
)}
</div>

View File

@@ -0,0 +1,144 @@
"use client";
import React, { useState } from "react";
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
import { Button } from "@/components/atoms/Button/Button";
import type { BlockOutputResponse } from "@/app/api/__generated__/models/blockOutputResponse";
import { formatMaybeJson } from "../../helpers";
interface Props {
output: BlockOutputResponse;
}
const COLLAPSED_LIMIT = 3;
function resolveWorkspaceUrl(src: string): string {
const withoutPrefix = src.replace("workspace://", "");
const fileId = withoutPrefix.split("#")[0];
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
return `/api/proxy${apiPath}`;
}
function getWorkspaceMimeHint(src: string): string | undefined {
const hashIndex = src.indexOf("#");
if (hashIndex === -1) return undefined;
return src.slice(hashIndex + 1) || undefined;
}
function isWorkspaceRef(value: unknown): value is string {
return typeof value === "string" && value.startsWith("workspace://");
}
function WorkspaceMedia({ value }: { value: string }) {
const [imgFailed, setImgFailed] = useState(false);
const resolvedUrl = resolveWorkspaceUrl(value);
const mime = getWorkspaceMimeHint(value);
if (mime?.startsWith("video/") || imgFailed) {
return (
<video
controls
className="mt-2 h-auto max-w-full rounded-md border border-zinc-200"
preload="metadata"
>
<source src={resolvedUrl} />
</video>
);
}
if (mime?.startsWith("audio/")) {
return <audio controls src={resolvedUrl} className="mt-2 w-full" />;
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedUrl}
alt="Output media"
className="mt-2 h-auto max-w-full rounded-md border border-zinc-200"
loading="lazy"
onError={() => setImgFailed(true)}
/>
);
}
function renderOutputValue(value: unknown): React.ReactNode {
if (isWorkspaceRef(value)) {
return <WorkspaceMedia value={value} />;
}
if (Array.isArray(value)) {
const hasWorkspace = value.some(isWorkspaceRef);
if (hasWorkspace) {
return (
<>
{value.map((item, i) =>
isWorkspaceRef(item) ? (
<WorkspaceMedia key={i} value={item} />
) : (
<pre
key={i}
className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground"
>
{formatMaybeJson(item)}
</pre>
),
)}
</>
);
}
}
return null;
}
function OutputKeySection({
outputKey,
items,
}: {
outputKey: string;
items: unknown[];
}) {
const [expanded, setExpanded] = useState(false);
const mediaContent = renderOutputValue(items);
const hasMoreItems = items.length > COLLAPSED_LIMIT;
const visibleItems = expanded ? items : items.slice(0, COLLAPSED_LIMIT);
return (
<div className="rounded-2xl border bg-background p-3">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-foreground">
{outputKey}
</p>
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{items.length} item{items.length === 1 ? "" : "s"}
</span>
</div>
{mediaContent || (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{formatMaybeJson(visibleItems)}
</pre>
)}
{!mediaContent && hasMoreItems && (
<Button
variant="ghost"
size="small"
className="mt-1 h-auto px-0 py-0.5 text-[11px] text-muted-foreground"
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? "Show less" : `Show all ${items.length} items`}
</Button>
)}
</div>
);
}
export function BlockOutputCard({ output }: Props) {
return (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{Object.entries(output.outputs ?? {}).map(([key, items]) => (
<OutputKeySection key={key} outputKey={key} items={items} />
))}
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import { formatMaybeJson } from "../../helpers";
interface Props {
output: ErrorResponse;
}
export function ErrorCard({ output }: Props) {
return (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{output.error && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.error)}
</pre>
)}
{output.details && (
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
{formatMaybeJson(output.details)}
</pre>
)}
</div>
);
}

View File

@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
import { Button } from "@/components/atoms/Button/Button";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
coerceCredentialFields,
coerceExpectedInputs,
buildExpectedInputsSchema,
} from "./helpers";
interface Props {
output: SetupRequirementsResponse;
}
export function SetupRequirementsCard({ output }: Props) {
const { onSend } = useCopilotChatActions();
const [inputCredentials, setInputCredentials] = useState<
Record<string, CredentialsMetaInput | undefined>
>({});
const [hasSentCredentials, setHasSentCredentials] = useState(false);
const [showInputForm, setShowInputForm] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, unknown>>({});
const { credentialFields, requiredCredentials } = coerceCredentialFields(
output.setup_info.user_readiness?.missing_credentials,
);
const expectedInputs = coerceExpectedInputs(
(output.setup_info.requirements as Record<string, unknown>)?.inputs,
);
const inputSchema = buildExpectedInputsSchema(expectedInputs);
function handleCredentialChange(key: string, value?: CredentialsMetaInput) {
setInputCredentials((prev) => ({ ...prev, [key]: value }));
}
const isAllCredentialsComplete =
credentialFields.length > 0 &&
[...requiredCredentials].every((key) => !!inputCredentials[key]);
function handleProceedCredentials() {
setHasSentCredentials(true);
onSend(
"I've configured the required credentials. Please re-run the block now.",
);
}
function handleRunWithInputs() {
const nonEmpty = Object.fromEntries(
Object.entries(inputValues).filter(
([, v]) => v !== undefined && v !== null && v !== "",
),
);
onSend(
`Run the block with these inputs: ${JSON.stringify(nonEmpty, null, 2)}`,
);
setShowInputForm(false);
setInputValues({});
}
return (
<div className="grid gap-2">
<p className="text-sm text-foreground">{output.message}</p>
{credentialFields.length > 0 && (
<div className="rounded-2xl border bg-background p-3">
<CredentialsGroupedView
credentialFields={credentialFields}
requiredCredentials={requiredCredentials}
inputCredentials={inputCredentials}
inputValues={{}}
onCredentialChange={handleCredentialChange}
/>
{isAllCredentialsComplete && !hasSentCredentials && (
<Button
variant="primary"
size="small"
className="mt-3 w-full"
onClick={handleProceedCredentials}
>
Proceed
</Button>
)}
</div>
)}
{inputSchema && (
<div className="flex gap-2 pt-2">
<Button
variant="secondary"
size="small"
className="w-fit"
onClick={() => setShowInputForm((prev) => !prev)}
>
{showInputForm ? "Hide inputs" : "Fill in inputs"}
</Button>
</div>
)}
<AnimatePresence initial={false}>
{showInputForm && inputSchema && (
<motion.div
initial={{ height: 0, opacity: 0, filter: "blur(6px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(6px)" }}
transition={{
height: { type: "spring", bounce: 0.15, duration: 0.5 },
opacity: { duration: 0.25 },
filter: { duration: 0.2 },
}}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
>
<div className="rounded-2xl border bg-background p-3 pt-4">
<p className="text-sm font-medium text-foreground">
Block inputs
</p>
<FormRenderer
jsonSchema={inputSchema}
handleChange={(v) => setInputValues(v.formData ?? {})}
uiSchema={{
"ui:submitButtonOptions": { norender: true },
}}
initialValues={inputValues}
formContext={{
showHandles: false,
size: "small",
}}
/>
<div className="-mt-8 flex gap-2">
<Button
variant="primary"
size="small"
className="w-fit"
onClick={handleRunWithInputs}
>
Run
</Button>
<Button
variant="secondary"
size="small"
className="w-fit"
onClick={() => {
setShowInputForm(false);
setInputValues({});
}}
>
Cancel
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{expectedInputs.length > 0 && !inputSchema && (
<div className="rounded-2xl border bg-background p-3">
<p className="text-xs font-medium text-foreground">Expected inputs</p>
<div className="mt-2 grid gap-2">
{expectedInputs.map((input) => (
<div key={input.name} className="rounded-xl border p-2">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-foreground">
{input.title}
</p>
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{input.required ? "Required" : "Optional"}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{input.name} &bull; {input.type}
{input.description ? ` \u2022 ${input.description}` : ""}
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,156 @@
import type { CredentialField } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/helpers";
import type { RJSFSchema } from "@rjsf/utils";
const VALID_CREDENTIAL_TYPES = new Set([
"api_key",
"oauth2",
"user_password",
"host_scoped",
]);
export function coerceCredentialFields(rawMissingCredentials: unknown): {
credentialFields: CredentialField[];
requiredCredentials: Set<string>;
} {
const missing =
rawMissingCredentials && typeof rawMissingCredentials === "object"
? (rawMissingCredentials as Record<string, unknown>)
: {};
const credentialFields: CredentialField[] = [];
const requiredCredentials = new Set<string>();
Object.entries(missing).forEach(([key, value]) => {
if (!value || typeof value !== "object") return;
const cred = value as Record<string, unknown>;
const provider =
typeof cred.provider === "string" ? cred.provider.trim() : "";
if (!provider) return;
const types =
Array.isArray(cred.types) && cred.types.length > 0
? cred.types
: typeof cred.type === "string"
? [cred.type]
: [];
const credentialTypes = types
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter((t) => VALID_CREDENTIAL_TYPES.has(t));
if (credentialTypes.length === 0) return;
const scopes = Array.isArray(cred.scopes)
? cred.scopes.filter((s): s is string => typeof s === "string")
: undefined;
const schema = {
type: "object" as const,
properties: {},
credentials_provider: [provider],
credentials_types: credentialTypes,
credentials_scopes: scopes,
};
credentialFields.push([key, schema]);
requiredCredentials.add(key);
});
return { credentialFields, requiredCredentials };
}
export function coerceExpectedInputs(rawInputs: unknown): Array<{
name: string;
title: string;
type: string;
description?: string;
required: boolean;
}> {
if (!Array.isArray(rawInputs)) return [];
const results: Array<{
name: string;
title: string;
type: string;
description?: string;
required: boolean;
}> = [];
rawInputs.forEach((value, index) => {
if (!value || typeof value !== "object") return;
const input = value as Record<string, unknown>;
const name =
typeof input.name === "string" && input.name.trim()
? input.name.trim()
: `input-${index}`;
const title =
typeof input.title === "string" && input.title.trim()
? input.title.trim()
: name;
const type = typeof input.type === "string" ? input.type : "unknown";
const description =
typeof input.description === "string" && input.description.trim()
? input.description.trim()
: undefined;
const required = Boolean(input.required);
const item: {
name: string;
title: string;
type: string;
description?: string;
required: boolean;
} = { name, title, type, required };
if (description) item.description = description;
results.push(item);
});
return results;
}
/**
* Build an RJSF schema from expected inputs so they can be rendered
* as a dynamic form via FormRenderer.
*/
export function buildExpectedInputsSchema(
expectedInputs: Array<{
name: string;
title: string;
type: string;
description?: string;
required: boolean;
}>,
): RJSFSchema | null {
if (expectedInputs.length === 0) return null;
const TYPE_MAP: Record<string, string> = {
string: "string",
str: "string",
text: "string",
number: "number",
int: "integer",
integer: "integer",
float: "number",
boolean: "boolean",
bool: "boolean",
};
const properties: Record<string, Record<string, unknown>> = {};
const required: string[] = [];
for (const input of expectedInputs) {
properties[input.name] = {
type: TYPE_MAP[input.type.toLowerCase()] ?? "string",
title: input.title,
...(input.description ? { description: input.description } : {}),
};
if (input.required) required.push(input.name);
}
return {
type: "object",
properties,
...(required.length > 0 ? { required } : {}),
};
}

View File

@@ -73,12 +73,6 @@ export function getRunBlockToolOutput(
return parseOutput((part as { output?: unknown }).output);
}
function getBlockLabel(input: RunBlockInput | undefined): string | null {
const blockId = input?.block_id?.trim();
if (!blockId) return null;
return `Block ${blockId.slice(0, 8)}`;
}
export function getAnimationText(part: {
state: ToolUIPart["state"];
input?: unknown;
@@ -138,3 +132,42 @@ export function formatMaybeJson(value: unknown): string {
return String(value);
}
}
export function getAccordionMeta(output: RunBlockToolOutput): {
badgeText: string;
title: string;
description?: string;
} {
if (isRunBlockBlockOutput(output)) {
const keys = Object.keys(output.outputs ?? {});
return {
badgeText: "Run block",
title: output.block_name,
description:
keys.length > 0
? `${keys.length} output key${keys.length === 1 ? "" : "s"}`
: output.message,
};
}
if (isRunBlockSetupRequirementsOutput(output)) {
const missingCredsCount = Object.keys(
(output.setup_info.user_readiness?.missing_credentials ?? {}) as Record<
string,
unknown
>,
).length;
return {
badgeText: "Run block",
title: output.setup_info.agent_name,
description:
missingCredsCount > 0
? `Missing ${missingCredsCount} credential${missingCredsCount === 1 ? "" : "s"}`
: output.message,
};
}
return { badgeText: "Run block", title: "Error" };
}