Merge remote-tracking branch 'origin/dev' into fix/frontend-chat-things

This commit is contained in:
Lluis Agusti
2026-01-20 23:44:10 +07:00
17 changed files with 286 additions and 105 deletions

View File

@@ -33,7 +33,7 @@ from .models import (
UserReadiness,
)
from .utils import (
check_user_has_required_credentials,
build_missing_credentials_from_graph,
extract_credentials_from_schema,
fetch_graph_from_store_slug,
get_or_create_library_agent,
@@ -237,15 +237,13 @@ class RunAgentTool(BaseTool):
# Return credentials needed response with input data info
# The UI handles credential setup automatically, so the message
# focuses on asking about input data
credentials = extract_credentials_from_schema(
graph.credentials_input_schema
requirements_creds_dict = build_missing_credentials_from_graph(
graph, None
)
missing_creds_check = await check_user_has_required_credentials(
user_id, credentials
missing_credentials_dict = build_missing_credentials_from_graph(
graph, graph_credentials
)
missing_credentials_dict = {
c.id: c.model_dump() for c in missing_creds_check
}
requirements_creds_list = list(requirements_creds_dict.values())
return SetupRequirementsResponse(
message=self._build_inputs_message(graph, MSG_WHAT_VALUES_TO_USE),
@@ -259,7 +257,7 @@ class RunAgentTool(BaseTool):
ready_to_run=False,
),
requirements={
"credentials": [c.model_dump() for c in credentials],
"credentials": requirements_creds_list,
"inputs": self._get_inputs_list(graph.input_schema),
"execution_modes": self._get_execution_modes(graph),
},

View File

@@ -22,6 +22,7 @@ from .models import (
ToolResponseBase,
UserReadiness,
)
from .utils import build_missing_credentials_from_field_info
logger = logging.getLogger(__name__)
@@ -189,7 +190,11 @@ class RunBlockTool(BaseTool):
if missing_credentials:
# Return setup requirements response with missing credentials
missing_creds_dict = {c.id: c.model_dump() for c in missing_credentials}
credentials_fields_info = block.input_schema.get_credentials_fields_info()
missing_creds_dict = build_missing_credentials_from_field_info(
credentials_fields_info, set(matched_credentials.keys())
)
missing_creds_list = list(missing_creds_dict.values())
return SetupRequirementsResponse(
message=(
@@ -206,7 +211,7 @@ class RunBlockTool(BaseTool):
ready_to_run=False,
),
requirements={
"credentials": [c.model_dump() for c in missing_credentials],
"credentials": missing_creds_list,
"inputs": self._get_inputs_list(block),
"execution_modes": ["immediate"],
},

View File

@@ -8,7 +8,7 @@ from backend.api.features.library import model as library_model
from backend.api.features.store import db as store_db
from backend.data import graph as graph_db
from backend.data.graph import GraphModel
from backend.data.model import CredentialsMetaInput
from backend.data.model import CredentialsFieldInfo, CredentialsMetaInput
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.util.exceptions import NotFoundError
@@ -89,6 +89,59 @@ def extract_credentials_from_schema(
return credentials
def _serialize_missing_credential(
field_key: str, field_info: CredentialsFieldInfo
) -> dict[str, Any]:
"""
Convert credential field info into a serializable dict that preserves all supported
credential types (e.g., api_key + oauth2) so the UI can offer multiple options.
"""
supported_types = sorted(field_info.supported_types)
provider = next(iter(field_info.provider), "unknown")
scopes = sorted(field_info.required_scopes or [])
return {
"id": field_key,
"title": field_key.replace("_", " ").title(),
"provider": provider,
"provider_name": provider.replace("_", " ").title(),
"type": supported_types[0] if supported_types else "api_key",
"types": supported_types,
"scopes": scopes,
}
def build_missing_credentials_from_graph(
graph: GraphModel, matched_credentials: dict[str, CredentialsMetaInput] | None
) -> dict[str, Any]:
"""
Build a missing_credentials mapping from a graph's aggregated credentials inputs,
preserving all supported credential types for each field.
"""
matched_keys = set(matched_credentials.keys()) if matched_credentials else set()
aggregated_fields = graph.aggregate_credentials_inputs()
return {
field_key: _serialize_missing_credential(field_key, field_info)
for field_key, (field_info, _node_fields) in aggregated_fields.items()
if field_key not in matched_keys
}
def build_missing_credentials_from_field_info(
credential_fields: dict[str, CredentialsFieldInfo],
matched_keys: set[str],
) -> dict[str, Any]:
"""
Build missing_credentials mapping from a simple credentials field info dictionary.
"""
return {
field_key: _serialize_missing_credential(field_key, field_info)
for field_key, field_info in credential_fields.items()
if field_key not in matched_keys
}
def extract_credentials_as_dict(
credentials_input_schema: dict[str, Any] | None,
) -> dict[str, CredentialsMetaInput]:

View File

@@ -5,10 +5,11 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
import { CircleNotchIcon, PlayIcon, StopIcon } from "@phosphor-icons/react";
import { useShallow } from "zustand/react/shallow";
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
import { useRunGraph } from "./useRunGraph";
import { cn } from "@/lib/utils";
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
const {
@@ -24,6 +25,31 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
useShallow((state) => state.isGraphRunning),
);
const isLoading = isExecutingGraph || isTerminatingGraph || isSaving;
// Determine which icon to show with proper animation
const renderIcon = () => {
const iconClass = cn(
"size-4 transition-transform duration-200 ease-out",
!isLoading && "group-hover:scale-110",
);
if (isLoading) {
return (
<CircleNotchIcon
className={cn(iconClass, "animate-spin")}
weight="bold"
/>
);
}
if (isGraphRunning) {
return <StopIcon className={iconClass} weight="fill" />;
}
return <PlayIcon className={iconClass} weight="fill" />;
};
return (
<>
<Tooltip>
@@ -33,18 +59,18 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
variant={isGraphRunning ? "destructive" : "primary"}
data-id={isGraphRunning ? "stop-graph-button" : "run-graph-button"}
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
disabled={!flowID || isExecutingGraph || isTerminatingGraph}
loading={isExecutingGraph || isTerminatingGraph || isSaving}
disabled={!flowID || isLoading}
className="group"
>
{!isGraphRunning ? (
<PlayIcon className="size-4" />
) : (
<StopIcon className="size-4" />
)}
{renderIcon()}
</Button>
</TooltipTrigger>
<TooltipContent>
{isGraphRunning ? "Stop agent" : "Run agent"}
{isLoading
? "Processing..."
: isGraphRunning
? "Stop agent"
: "Run agent"}
</TooltipContent>
</Tooltip>
<RunInputDialog

View File

@@ -61,63 +61,67 @@ export const RunInputDialog = ({
isOpen,
set: setIsOpen,
}}
styling={{ maxWidth: "600px", minWidth: "600px" }}
styling={{ maxWidth: "700px", minWidth: "700px" }}
>
<Dialog.Content>
<div className="space-y-6 p-1" data-id="run-input-dialog-content">
{/* Credentials Section */}
{hasCredentials() && credentialFields.length > 0 && (
<div data-id="run-input-credentials-section">
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Credentials
</Text>
<div
className="grid grid-cols-[1fr_auto] gap-10 p-1"
data-id="run-input-dialog-content"
>
<div className="space-y-6">
{/* Credentials Section */}
{hasCredentials() && credentialFields.length > 0 && (
<div data-id="run-input-credentials-section">
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Credentials
</Text>
</div>
<div className="px-2" data-id="run-input-credentials-form">
<CredentialsGroupedView
credentialFields={credentialFields}
requiredCredentials={requiredCredentials}
inputCredentials={credentialValues}
inputValues={inputValues}
onCredentialChange={handleCredentialFieldChange}
/>
</div>
</div>
<div className="px-2" data-id="run-input-credentials-form">
<CredentialsGroupedView
credentialFields={credentialFields}
requiredCredentials={requiredCredentials}
inputCredentials={credentialValues}
inputValues={inputValues}
onCredentialChange={handleCredentialFieldChange}
/>
</div>
</div>
)}
)}
{/* Inputs Section */}
{hasInputs() && (
<div data-id="run-input-inputs-section">
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Inputs
</Text>
{/* Inputs Section */}
{hasInputs() && (
<div data-id="run-input-inputs-section">
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Inputs
</Text>
</div>
<div data-id="run-input-inputs-form">
<FormRenderer
jsonSchema={inputSchema as RJSFSchema}
handleChange={(v) => handleInputChange(v.formData)}
uiSchema={uiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
</div>
<div data-id="run-input-inputs-form">
<FormRenderer
jsonSchema={inputSchema as RJSFSchema}
handleChange={(v) => handleInputChange(v.formData)}
uiSchema={uiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
</div>
)}
)}
</div>
{/* Action Button */}
<div
className="flex justify-end pt-2"
className="flex flex-col items-end justify-start"
data-id="run-input-actions-section"
>
{purpose === "run" && (
<Button
variant="primary"
size="large"
className="group h-fit min-w-0 gap-2"
className="group h-fit min-w-0 gap-2 px-10"
onClick={handleManualRun}
loading={isExecutingGraph}
data-id="run-input-manual-run-button"
@@ -132,7 +136,7 @@ export const RunInputDialog = ({
<Button
variant="primary"
size="large"
className="group h-fit min-w-0 gap-2"
className="group h-fit min-w-0 gap-2 px-10"
onClick={() => setOpenCronSchedulerDialog(true)}
data-id="run-input-schedule-button"
>

View File

@@ -53,14 +53,14 @@ export const CustomControls = memo(
const controls = [
{
id: "zoom-in-button",
icon: <PlusIcon className="size-4" />,
icon: <PlusIcon className="size-3.5 text-zinc-600" />,
label: "Zoom In",
onClick: () => zoomIn(),
className: "h-10 w-10 border-none",
},
{
id: "zoom-out-button",
icon: <MinusIcon className="size-4" />,
icon: <MinusIcon className="size-3.5 text-zinc-600" />,
label: "Zoom Out",
onClick: () => zoomOut(),
className: "h-10 w-10 border-none",
@@ -68,9 +68,9 @@ export const CustomControls = memo(
{
id: "tutorial-button",
icon: isTutorialLoading ? (
<CircleNotchIcon className="size-4 animate-spin" />
<CircleNotchIcon className="size-3.5 animate-spin text-zinc-600" />
) : (
<ChalkboardIcon className="size-4" />
<ChalkboardIcon className="size-3.5 text-zinc-600" />
),
label: isTutorialLoading ? "Loading Tutorial..." : "Start Tutorial",
onClick: handleTutorialClick,
@@ -79,7 +79,7 @@ export const CustomControls = memo(
},
{
id: "fit-view-button",
icon: <FrameCornersIcon className="size-4" />,
icon: <FrameCornersIcon className="size-3.5 text-zinc-600" />,
label: "Fit View",
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
className: "h-10 w-10 border-none",
@@ -87,9 +87,9 @@ export const CustomControls = memo(
{
id: "lock-button",
icon: !isLocked ? (
<LockOpenIcon className="size-4" />
<LockOpenIcon className="size-3.5 text-zinc-600" />
) : (
<LockIcon className="size-4" />
<LockIcon className="size-3.5 text-zinc-600" />
),
label: "Toggle Lock",
onClick: () => setIsLocked(!isLocked),

View File

@@ -19,6 +19,8 @@ export type CustomEdgeData = {
beadUp?: number;
beadDown?: number;
beadData?: Map<string, NodeExecutionResult["status"]>;
edgeColorClass?: string;
edgeHexColor?: string;
};
export type CustomEdge = XYEdge<CustomEdgeData, "custom">;
@@ -36,7 +38,6 @@ const CustomEdge = ({
selected,
}: EdgeProps<CustomEdge>) => {
const removeConnection = useEdgeStore((state) => state.removeEdge);
// Subscribe to the brokenEdgeIDs map and check if this edge is broken across any node
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
const [isHovered, setIsHovered] = useState(false);
@@ -52,6 +53,7 @@ const CustomEdge = ({
const isStatic = data?.isStatic ?? false;
const beadUp = data?.beadUp ?? 0;
const beadDown = data?.beadDown ?? 0;
const edgeColorClass = data?.edgeColorClass;
const handleRemoveEdge = () => {
removeConnection(id);
@@ -70,7 +72,9 @@ const CustomEdge = ({
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
: selected
? "stroke-zinc-800"
: "stroke-zinc-500/50 hover:stroke-zinc-500",
: edgeColorClass
? cn(edgeColorClass, "opacity-70 hover:opacity-100")
: "stroke-zinc-500/50 hover:stroke-zinc-500",
)}
/>
<JSBeads

View File

@@ -8,6 +8,7 @@ import { useCallback } from "react";
import { useNodeStore } from "../../../stores/nodeStore";
import { useHistoryStore } from "../../../stores/historyStore";
import { CustomEdge } from "./CustomEdge";
import { getEdgeColorFromOutputType } from "../nodes/helpers";
export const useCustomEdge = () => {
const edges = useEdgeStore((s) => s.edges);
@@ -34,8 +35,13 @@ export const useCustomEdge = () => {
if (exists) return;
const nodes = useNodeStore.getState().nodes;
const isStatic = nodes.find((n) => n.id === conn.source)?.data
?.staticOutput;
const sourceNode = nodes.find((n) => n.id === conn.source);
const isStatic = sourceNode?.data?.staticOutput;
const { colorClass, hexColor } = getEdgeColorFromOutputType(
sourceNode?.data?.outputSchema,
conn.sourceHandle,
);
addEdge({
source: conn.source,
@@ -44,6 +50,8 @@ export const useCustomEdge = () => {
targetHandle: conn.targetHandle,
data: {
isStatic,
edgeColorClass: colorClass,
edgeHexColor: hexColor,
},
});
},

View File

@@ -187,3 +187,38 @@ export const getTypeDisplayInfo = (schema: any) => {
hexColor,
};
};
export function getEdgeColorFromOutputType(
outputSchema: RJSFSchema | undefined,
sourceHandle: string,
): { colorClass: string; hexColor: string } {
const defaultColor = {
colorClass: "stroke-zinc-500/50",
hexColor: "#6b7280",
};
if (!outputSchema?.properties) return defaultColor;
const properties = outputSchema.properties as Record<string, unknown>;
const handleParts = sourceHandle.split("_#_");
let currentSchema: Record<string, unknown> = properties;
for (let i = 0; i < handleParts.length; i++) {
const part = handleParts[i];
const fieldSchema = currentSchema[part] as Record<string, unknown>;
if (!fieldSchema) return defaultColor;
if (i === handleParts.length - 1) {
const { hexColor, colorClass } = getTypeDisplayInfo(fieldSchema);
return { colorClass: colorClass.replace("!text-", "stroke-"), hexColor };
}
if (fieldSchema.properties) {
currentSchema = fieldSchema.properties as Record<string, unknown>;
} else {
return defaultColor;
}
}
return defaultColor;
}

View File

@@ -1,7 +1,32 @@
// These are SVG Phosphor icons
type IconOptions = {
size?: number;
color?: string;
};
const DEFAULT_SIZE = 16;
const DEFAULT_COLOR = "#52525b"; // zinc-600
const iconPaths = {
ClickIcon: `M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z`,
Keyboard: `M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z`,
Drag: `M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z`,
};
function createIcon(path: string, options: IconOptions = {}): string {
const size = options.size ?? DEFAULT_SIZE;
const color = options.color ?? DEFAULT_COLOR;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" fill="${color}" viewBox="0 0 256 256"><path d="${path}"></path></svg>`;
}
export const ICONS = {
ClickIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z"></path></svg>`,
Keyboard: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z"></path></svg>`,
Drag: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z"></path></svg>`,
ClickIcon: createIcon(iconPaths.ClickIcon),
Keyboard: createIcon(iconPaths.Keyboard),
Drag: createIcon(iconPaths.Drag),
};
export function getIcon(
name: keyof typeof iconPaths,
options?: IconOptions,
): string {
return createIcon(iconPaths[name], options);
}

View File

@@ -11,6 +11,7 @@ import {
} from "./helpers";
import { useNodeStore } from "../../../stores/nodeStore";
import { useEdgeStore } from "../../../stores/edgeStore";
import { useTutorialStore } from "../../../stores/tutorialStore";
let isTutorialLoading = false;
let tutorialLoadingCallback: ((loading: boolean) => void) | null = null;
@@ -60,12 +61,14 @@ export const startTutorial = async () => {
handleTutorialComplete();
removeTutorialStyles();
clearPrefetchedBlocks();
useTutorialStore.getState().setIsTutorialRunning(false);
});
tour.on("cancel", () => {
handleTutorialCancel(tour);
removeTutorialStyles();
clearPrefetchedBlocks();
useTutorialStore.getState().setIsTutorialRunning(false);
});
for (const step of tour.steps) {

View File

@@ -2,6 +2,7 @@
import { Button } from "@/components/atoms/Button/Button";
import { FileInput } from "@/components/atoms/FileInput/FileInput";
import { Input } from "@/components/atoms/Input/Input";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
@@ -120,7 +121,7 @@ export default function LibraryUploadAgentDialog() {
>
{isUploading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-t-2 border-white"></div>
<LoadingSpinner size="small" className="text-white" />
<span>Uploading...</span>
</div>
) : (

View File

@@ -304,23 +304,34 @@ export function extractCredentialsNeeded(
| undefined;
if (missingCreds && Object.keys(missingCreds).length > 0) {
const agentName = (setupInfo?.agent_name as string) || "this block";
const credentials = Object.values(missingCreds).map((credInfo) => ({
provider: (credInfo.provider as string) || "unknown",
providerName:
(credInfo.provider_name as string) ||
(credInfo.provider as string) ||
"Unknown Provider",
credentialType:
const credentials = Object.values(missingCreds).map((credInfo) => {
// Normalize to array at boundary - prefer 'types' array, fall back to single 'type'
const typesArray = credInfo.types as
| Array<"api_key" | "oauth2" | "user_password" | "host_scoped">
| undefined;
const singleType =
(credInfo.type as
| "api_key"
| "oauth2"
| "user_password"
| "host_scoped") || "api_key",
title:
(credInfo.title as string) ||
`${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
scopes: credInfo.scopes as string[] | undefined,
}));
| "host_scoped"
| undefined) || "api_key";
const credentialTypes =
typesArray && typesArray.length > 0 ? typesArray : [singleType];
return {
provider: (credInfo.provider as string) || "unknown",
providerName:
(credInfo.provider_name as string) ||
(credInfo.provider as string) ||
"Unknown Provider",
credentialTypes,
title:
(credInfo.title as string) ||
`${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
scopes: credInfo.scopes as string[] | undefined,
};
});
return {
type: "credentials_needed",
toolName,
@@ -395,11 +406,14 @@ export function extractInputsNeeded(
credentials.forEach((cred) => {
const id = cred.id as string;
if (id) {
const credentialTypes = Array.isArray(cred.types)
? cred.types
: [(cred.type as string) || "api_key"];
credentialsSchema[id] = {
type: "object",
properties: {},
credentials_provider: [cred.provider as string],
credentials_types: [(cred.type as string) || "api_key"],
credentials_types: credentialTypes,
credentials_scopes: cred.scopes as string[] | undefined,
};
}

View File

@@ -9,7 +9,9 @@ import { useChatCredentialsSetup } from "./useChatCredentialsSetup";
export interface CredentialInfo {
provider: string;
providerName: string;
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
credentialTypes: Array<
"api_key" | "oauth2" | "user_password" | "host_scoped"
>;
title: string;
scopes?: string[];
}
@@ -30,7 +32,7 @@ function createSchemaFromCredentialInfo(
type: "object",
properties: {},
credentials_provider: [credential.provider],
credentials_types: [credential.credentialType],
credentials_types: credential.credentialTypes,
credentials_scopes: credential.scopes,
discriminator: undefined,
discriminator_mapping: undefined,

View File

@@ -41,7 +41,9 @@ export type ChatMessageData =
credentials: Array<{
provider: string;
providerName: string;
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
credentialTypes: Array<
"api_key" | "oauth2" | "user_password" | "host_scoped"
>;
title: string;
scopes?: string[];
}>;

View File

@@ -35,12 +35,13 @@ export const CredentialFieldTitle = (props: {
uiOptions,
);
const credentialProvider = toDisplayName(
getCredentialProviderFromSchema(
useNodeStore.getState().getHardCodedValues(nodeId),
schema as BlockIOCredentialsSubSchema,
) ?? "",
const provider = getCredentialProviderFromSchema(
useNodeStore.getState().getHardCodedValues(nodeId),
schema as BlockIOCredentialsSubSchema,
);
const credentialProvider = provider
? `${toDisplayName(provider)} credential`
: "credential";
const updatedUiSchema = updateUiOption(uiSchema, {
showHandles: false,

View File

@@ -5,7 +5,7 @@ import isEqual from "lodash/isEqual";
export function cleanNode(node: CustomNode) {
return {
id: node.id,
position: node.position,
// Note: position is intentionally excluded to prevent draft saves when dragging nodes
data: {
hardcodedValues: node.data.hardcodedValues,
title: node.data.title,