mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-12 08:38:09 -05:00
Compare commits
6 Commits
hackathon-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f482eb668b | ||
|
|
fc8434fb30 | ||
|
|
3ae08cd48e | ||
|
|
4db13837b9 | ||
|
|
df87867625 | ||
|
|
4a7bc006a8 |
@@ -495,8 +495,14 @@ class SmartDecisionMakerBlock(Block):
|
||||
}
|
||||
|
||||
properties = {}
|
||||
field_mapping = {}
|
||||
|
||||
for link in links:
|
||||
field_name = link.sink_name
|
||||
|
||||
clean_field_name = SmartDecisionMakerBlock.cleanup(field_name)
|
||||
field_mapping[clean_field_name] = field_name
|
||||
|
||||
sink_block_input_schema = sink_node.input_default["input_schema"]
|
||||
sink_block_properties = sink_block_input_schema.get("properties", {}).get(
|
||||
link.sink_name, {}
|
||||
@@ -506,7 +512,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
if "description" in sink_block_properties
|
||||
else f"The {link.sink_name} of the tool"
|
||||
)
|
||||
properties[link.sink_name] = {
|
||||
properties[clean_field_name] = {
|
||||
"type": "string",
|
||||
"description": description,
|
||||
"default": json.dumps(sink_block_properties.get("default", None)),
|
||||
@@ -519,7 +525,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
"strict": True,
|
||||
}
|
||||
|
||||
# Store node info for later use in output processing
|
||||
tool_function["_field_mapping"] = field_mapping
|
||||
tool_function["_sink_node_id"] = sink_node.id
|
||||
|
||||
return {"type": "function", "function": tool_function}
|
||||
@@ -1129,8 +1135,9 @@ class SmartDecisionMakerBlock(Block):
|
||||
original_field_name = field_mapping.get(clean_arg_name, clean_arg_name)
|
||||
arg_value = tool_args.get(clean_arg_name)
|
||||
|
||||
sanitized_arg_name = self.cleanup(original_field_name)
|
||||
emit_key = f"tools_^_{sink_node_id}_~_{sanitized_arg_name}"
|
||||
# Use original_field_name directly (not sanitized) to match link sink_name
|
||||
# The field_mapping already translates from LLM's cleaned names to original names
|
||||
emit_key = f"tools_^_{sink_node_id}_~_{original_field_name}"
|
||||
|
||||
logger.debug(
|
||||
"[SmartDecisionMakerBlock|geid:%s|neid:%s] emit %s",
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@rjsf/core": "6.1.2",
|
||||
"@rjsf/utils": "6.1.2",
|
||||
"@rjsf/validator-ajv8": "5.24.13",
|
||||
"@rjsf/validator-ajv8": "6.1.2",
|
||||
"@sentry/nextjs": "10.27.0",
|
||||
"@supabase/ssr": "0.7.0",
|
||||
"@supabase/supabase-js": "2.78.0",
|
||||
|
||||
3640
autogpt_platform/frontend/pnpm-lock.yaml
generated
3640
autogpt_platform/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
|
||||
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { useState } from "react";
|
||||
import { getSchemaDefaultCredentials } from "../../helpers";
|
||||
import { areAllCredentialsSet, getCredentialFields } from "./helpers";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { RunAgentInputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { CircleNotchIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChatDrawer } from "@/components/contextual/Chat/ChatDrawer";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Children, ReactNode } from "react";
|
||||
|
||||
interface PlatformLayoutContentProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PlatformLayoutContent({
|
||||
children,
|
||||
}: PlatformLayoutContentProps) {
|
||||
const pathname = usePathname();
|
||||
const isAuthPage =
|
||||
pathname?.includes("/login") || pathname?.includes("/signup");
|
||||
|
||||
// Extract Navbar, AdminImpersonationBanner, and page content from children
|
||||
const childrenArray = Children.toArray(children);
|
||||
const navbar = childrenArray[0];
|
||||
const adminBanner = childrenArray[1];
|
||||
const pageContent = childrenArray.slice(2);
|
||||
|
||||
// For login/signup pages, use a simpler layout that doesn't interfere with centering
|
||||
if (isAuthPage) {
|
||||
return (
|
||||
<main className="flex min-h-screen w-full flex-col">
|
||||
{navbar}
|
||||
{adminBanner}
|
||||
<section className="flex-1">{pageContent}</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// For logged-in pages, use the drawer layout
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col overflow-hidden">
|
||||
{navbar}
|
||||
{adminBanner}
|
||||
<section className="flex min-h-0 flex-1 overflow-auto">
|
||||
{pageContent}
|
||||
</section>
|
||||
<ChatDrawer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { AuthCard } from "@/components/auth/AuthCard";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import type {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { BlockUIType } from "@/app/(platform)/build/components/types";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import { Label } from "@/components/__legacy__/ui/label";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import {
|
||||
@@ -18,11 +23,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { BookOpenIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import { globalRegistry } from "@/components/contextual/OutputRenderers";
|
||||
import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import { globalRegistry } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
|
||||
export const TextRenderer: React.FC<{
|
||||
value: any;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
@@ -7,10 +11,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import {
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import { globalRegistry } from "@/components/contextual/OutputRenderers";
|
||||
import { downloadOutputs } from "@/components/contextual/OutputRenderers/utils/download";
|
||||
import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import { globalRegistry } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import { downloadOutputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers/utils/download";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import Link from "next/link";
|
||||
import { useGetV2GetLibraryAgentByGraphId } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { isValidUUID } from "@/components/contextual/Chat/helpers";
|
||||
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
|
||||
import Link from "next/link";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { useQueryStates, parseAsString } from "nuqs";
|
||||
import { isValidUUID } from "@/app/(platform)/chat/helpers";
|
||||
|
||||
export const WebhookDisclaimer = ({ nodeId }: { nodeId: string }) => {
|
||||
const [{ flowID }] = useQueryStates({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CustomNodeData,
|
||||
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Calendar } from "@/components/__legacy__/ui/calendar";
|
||||
import { LocalValuedInput } from "@/components/__legacy__/ui/input";
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/__legacy__/ui/select";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
|
||||
import {
|
||||
BlockIOArraySubSchema,
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { Drawer } from "vaul";
|
||||
|
||||
import { ChatContainer } from "@/components/contextual/Chat/components/ChatContainer/ChatContainer";
|
||||
import { ChatErrorState } from "@/components/contextual/Chat/components/ChatErrorState/ChatErrorState";
|
||||
import { ChatLoadingState } from "@/components/contextual/Chat/components/ChatLoadingState/ChatLoadingState";
|
||||
import { useChatPage } from "./useChatPage";
|
||||
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
|
||||
import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState";
|
||||
import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ChatPage() {
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const isOpen = pathname === "/chat";
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
@@ -36,88 +28,56 @@ export default function ChatPage() {
|
||||
}
|
||||
}, [isChatEnabled, router]);
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
router.replace("/marketplace");
|
||||
}
|
||||
}
|
||||
|
||||
if (isChatEnabled === null || isChatEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer.Root
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
direction="right"
|
||||
modal={false}
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Content
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-50 flex h-full w-1/2 flex-col border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="shrink-0 border-b border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<Drawer.Title className="text-xl font-semibold">
|
||||
Chat
|
||||
</Drawer.Title>
|
||||
<div className="flex items-center gap-4">
|
||||
{sessionId && (
|
||||
<>
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Session: {sessionId.slice(0, 8)}...
|
||||
</span>
|
||||
<button
|
||||
onClick={clearSession}
|
||||
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
New Chat
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
aria-label="Close"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className="!focus-visible:ring-0 p-0"
|
||||
>
|
||||
<X width="1.5rem" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<header className="border-b border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="container mx-auto flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Chat</h1>
|
||||
{sessionId && (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Session: {sessionId.slice(0, 8)}...
|
||||
</span>
|
||||
<button
|
||||
onClick={clearSession}
|
||||
className="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */}
|
||||
{(isLoading || isCreating || (!sessionId && !error)) && (
|
||||
<ChatLoadingState
|
||||
message={isCreating ? "Creating session..." : "Loading..."}
|
||||
/>
|
||||
)}
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto flex flex-1 flex-col overflow-hidden">
|
||||
{/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */}
|
||||
{(isLoading || isCreating || (!sessionId && !error)) && (
|
||||
<ChatLoadingState
|
||||
message={isCreating ? "Creating session..." : "Loading..."}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<ChatErrorState error={error} onRetry={createSession} />
|
||||
)}
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<ChatErrorState error={error} onRetry={createSession} />
|
||||
)}
|
||||
|
||||
{/* Session Content */}
|
||||
{sessionId && !isLoading && !error && (
|
||||
<ChatContainer
|
||||
sessionId={sessionId}
|
||||
initialMessages={messages}
|
||||
onRefreshSession={refreshSession}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
{/* Session Content */}
|
||||
{sessionId && !isLoading && !error && (
|
||||
<ChatContainer
|
||||
sessionId={sessionId}
|
||||
initialMessages={messages}
|
||||
onRefreshSession={refreshSession}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useChatSession } from "@/components/contextual/Chat/useChatSession";
|
||||
import { useChatStream } from "@/components/contextual/Chat/useChatStream";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useChatSession } from "@/app/(platform)/chat/useChatSession";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useChatStream } from "@/app/(platform)/chat/useChatStream";
|
||||
|
||||
export function useChatPage() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Navbar } from "@/components/layout/Navbar/Navbar";
|
||||
import { ReactNode } from "react";
|
||||
import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner";
|
||||
import { PlatformLayoutContent } from "./PlatformLayoutContent";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function PlatformLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PlatformLayoutContent>
|
||||
<main className="flex h-screen w-full flex-col">
|
||||
<Navbar />
|
||||
<AdminImpersonationBanner />
|
||||
{children}
|
||||
</PlatformLayoutContent>
|
||||
<section className="flex-1">{children}</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import { CredentialsInput } from "../../../../../../../../../../components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { RunAgentInputs } from "../../../../../../../../../../components/contextual/RunAgentInputs/RunAgentInputs";
|
||||
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
|
||||
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
|
||||
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
import { RunAgentInputs } from "../../../../../../../../../../../../components/contextual/RunAgentInputs/RunAgentInputs";
|
||||
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
|
||||
import { useRunAgentModalContext } from "../../context";
|
||||
import { ModalSection } from "../ModalSection/ModalSection";
|
||||
import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner";
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import type {
|
||||
OutputMetadata,
|
||||
OutputRenderer,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
type OutputsRecord = Record<string, Array<unknown>>;
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExe
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { CredentialsInput } from "../../../../../../../../../../components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { RunAgentInputs } from "../../../../../../../../../../components/contextual/RunAgentInputs/RunAgentInputs";
|
||||
import {
|
||||
getAgentCredentialsFields,
|
||||
getAgentInputFields,
|
||||
} from "../../modals/AgentInputsReadOnly/helpers";
|
||||
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs";
|
||||
import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs";
|
||||
import { LoadingSelectedContent } from "../LoadingSelectedContent";
|
||||
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
||||
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { CredentialsInput } from "../../../../../../../../../../components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { RunAgentInputs } from "../../../../../../../../../../components/contextual/RunAgentInputs/RunAgentInputs";
|
||||
import {
|
||||
getAgentCredentialsFields,
|
||||
getAgentInputFields,
|
||||
} from "../../modals/AgentInputsReadOnly/helpers";
|
||||
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs";
|
||||
import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs";
|
||||
import { LoadingSelectedContent } from "../LoadingSelectedContent";
|
||||
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
||||
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import { RunAgentInputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs";
|
||||
import { ScheduleTaskDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog";
|
||||
import ActionButtonGroup from "@/components/__legacy__/action-button-group";
|
||||
import type { ButtonAction } from "@/components/__legacy__/types";
|
||||
@@ -28,8 +30,6 @@ import {
|
||||
} from "@/components/__legacy__/ui/icons";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
import {
|
||||
useToast,
|
||||
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
|
||||
import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
import type { OutputMetadata } from "../../../../../../../../components/contextual/OutputRenderers";
|
||||
import type { OutputMetadata } from "../../NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "../../../../../../../../components/contextual/OutputRenderers";
|
||||
} from "../../NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
|
||||
export function AgentRunOutputView({
|
||||
agentRunOutputs,
|
||||
|
||||
@@ -4,91 +4,8 @@ import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* SSE Proxy for chat streaming.
|
||||
* Supports POST with context (page content + URL) in the request body.
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> },
|
||||
) {
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { message, is_user_message, context } = body;
|
||||
|
||||
if (!message) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing message parameter" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Get auth token from server-side session
|
||||
const token = await getServerAuthToken();
|
||||
|
||||
// Build backend URL
|
||||
const backendUrl = environment.getAGPTServerBaseUrl();
|
||||
const streamUrl = new URL(
|
||||
`/api/chat/sessions/${sessionId}/stream`,
|
||||
backendUrl,
|
||||
);
|
||||
|
||||
// Forward request to backend with auth header
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(streamUrl.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
is_user_message: is_user_message ?? true,
|
||||
context: context || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return new Response(error, {
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Return the SSE stream directly
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SSE proxy error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to connect to chat service",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy GET endpoint for backward compatibility
|
||||
* EventSource doesn't support custom headers, so we need a server-side proxy
|
||||
* that adds authentication and forwards the SSE stream to the client.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -141,52 +141,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes l3 {
|
||||
25% {
|
||||
background-position:
|
||||
0 0,
|
||||
100% 100%,
|
||||
100% calc(100% - 5px);
|
||||
}
|
||||
50% {
|
||||
background-position:
|
||||
0 100%,
|
||||
100% 100%,
|
||||
0 calc(100% - 5px);
|
||||
}
|
||||
75% {
|
||||
background-position:
|
||||
0 100%,
|
||||
100% 0,
|
||||
100% 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 80px;
|
||||
height: 70px;
|
||||
border: 5px solid rgb(241 245 249);
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
linear-gradient(rgb(15 23 42) 0 0) 0 0/8px 20px,
|
||||
linear-gradient(rgb(15 23 42) 0 0) 100% 0/8px 20px,
|
||||
radial-gradient(farthest-side, rgb(15 23 42) 90%, #0000) 0 5px/8px 8px
|
||||
content-box,
|
||||
transparent;
|
||||
background-repeat: no-repeat;
|
||||
animation: l3 2s infinite linear;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
|
||||
@@ -92,7 +92,7 @@ export function Input({
|
||||
className={cn(
|
||||
baseStyles,
|
||||
errorStyles,
|
||||
"-mb-1 h-auto min-h-[2.875rem] rounded-full",
|
||||
"-mb-1 h-auto min-h-[2.875rem] rounded-medium",
|
||||
// Size variants for textarea
|
||||
size === "small" && [
|
||||
"min-h-[2.25rem]", // 36px minimum
|
||||
@@ -107,11 +107,6 @@ export function Input({
|
||||
)}
|
||||
placeholder={placeholder || label}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={
|
||||
props.onKeyDown as
|
||||
| React.KeyboardEventHandler<HTMLTextAreaElement>
|
||||
| undefined
|
||||
}
|
||||
rows={props.rows || 3}
|
||||
{...(hideLabel ? { "aria-label": label } : {})}
|
||||
id={props.id}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { List } from "@phosphor-icons/react";
|
||||
import React, { useState } from "react";
|
||||
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
|
||||
import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState";
|
||||
import { SessionsDrawer } from "./components/SessionsDrawer/SessionsDrawer";
|
||||
import { useChat } from "./useChat";
|
||||
|
||||
export interface ChatProps {
|
||||
className?: string;
|
||||
headerTitle?: React.ReactNode;
|
||||
showHeader?: boolean;
|
||||
showSessionInfo?: boolean;
|
||||
showNewChatButton?: boolean;
|
||||
onNewChat?: () => void;
|
||||
headerActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
className,
|
||||
headerTitle = "AutoGPT Copilot",
|
||||
showHeader = true,
|
||||
showSessionInfo = true,
|
||||
showNewChatButton = true,
|
||||
onNewChat,
|
||||
headerActions,
|
||||
}: ChatProps) {
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
sessionId,
|
||||
createSession,
|
||||
clearSession,
|
||||
refreshSession,
|
||||
loadSession,
|
||||
} = useChat();
|
||||
|
||||
const [isSessionsDrawerOpen, setIsSessionsDrawerOpen] = useState(false);
|
||||
|
||||
const handleNewChat = () => {
|
||||
clearSession();
|
||||
onNewChat?.();
|
||||
};
|
||||
|
||||
const handleSelectSession = async (sessionId: string) => {
|
||||
try {
|
||||
await loadSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error("Failed to load session:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Header */}
|
||||
{showHeader && (
|
||||
<header className="shrink-0 border-t border-zinc-200 bg-white p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
aria-label="View sessions"
|
||||
onClick={() => setIsSessionsDrawerOpen(true)}
|
||||
className="flex size-8 items-center justify-center rounded hover:bg-zinc-100"
|
||||
>
|
||||
<List width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
{typeof headerTitle === "string" ? (
|
||||
<Text variant="h2" className="text-lg font-semibold">
|
||||
{headerTitle}
|
||||
</Text>
|
||||
) : (
|
||||
headerTitle
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{showSessionInfo && sessionId && (
|
||||
<>
|
||||
{showNewChatButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
New Chat
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */}
|
||||
{(isLoading || isCreating || (!sessionId && !error)) && (
|
||||
<ChatLoadingState
|
||||
message={isCreating ? "Creating session..." : "Loading..."}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<ChatErrorState error={error} onRetry={createSession} />
|
||||
)}
|
||||
|
||||
{/* Session Content */}
|
||||
{sessionId && !isLoading && !error && (
|
||||
<ChatContainer
|
||||
sessionId={sessionId}
|
||||
initialMessages={messages}
|
||||
onRefreshSession={refreshSession}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Sessions Drawer */}
|
||||
<SessionsDrawer
|
||||
isOpen={isSessionsDrawerOpen}
|
||||
onClose={() => setIsSessionsDrawerOpen(false)}
|
||||
onSelectSession={handleSelectSession}
|
||||
currentSessionId={sessionId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Drawer } from "vaul";
|
||||
import { Chat } from "./Chat";
|
||||
import { useChatDrawer } from "./useChatDrawer";
|
||||
|
||||
interface ChatDrawerProps {
|
||||
blurBackground?: boolean;
|
||||
}
|
||||
|
||||
export function ChatDrawer({ blurBackground = true }: ChatDrawerProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const { isOpen, close } = useChatDrawer();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatEnabled === false && isOpen) {
|
||||
close();
|
||||
}
|
||||
}, [isChatEnabled, isOpen, close]);
|
||||
|
||||
// Don't render on server - vaul drawer accesses document during SSR
|
||||
if (!isMounted || isChatEnabled === null || isChatEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
modal={false}
|
||||
>
|
||||
{blurBackground && isOpen && (
|
||||
<div
|
||||
onClick={close}
|
||||
className="fixed inset-0 z-[45] cursor-pointer animate-in fade-in-0"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
/>
|
||||
)}
|
||||
<Drawer.Content
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onInteractOutside={blurBackground ? close : undefined}
|
||||
className={cn(
|
||||
"fixed right-0 top-[60px] z-50 flex h-[calc(100vh-60px)] w-1/2 flex-col border-l border-zinc-200 bg-white",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
<Chat
|
||||
headerTitle={
|
||||
<Drawer.Title className="text-lg font-semibold">
|
||||
AutoGPT Copilot
|
||||
</Drawer.Title>
|
||||
}
|
||||
headerActions={
|
||||
<button aria-label="Close" onClick={close} className="size-8">
|
||||
<X width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Card } from "@/components/atoms/Card/Card";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowRight, List, Robot } from "@phosphor-icons/react";
|
||||
import Image from "next/image";
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version?: number;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface AgentCarouselMessageProps {
|
||||
agents: Agent[];
|
||||
totalCount?: number;
|
||||
onSelectAgent?: (agentId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentCarouselMessage({
|
||||
agents,
|
||||
totalCount,
|
||||
onSelectAgent,
|
||||
className,
|
||||
}: AgentCarouselMessageProps) {
|
||||
const displayCount = totalCount ?? agents.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 my-2 flex flex-col gap-4 rounded-lg border border-purple-200 bg-purple-50 p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500">
|
||||
<List size={24} weight="bold" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="h3" className="text-purple-900">
|
||||
Found {displayCount} {displayCount === 1 ? "Agent" : "Agents"}
|
||||
</Text>
|
||||
<Text variant="small" className="text-purple-700">
|
||||
Select an agent to view details or run it
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Cards */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{agents.map((agent) => (
|
||||
<Card
|
||||
key={agent.id}
|
||||
className="border border-purple-200 bg-white p-4"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative h-10 w-10 flex-shrink-0 overflow-hidden rounded-lg bg-purple-100">
|
||||
{agent.image_url ? (
|
||||
<Image
|
||||
src={agent.image_url}
|
||||
alt={`${agent.name} preview image`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Robot
|
||||
size={20}
|
||||
weight="bold"
|
||||
className="text-purple-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<Text
|
||||
variant="body"
|
||||
className="font-semibold text-purple-900"
|
||||
>
|
||||
{agent.name}
|
||||
</Text>
|
||||
{agent.version && (
|
||||
<Text variant="small" className="text-purple-600">
|
||||
v{agent.version}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Text variant="small" className="line-clamp-2 text-purple-700">
|
||||
{agent.description}
|
||||
</Text>
|
||||
{onSelectAgent && (
|
||||
<Button
|
||||
onClick={() => onSelectAgent(agent.id)}
|
||||
variant="ghost"
|
||||
className="mt-2 flex items-center gap-1 p-0 text-sm text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
View details
|
||||
<ArrowRight size={16} weight="bold" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalCount && totalCount > agents.length && (
|
||||
<Text variant="small" className="text-center text-purple-600">
|
||||
Showing {agents.length} of {totalCount} results
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Card } from "@/components/atoms/Card/Card";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs";
|
||||
import type {
|
||||
BlockIOCredentialsSubSchema,
|
||||
BlockIOSubSchema,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlayIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { useAgentInputsSetup } from "./useAgentInputsSetup";
|
||||
|
||||
interface Props {
|
||||
agentName?: string;
|
||||
inputSchema: Record<string, BlockIOSubSchema>;
|
||||
credentialsSchema?: Record<string, BlockIOCredentialsSubSchema>;
|
||||
message: string;
|
||||
onRun: (
|
||||
inputs: Record<string, any>,
|
||||
credentials: Record<string, any>,
|
||||
) => void;
|
||||
onCancel?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentInputsSetup({
|
||||
agentName,
|
||||
inputSchema,
|
||||
credentialsSchema,
|
||||
message,
|
||||
onRun,
|
||||
onCancel,
|
||||
className,
|
||||
}: Props) {
|
||||
const { inputValues, setInputValue, credentialsValues, setCredentialsValue } =
|
||||
useAgentInputsSetup();
|
||||
|
||||
const inputFields = Object.entries(inputSchema || {});
|
||||
const credentialFields = Object.entries(credentialsSchema || {});
|
||||
|
||||
const allRequiredInputsAreSet = useMemo(() => {
|
||||
const requiredFields = Object.entries(inputSchema || {}).filter(
|
||||
([_, schema]) => !schema.hidden,
|
||||
);
|
||||
return requiredFields.every(([key]) => {
|
||||
const value = inputValues[key];
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
});
|
||||
}, [inputSchema, inputValues]);
|
||||
|
||||
const allCredentialsAreSet = useMemo(() => {
|
||||
if (!credentialsSchema || Object.keys(credentialsSchema).length === 0) {
|
||||
return true;
|
||||
}
|
||||
return Object.keys(credentialsSchema).every(
|
||||
(key) => credentialsValues[key] !== undefined,
|
||||
);
|
||||
}, [credentialsSchema, credentialsValues]);
|
||||
|
||||
const canRun = allRequiredInputsAreSet && allCredentialsAreSet;
|
||||
|
||||
function handleRun() {
|
||||
if (canRun) {
|
||||
onRun(inputValues, credentialsValues);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"mx-4 my-2 overflow-hidden border-blue-200 bg-blue-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4 p-6">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-500">
|
||||
<WarningIcon size={24} weight="bold" className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Text variant="h3" className="mb-2 text-blue-900">
|
||||
{agentName ? `Configure ${agentName}` : "Agent Configuration"}
|
||||
</Text>
|
||||
<Text variant="body" className="mb-4 text-blue-700">
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
{inputFields.length > 0 && (
|
||||
<div className="mb-4 space-y-4">
|
||||
{inputFields.map(([key, schema]) => {
|
||||
if (schema.hidden) return null;
|
||||
const defaultValue = (schema as any).default;
|
||||
return (
|
||||
<RunAgentInputs
|
||||
key={key}
|
||||
schema={schema}
|
||||
value={inputValues[key] ?? defaultValue}
|
||||
placeholder={schema.description}
|
||||
onChange={(value) => setInputValue(key, value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialFields.length > 0 && (
|
||||
<div className="mb-4 space-y-4">
|
||||
{credentialFields.map(([key, schema]) => (
|
||||
<CredentialsInput
|
||||
key={key}
|
||||
schema={schema}
|
||||
selectedCredentials={credentialsValues[key]}
|
||||
onSelectCredentials={(value) =>
|
||||
setCredentialsValue(key, value)
|
||||
}
|
||||
siblingInputs={inputValues}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={handleRun}
|
||||
disabled={!canRun}
|
||||
>
|
||||
<PlayIcon className="mr-2 h-4 w-4" weight="bold" />
|
||||
Run Agent
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button variant="outline" size="small" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import { useState } from "react";
|
||||
|
||||
export function useAgentInputsSetup() {
|
||||
const [inputValues, setInputValues] = useState<Record<string, any>>({});
|
||||
const [credentialsValues, setCredentialsValues] = useState<
|
||||
Record<string, CredentialsMetaInput>
|
||||
>({});
|
||||
|
||||
function setInputValue(key: string, value: any) {
|
||||
setInputValues((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
function setCredentialsValue(key: string, value?: CredentialsMetaInput) {
|
||||
if (value) {
|
||||
setCredentialsValues((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
} else {
|
||||
setCredentialsValues((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inputValues,
|
||||
setInputValue,
|
||||
credentialsValues,
|
||||
setCredentialsValue,
|
||||
};
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ShieldIcon, SignInIcon, UserPlusIcon } from "@phosphor-icons/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export interface AuthPromptWidgetProps {
|
||||
message: string;
|
||||
sessionId: string;
|
||||
agentInfo?: {
|
||||
graph_id: string;
|
||||
name: string;
|
||||
trigger_type: string;
|
||||
};
|
||||
returnUrl?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AuthPromptWidget({
|
||||
message,
|
||||
sessionId,
|
||||
agentInfo,
|
||||
returnUrl = "/chat",
|
||||
className,
|
||||
}: AuthPromptWidgetProps) {
|
||||
const router = useRouter();
|
||||
|
||||
function handleSignIn() {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("pending_chat_session", sessionId);
|
||||
if (agentInfo) {
|
||||
localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo));
|
||||
}
|
||||
}
|
||||
const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`;
|
||||
const encodedReturnUrl = encodeURIComponent(returnUrlWithSession);
|
||||
router.push(`/login?returnUrl=${encodedReturnUrl}`);
|
||||
}
|
||||
|
||||
function handleSignUp() {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("pending_chat_session", sessionId);
|
||||
if (agentInfo) {
|
||||
localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo));
|
||||
}
|
||||
}
|
||||
const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`;
|
||||
const encodedReturnUrl = encodeURIComponent(returnUrlWithSession);
|
||||
router.push(`/signup?returnUrl=${encodedReturnUrl}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"my-4 overflow-hidden rounded-lg border border-violet-200",
|
||||
"bg-gradient-to-br from-violet-50 to-purple-50",
|
||||
"duration-500 animate-in fade-in-50 slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="px-6 py-5">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-600">
|
||||
<ShieldIcon size={20} weight="fill" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">
|
||||
Authentication Required
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Sign in to set up and manage agents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-md bg-white/50 p-4">
|
||||
<p className="text-sm text-neutral-700">{message}</p>
|
||||
{agentInfo && (
|
||||
<div className="mt-3 text-xs text-neutral-600">
|
||||
<p>
|
||||
Ready to set up:{" "}
|
||||
<span className="font-medium">{agentInfo.name}</span>
|
||||
</p>
|
||||
<p>
|
||||
Type:{" "}
|
||||
<span className="font-medium">{agentInfo.trigger_type}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleSignIn}
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="flex-1"
|
||||
>
|
||||
<SignInIcon size={16} weight="bold" className="mr-2" />
|
||||
Sign In
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSignUp}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="flex-1"
|
||||
>
|
||||
<UserPlusIcon size={16} weight="bold" className="mr-2" />
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-xs text-neutral-500">
|
||||
Your chat session will be preserved after signing in
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback } from "react";
|
||||
import { usePageContext } from "../../usePageContext";
|
||||
import { ChatInput } from "../ChatInput/ChatInput";
|
||||
import { MessageList } from "../MessageList/MessageList";
|
||||
import { QuickActionsWelcome } from "../QuickActionsWelcome/QuickActionsWelcome";
|
||||
import { useChatContainer } from "./useChatContainer";
|
||||
|
||||
export interface ChatContainerProps {
|
||||
sessionId: string | null;
|
||||
initialMessages: SessionDetailResponse["messages"];
|
||||
onRefreshSession: () => Promise<void>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
sessionId,
|
||||
initialMessages,
|
||||
onRefreshSession,
|
||||
className,
|
||||
}: ChatContainerProps) {
|
||||
const { messages, streamingChunks, isStreaming, sendMessage } =
|
||||
useChatContainer({
|
||||
sessionId,
|
||||
initialMessages,
|
||||
onRefreshSession,
|
||||
});
|
||||
const { capturePageContext } = usePageContext();
|
||||
|
||||
// Wrap sendMessage to automatically capture page context
|
||||
const sendMessageWithContext = useCallback(
|
||||
async (content: string, isUserMessage: boolean = true) => {
|
||||
const context = capturePageContext();
|
||||
await sendMessage(content, isUserMessage, context);
|
||||
},
|
||||
[sendMessage, capturePageContext],
|
||||
);
|
||||
|
||||
const quickActions = [
|
||||
"Find agents for social media management",
|
||||
"Show me agents for content creation",
|
||||
"Help me automate my business",
|
||||
"What can you help me with?",
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex h-full flex-col", className)}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundImage:
|
||||
"radial-gradient(#e5e5e5 0.5px, transparent 0.5px), radial-gradient(#e5e5e5 0.5px, #ffffff 0.5px)",
|
||||
backgroundSize: "20px 20px",
|
||||
backgroundPosition: "0 0, 10px 10px",
|
||||
}}
|
||||
>
|
||||
{/* Messages or Welcome Screen */}
|
||||
{messages.length === 0 ? (
|
||||
<QuickActionsWelcome
|
||||
title="Welcome to AutoGPT Copilot"
|
||||
description="Start a conversation to discover and run AI agents."
|
||||
actions={quickActions}
|
||||
onActionClick={sendMessageWithContext}
|
||||
disabled={isStreaming || !sessionId}
|
||||
/>
|
||||
) : (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingChunks={streamingChunks}
|
||||
isStreaming={isStreaming}
|
||||
onSendMessage={sendMessageWithContext}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input - Always visible */}
|
||||
<div className="border-t border-zinc-200 p-4">
|
||||
<ChatInput
|
||||
onSend={sendMessageWithContext}
|
||||
disabled={isStreaming || !sessionId}
|
||||
placeholder={
|
||||
sessionId ? "Type your message..." : "Creating session..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { StreamChunk } from "@/components/contextual/Chat/useChatStream";
|
||||
import { toast } from "sonner";
|
||||
import type { HandlerDependencies } from "./useChatContainer.handlers";
|
||||
import {
|
||||
handleError,
|
||||
handleLoginNeeded,
|
||||
handleStreamEnd,
|
||||
handleTextChunk,
|
||||
handleTextEnded,
|
||||
handleToolCallStart,
|
||||
handleToolResponse,
|
||||
} from "./useChatContainer.handlers";
|
||||
|
||||
export function createStreamEventDispatcher(
|
||||
deps: HandlerDependencies,
|
||||
): (chunk: StreamChunk) => void {
|
||||
return function dispatchStreamEvent(chunk: StreamChunk): void {
|
||||
switch (chunk.type) {
|
||||
case "text_chunk":
|
||||
handleTextChunk(chunk, deps);
|
||||
break;
|
||||
|
||||
case "text_ended":
|
||||
handleTextEnded(chunk, deps);
|
||||
break;
|
||||
|
||||
case "tool_call_start":
|
||||
handleToolCallStart(chunk, deps);
|
||||
break;
|
||||
|
||||
case "tool_response":
|
||||
handleToolResponse(chunk, deps);
|
||||
break;
|
||||
|
||||
case "login_needed":
|
||||
case "need_login":
|
||||
handleLoginNeeded(chunk, deps);
|
||||
break;
|
||||
|
||||
case "stream_end":
|
||||
handleStreamEnd(chunk, deps);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
handleError(chunk, deps);
|
||||
// Show toast at dispatcher level to avoid circular dependencies
|
||||
toast.error("Chat Error", {
|
||||
description: chunk.message || chunk.content || "An error occurred",
|
||||
});
|
||||
break;
|
||||
|
||||
case "usage":
|
||||
// TODO: Handle usage for display
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("Unknown stream chunk type:", chunk);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
import type { ToolResult } from "@/types/chat";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
|
||||
export function removePageContext(content: string): string {
|
||||
// Remove "Page URL: ..." pattern (case insensitive, handles various formats)
|
||||
let cleaned = content.replace(/Page URL:\s*[^\n\r]*/gi, "");
|
||||
|
||||
// Find "User Message:" marker to preserve the actual user message
|
||||
const userMessageMatch = cleaned.match(/User Message:\s*([\s\S]*)$/i);
|
||||
if (userMessageMatch) {
|
||||
// If we found "User Message:", extract everything after it
|
||||
cleaned = userMessageMatch[1];
|
||||
} else {
|
||||
// If no "User Message:" marker, remove "Page Content:" and everything after it
|
||||
cleaned = cleaned.replace(/Page Content:[\s\S]*$/gi, "");
|
||||
}
|
||||
|
||||
// Clean up extra whitespace and newlines
|
||||
cleaned = cleaned.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function createUserMessage(content: string): ChatMessageData {
|
||||
return {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterAuthMessages(
|
||||
messages: ChatMessageData[],
|
||||
): ChatMessageData[] {
|
||||
return messages.filter(
|
||||
(msg) => msg.type !== "credentials_needed" && msg.type !== "login_needed",
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidMessage(msg: unknown): msg is Record<string, unknown> {
|
||||
if (typeof msg !== "object" || msg === null) {
|
||||
return false;
|
||||
}
|
||||
const m = msg as Record<string, unknown>;
|
||||
if (typeof m.role !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (m.content !== undefined && typeof m.content !== "string") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isToolCallArray(value: unknown): value is Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
function: { name: string; arguments: string };
|
||||
}> {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return value.every(
|
||||
(item) =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
"id" in item &&
|
||||
typeof item.id === "string" &&
|
||||
"type" in item &&
|
||||
typeof item.type === "string" &&
|
||||
"function" in item &&
|
||||
typeof item.function === "object" &&
|
||||
item.function !== null &&
|
||||
"name" in item.function &&
|
||||
typeof item.function.name === "string" &&
|
||||
"arguments" in item.function &&
|
||||
typeof item.function.arguments === "string",
|
||||
);
|
||||
}
|
||||
|
||||
export function isAgentArray(value: unknown): value is Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version?: number;
|
||||
image_url?: string;
|
||||
}> {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return value.every(
|
||||
(item) =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
"id" in item &&
|
||||
typeof item.id === "string" &&
|
||||
"name" in item &&
|
||||
typeof item.name === "string" &&
|
||||
"description" in item &&
|
||||
typeof item.description === "string" &&
|
||||
(!("version" in item) || typeof item.version === "number") &&
|
||||
(!("image_url" in item) || typeof item.image_url === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
export function extractJsonFromErrorMessage(
|
||||
message: string,
|
||||
): Record<string, unknown> | null {
|
||||
try {
|
||||
const start = message.indexOf("{");
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
for (let i = start; i < message.length; i++) {
|
||||
const ch = message[i];
|
||||
if (ch === "{") {
|
||||
depth++;
|
||||
} else if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end === -1) {
|
||||
return null;
|
||||
}
|
||||
const jsonStr = message.slice(start, end + 1);
|
||||
return JSON.parse(jsonStr) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseToolResponse(
|
||||
result: ToolResult,
|
||||
toolId: string,
|
||||
toolName: string,
|
||||
timestamp?: Date,
|
||||
): ChatMessageData | null {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof result === "string"
|
||||
? JSON.parse(result)
|
||||
: (result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult && typeof parsedResult === "object") {
|
||||
const responseType = parsedResult.type as string | undefined;
|
||||
if (responseType === "no_results") {
|
||||
return {
|
||||
type: "tool_response",
|
||||
toolId,
|
||||
toolName,
|
||||
result: (parsedResult.message as string) || "No results found",
|
||||
success: true,
|
||||
timestamp: timestamp || new Date(),
|
||||
};
|
||||
}
|
||||
if (responseType === "agent_carousel") {
|
||||
const agentsData = parsedResult.agents;
|
||||
if (isAgentArray(agentsData)) {
|
||||
return {
|
||||
type: "agent_carousel",
|
||||
toolName: "agent_carousel",
|
||||
agents: agentsData,
|
||||
totalCount: parsedResult.total_count as number | undefined,
|
||||
timestamp: timestamp || new Date(),
|
||||
};
|
||||
} else {
|
||||
console.warn("Invalid agents array in agent_carousel response");
|
||||
}
|
||||
}
|
||||
if (responseType === "execution_started") {
|
||||
return {
|
||||
type: "execution_started",
|
||||
toolName: "execution_started",
|
||||
executionId: (parsedResult.execution_id as string) || "",
|
||||
agentName: (parsedResult.graph_name as string) || undefined,
|
||||
message: parsedResult.message as string | undefined,
|
||||
libraryAgentLink: parsedResult.library_agent_link as string | undefined,
|
||||
timestamp: timestamp || new Date(),
|
||||
};
|
||||
}
|
||||
if (responseType === "need_login") {
|
||||
return {
|
||||
type: "login_needed",
|
||||
toolName: "login_needed",
|
||||
message:
|
||||
(parsedResult.message as string) ||
|
||||
"Please sign in to use chat and agent features",
|
||||
sessionId: (parsedResult.session_id as string) || "",
|
||||
agentInfo: parsedResult.agent_info as
|
||||
| {
|
||||
graph_id: string;
|
||||
name: string;
|
||||
trigger_type: string;
|
||||
}
|
||||
| undefined,
|
||||
timestamp: timestamp || new Date(),
|
||||
};
|
||||
}
|
||||
if (responseType === "setup_requirements") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "tool_response",
|
||||
toolId,
|
||||
toolName,
|
||||
result,
|
||||
success: true,
|
||||
timestamp: timestamp || new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export function isUserReadiness(
|
||||
value: unknown,
|
||||
): value is { missing_credentials?: Record<string, unknown> } {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(!("missing_credentials" in value) ||
|
||||
typeof (value as any).missing_credentials === "object")
|
||||
);
|
||||
}
|
||||
|
||||
export function isMissingCredentials(
|
||||
value: unknown,
|
||||
): value is Record<string, Record<string, unknown>> {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every((v) => typeof v === "object" && v !== null);
|
||||
}
|
||||
|
||||
export function isSetupInfo(value: unknown): value is {
|
||||
user_readiness?: Record<string, unknown>;
|
||||
agent_name?: string;
|
||||
} {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(!("user_readiness" in value) ||
|
||||
typeof (value as any).user_readiness === "object") &&
|
||||
(!("agent_name" in value) || typeof (value as any).agent_name === "string")
|
||||
);
|
||||
}
|
||||
|
||||
export function extractCredentialsNeeded(
|
||||
parsedResult: Record<string, unknown>,
|
||||
toolName: string = "run_agent",
|
||||
): ChatMessageData | null {
|
||||
try {
|
||||
const setupInfo = parsedResult?.setup_info as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const userReadiness = setupInfo?.user_readiness as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const missingCreds = userReadiness?.missing_credentials as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| 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:
|
||||
(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,
|
||||
}));
|
||||
return {
|
||||
type: "credentials_needed",
|
||||
toolName,
|
||||
credentials,
|
||||
message: `To run ${agentName}, you need to add ${credentials.length === 1 ? "credentials" : `${credentials.length} credentials`}.`,
|
||||
agentName,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("Failed to extract credentials from setup info:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractInputsNeeded(
|
||||
parsedResult: Record<string, unknown>,
|
||||
toolName: string = "run_agent",
|
||||
): ChatMessageData | null {
|
||||
try {
|
||||
const setupInfo = parsedResult?.setup_info as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const requirements = setupInfo?.requirements as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const inputs = requirements?.inputs as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
const credentials = requirements?.credentials as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
|
||||
if (!inputs || inputs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentName = (setupInfo?.agent_name as string) || "this agent";
|
||||
const agentId = parsedResult?.graph_id as string | undefined;
|
||||
const graphVersion = parsedResult?.graph_version as number | undefined;
|
||||
|
||||
const inputSchema: Record<string, any> = {};
|
||||
inputs.forEach((input) => {
|
||||
const name = input.name as string;
|
||||
if (name) {
|
||||
inputSchema[name] = {
|
||||
title: input.name as string,
|
||||
description: (input.description as string) || "",
|
||||
type: (input.type as string) || "string",
|
||||
default: input.default,
|
||||
required: (input.required as boolean) || false,
|
||||
enum: input.options,
|
||||
format: input.format,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const credentialsSchema: Record<string, any> = {};
|
||||
if (credentials && credentials.length > 0) {
|
||||
credentials.forEach((cred) => {
|
||||
const id = cred.id as string;
|
||||
if (id) {
|
||||
credentialsSchema[id] = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
credentials_provider: [cred.provider as string],
|
||||
credentials_types: [(cred.type as string) || "api_key"],
|
||||
credentials_scopes: cred.scopes as string[] | undefined,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "inputs_needed",
|
||||
toolName,
|
||||
agentName,
|
||||
agentId,
|
||||
graphVersion,
|
||||
inputSchema,
|
||||
credentialsSchema:
|
||||
Object.keys(credentialsSchema).length > 0
|
||||
? credentialsSchema
|
||||
: undefined,
|
||||
message: `Please provide the required inputs to run ${agentName}.`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Failed to extract inputs from setup info:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import type { StreamChunk } from "@/components/contextual/Chat/useChatStream";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import {
|
||||
extractCredentialsNeeded,
|
||||
extractInputsNeeded,
|
||||
parseToolResponse,
|
||||
} from "./helpers";
|
||||
|
||||
export interface HandlerDependencies {
|
||||
setHasTextChunks: Dispatch<SetStateAction<boolean>>;
|
||||
setStreamingChunks: Dispatch<SetStateAction<string[]>>;
|
||||
streamingChunksRef: MutableRefObject<string[]>;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
|
||||
setIsStreamingInitiated: Dispatch<SetStateAction<boolean>>;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function handleTextChunk(chunk: StreamChunk, deps: HandlerDependencies) {
|
||||
if (!chunk.content) return;
|
||||
deps.setHasTextChunks(true);
|
||||
deps.setStreamingChunks((prev) => {
|
||||
const updated = [...prev, chunk.content!];
|
||||
deps.streamingChunksRef.current = updated;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export function handleTextEnded(
|
||||
_chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
console.log("[Text Ended] Saving streamed text as assistant message");
|
||||
const completedText = deps.streamingChunksRef.current.join("");
|
||||
if (completedText.trim()) {
|
||||
const assistantMessage: ChatMessageData = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: completedText,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
deps.setMessages((prev) => [...prev, assistantMessage]);
|
||||
}
|
||||
deps.setStreamingChunks([]);
|
||||
deps.streamingChunksRef.current = [];
|
||||
deps.setHasTextChunks(false);
|
||||
deps.setIsStreamingInitiated(false);
|
||||
}
|
||||
|
||||
export function handleToolCallStart(
|
||||
chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
const toolCallMessage: ChatMessageData = {
|
||||
type: "tool_call",
|
||||
toolId: chunk.tool_id || `tool-${Date.now()}-${chunk.idx || 0}`,
|
||||
toolName: chunk.tool_name || "Executing...",
|
||||
arguments: chunk.arguments || {},
|
||||
timestamp: new Date(),
|
||||
};
|
||||
deps.setMessages((prev) => [...prev, toolCallMessage]);
|
||||
console.log("[Tool Call Start]", {
|
||||
toolId: toolCallMessage.toolId,
|
||||
toolName: toolCallMessage.toolName,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function handleToolResponse(
|
||||
chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
console.log("[Tool Response] Received:", {
|
||||
toolId: chunk.tool_id,
|
||||
toolName: chunk.tool_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
let toolName = chunk.tool_name || "unknown";
|
||||
if (!chunk.tool_name || chunk.tool_name === "unknown") {
|
||||
deps.setMessages((prev) => {
|
||||
const matchingToolCall = [...prev]
|
||||
.reverse()
|
||||
.find(
|
||||
(msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
|
||||
);
|
||||
if (matchingToolCall && matchingToolCall.type === "tool_call") {
|
||||
toolName = matchingToolCall.toolName;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
const responseMessage = parseToolResponse(
|
||||
chunk.result!,
|
||||
chunk.tool_id!,
|
||||
toolName,
|
||||
new Date(),
|
||||
);
|
||||
if (!responseMessage) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof chunk.result === "string"
|
||||
? JSON.parse(chunk.result)
|
||||
: (chunk.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (
|
||||
(chunk.tool_name === "run_agent" || chunk.tool_name === "run_block") &&
|
||||
chunk.success &&
|
||||
parsedResult?.type === "setup_requirements"
|
||||
) {
|
||||
const inputsMessage = extractInputsNeeded(parsedResult, chunk.tool_name);
|
||||
if (inputsMessage) {
|
||||
deps.setMessages((prev) => [...prev, inputsMessage]);
|
||||
}
|
||||
const credentialsMessage = extractCredentialsNeeded(
|
||||
parsedResult,
|
||||
chunk.tool_name,
|
||||
);
|
||||
if (credentialsMessage) {
|
||||
deps.setMessages((prev) => [...prev, credentialsMessage]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
deps.setMessages((prev) => {
|
||||
const toolCallIndex = prev.findIndex(
|
||||
(msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
|
||||
);
|
||||
if (toolCallIndex !== -1) {
|
||||
const newMessages = [...prev];
|
||||
newMessages[toolCallIndex] = responseMessage;
|
||||
console.log(
|
||||
"[Tool Response] Replaced tool_call with matching tool_id:",
|
||||
chunk.tool_id,
|
||||
"at index:",
|
||||
toolCallIndex,
|
||||
);
|
||||
return newMessages;
|
||||
}
|
||||
console.warn(
|
||||
"[Tool Response] No tool_call found with tool_id:",
|
||||
chunk.tool_id,
|
||||
"appending instead",
|
||||
);
|
||||
return [...prev, responseMessage];
|
||||
});
|
||||
}
|
||||
|
||||
export function handleLoginNeeded(
|
||||
chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
const loginNeededMessage: ChatMessageData = {
|
||||
type: "login_needed",
|
||||
toolName: "login_needed",
|
||||
message: chunk.message || "Please sign in to use chat and agent features",
|
||||
sessionId: chunk.session_id || deps.sessionId,
|
||||
agentInfo: chunk.agent_info,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
deps.setMessages((prev) => [...prev, loginNeededMessage]);
|
||||
}
|
||||
|
||||
export function handleStreamEnd(
|
||||
_chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
const completedContent = deps.streamingChunksRef.current.join("");
|
||||
// Only save message if there are uncommitted chunks
|
||||
// (text_ended already saved if there were tool calls)
|
||||
if (completedContent.trim()) {
|
||||
console.log(
|
||||
"[Stream End] Saving remaining streamed text as assistant message",
|
||||
);
|
||||
const assistantMessage: ChatMessageData = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: completedContent,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
deps.setMessages((prev) => {
|
||||
const updated = [...prev, assistantMessage];
|
||||
console.log("[Stream End] Final state:", {
|
||||
localMessages: updated.map((m) => ({
|
||||
type: m.type,
|
||||
...(m.type === "message" && {
|
||||
role: m.role,
|
||||
contentLength: m.content.length,
|
||||
}),
|
||||
...(m.type === "tool_call" && {
|
||||
toolId: m.toolId,
|
||||
toolName: m.toolName,
|
||||
}),
|
||||
...(m.type === "tool_response" && {
|
||||
toolId: m.toolId,
|
||||
toolName: m.toolName,
|
||||
success: m.success,
|
||||
}),
|
||||
})),
|
||||
streamingChunks: deps.streamingChunksRef.current,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
console.log("[Stream End] No uncommitted chunks, message already saved");
|
||||
}
|
||||
deps.setStreamingChunks([]);
|
||||
deps.streamingChunksRef.current = [];
|
||||
deps.setHasTextChunks(false);
|
||||
deps.setIsStreamingInitiated(false);
|
||||
console.log("[Stream End] Stream complete, messages in local state");
|
||||
}
|
||||
|
||||
export function handleError(chunk: StreamChunk, deps: HandlerDependencies) {
|
||||
const errorMessage = chunk.message || chunk.content || "An error occurred";
|
||||
console.error("Stream error:", errorMessage);
|
||||
deps.setIsStreamingInitiated(false);
|
||||
deps.setHasTextChunks(false);
|
||||
deps.setStreamingChunks([]);
|
||||
deps.streamingChunksRef.current = [];
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import { useChatStream } from "@/components/contextual/Chat/useChatStream";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
|
||||
import {
|
||||
createUserMessage,
|
||||
filterAuthMessages,
|
||||
isToolCallArray,
|
||||
isValidMessage,
|
||||
parseToolResponse,
|
||||
removePageContext,
|
||||
} from "./helpers";
|
||||
|
||||
interface UseChatContainerArgs {
|
||||
sessionId: string | null;
|
||||
initialMessages: SessionDetailResponse["messages"];
|
||||
onRefreshSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useChatContainer({
|
||||
sessionId,
|
||||
initialMessages,
|
||||
}: UseChatContainerArgs) {
|
||||
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
||||
const [streamingChunks, setStreamingChunks] = useState<string[]>([]);
|
||||
const [hasTextChunks, setHasTextChunks] = useState(false);
|
||||
const [isStreamingInitiated, setIsStreamingInitiated] = useState(false);
|
||||
const streamingChunksRef = useRef<string[]>([]);
|
||||
const { error, sendMessage: sendStreamMessage } = useChatStream();
|
||||
const isStreaming = isStreamingInitiated || hasTextChunks;
|
||||
|
||||
const allMessages = useMemo(() => {
|
||||
const processedInitialMessages: ChatMessageData[] = [];
|
||||
// Map to track tool calls by their ID so we can look up tool names for tool responses
|
||||
const toolCallMap = new Map<string, string>();
|
||||
|
||||
for (const msg of initialMessages) {
|
||||
if (!isValidMessage(msg)) {
|
||||
console.warn("Invalid message structure from backend:", msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = String(msg.content || "");
|
||||
const role = String(msg.role || "assistant").toLowerCase();
|
||||
const toolCalls = msg.tool_calls;
|
||||
const timestamp = msg.timestamp
|
||||
? new Date(msg.timestamp as string)
|
||||
: undefined;
|
||||
|
||||
// Remove page context from user messages when loading existing sessions
|
||||
if (role === "user") {
|
||||
content = removePageContext(content);
|
||||
// Skip user messages that become empty after removing page context
|
||||
if (!content.trim()) {
|
||||
continue;
|
||||
}
|
||||
processedInitialMessages.push({
|
||||
type: "message",
|
||||
role: "user",
|
||||
content,
|
||||
timestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle assistant messages first (before tool messages) to build tool call map
|
||||
if (role === "assistant") {
|
||||
// Strip <thinking> tags from content
|
||||
content = content
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.trim();
|
||||
|
||||
// If assistant has tool calls, create tool_call messages for each
|
||||
if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) {
|
||||
for (const toolCall of toolCalls) {
|
||||
const toolName = toolCall.function.name;
|
||||
const toolId = toolCall.id;
|
||||
// Store tool name for later lookup
|
||||
toolCallMap.set(toolId, toolName);
|
||||
|
||||
try {
|
||||
const args = JSON.parse(toolCall.function.arguments || "{}");
|
||||
processedInitialMessages.push({
|
||||
type: "tool_call",
|
||||
toolId,
|
||||
toolName,
|
||||
arguments: args,
|
||||
timestamp,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse tool call arguments:", err);
|
||||
processedInitialMessages.push({
|
||||
type: "tool_call",
|
||||
toolId,
|
||||
toolName,
|
||||
arguments: {},
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only add assistant message if there's content after stripping thinking tags
|
||||
if (content.trim()) {
|
||||
processedInitialMessages.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
} else if (content.trim()) {
|
||||
// Assistant message without tool calls, but with content
|
||||
processedInitialMessages.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle tool messages - look up tool name from tool call map
|
||||
if (role === "tool") {
|
||||
const toolCallId = (msg.tool_call_id as string) || "";
|
||||
const toolName = toolCallMap.get(toolCallId) || "unknown";
|
||||
const toolResponse = parseToolResponse(
|
||||
content,
|
||||
toolCallId,
|
||||
toolName,
|
||||
timestamp,
|
||||
);
|
||||
if (toolResponse) {
|
||||
processedInitialMessages.push(toolResponse);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle other message types (system, etc.)
|
||||
if (content.trim()) {
|
||||
processedInitialMessages.push({
|
||||
type: "message",
|
||||
role: role as "user" | "assistant" | "system",
|
||||
content,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...processedInitialMessages, ...messages];
|
||||
}, [initialMessages, messages]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async function sendMessage(
|
||||
content: string,
|
||||
isUserMessage: boolean = true,
|
||||
context?: { url: string; content: string },
|
||||
) {
|
||||
if (!sessionId) {
|
||||
console.error("Cannot send message: no session ID");
|
||||
return;
|
||||
}
|
||||
if (isUserMessage) {
|
||||
const userMessage = createUserMessage(content);
|
||||
setMessages((prev) => [...filterAuthMessages(prev), userMessage]);
|
||||
} else {
|
||||
setMessages((prev) => filterAuthMessages(prev));
|
||||
}
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
setIsStreamingInitiated(true);
|
||||
const dispatcher = createStreamEventDispatcher({
|
||||
setHasTextChunks,
|
||||
setStreamingChunks,
|
||||
streamingChunksRef,
|
||||
setMessages,
|
||||
sessionId,
|
||||
setIsStreamingInitiated,
|
||||
});
|
||||
try {
|
||||
await sendStreamMessage(
|
||||
sessionId,
|
||||
content,
|
||||
dispatcher,
|
||||
isUserMessage,
|
||||
context,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to send message:", err);
|
||||
setIsStreamingInitiated(false);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to send message";
|
||||
toast.error("Failed to send message", {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
},
|
||||
[sessionId, sendStreamMessage],
|
||||
);
|
||||
|
||||
return {
|
||||
messages: allMessages,
|
||||
streamingChunks,
|
||||
isStreaming,
|
||||
error,
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import type { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, RobotIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useChatCredentialsSetup } from "./useChatCredentialsSetup";
|
||||
|
||||
export interface CredentialInfo {
|
||||
provider: string;
|
||||
providerName: string;
|
||||
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
|
||||
title: string;
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
credentials: CredentialInfo[];
|
||||
agentName?: string;
|
||||
message: string;
|
||||
onAllCredentialsComplete: () => void;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function createSchemaFromCredentialInfo(
|
||||
credential: CredentialInfo,
|
||||
): BlockIOCredentialsSubSchema {
|
||||
return {
|
||||
type: "object",
|
||||
properties: {},
|
||||
credentials_provider: [credential.provider],
|
||||
credentials_types: [credential.credentialType],
|
||||
credentials_scopes: credential.scopes,
|
||||
discriminator: undefined,
|
||||
discriminator_mapping: undefined,
|
||||
discriminator_values: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatCredentialsSetup({
|
||||
credentials,
|
||||
agentName: _agentName,
|
||||
message,
|
||||
onAllCredentialsComplete,
|
||||
onCancel: _onCancel,
|
||||
}: Props) {
|
||||
const { selectedCredentials, isAllComplete, handleCredentialSelect } =
|
||||
useChatCredentialsSetup(credentials);
|
||||
|
||||
// Track if we've already called completion to prevent double calls
|
||||
const hasCalledCompleteRef = useRef(false);
|
||||
|
||||
// Reset the completion flag when credentials change (new credential setup flow)
|
||||
useEffect(
|
||||
function resetCompletionFlag() {
|
||||
hasCalledCompleteRef.current = false;
|
||||
},
|
||||
[credentials],
|
||||
);
|
||||
|
||||
// Auto-call completion when all credentials are configured
|
||||
useEffect(
|
||||
function autoCompleteWhenReady() {
|
||||
if (isAllComplete && !hasCalledCompleteRef.current) {
|
||||
hasCalledCompleteRef.current = true;
|
||||
onAllCredentialsComplete();
|
||||
}
|
||||
},
|
||||
[isAllComplete, onAllCredentialsComplete],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group relative flex w-full justify-start gap-3 px-4 py-3">
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="group relative min-w-20 overflow-hidden rounded-xl border border-slate-100 bg-slate-50/20 px-6 py-2.5 text-sm leading-relaxed backdrop-blur-xl">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-200/20 via-slate-300/10 to-transparent" />
|
||||
<div className="relative z-10 space-y-3 text-slate-900">
|
||||
<div>
|
||||
<Text variant="h4" className="mb-1 text-slate-900">
|
||||
Credentials Required
|
||||
</Text>
|
||||
<Text variant="small" className="text-slate-600">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{credentials.map((cred, index) => {
|
||||
const schema = createSchemaFromCredentialInfo(cred);
|
||||
const isSelected = !!selectedCredentials[cred.provider];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${cred.provider}-${index}`}
|
||||
className={cn(
|
||||
"relative rounded-lg border p-3",
|
||||
isSelected
|
||||
? "border-green-500 bg-green-50/50"
|
||||
: "border-slate-200 bg-white/50",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isSelected ? (
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className="text-green-500"
|
||||
weight="bold"
|
||||
/>
|
||||
) : (
|
||||
<WarningIcon
|
||||
size={16}
|
||||
className="text-slate-500"
|
||||
weight="bold"
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-semibold text-slate-900"
|
||||
>
|
||||
{cred.providerName}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<CredentialsInput
|
||||
schema={schema}
|
||||
selectedCredentials={selectedCredentials[cred.provider]}
|
||||
onSelectCredentials={(credMeta) =>
|
||||
handleCredentialSelect(cred.provider, credMeta)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import type { CredentialInfo } from "./ChatCredentialsSetup";
|
||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api";
|
||||
|
||||
export function useChatCredentialsSetup(credentials: CredentialInfo[]) {
|
||||
const [selectedCredentials, setSelectedCredentials] = useState<
|
||||
Record<string, CredentialsMetaInput>
|
||||
>({});
|
||||
|
||||
// Check if all credentials are configured
|
||||
const isAllComplete = useMemo(
|
||||
function checkAllComplete() {
|
||||
if (credentials.length === 0) return false;
|
||||
return credentials.every((cred) => selectedCredentials[cred.provider]);
|
||||
},
|
||||
[credentials, selectedCredentials],
|
||||
);
|
||||
|
||||
function handleCredentialSelect(
|
||||
provider: string,
|
||||
credential?: CredentialsMetaInput,
|
||||
) {
|
||||
if (credential) {
|
||||
setSelectedCredentials((prev) => ({
|
||||
...prev,
|
||||
[provider]: credential,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedCredentials,
|
||||
isAllComplete,
|
||||
handleCredentialSelect,
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ChatErrorStateProps {
|
||||
error: Error;
|
||||
onRetry?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatErrorState({
|
||||
error,
|
||||
onRetry,
|
||||
className,
|
||||
}: ChatErrorStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-1 items-center justify-center p-6", className)}
|
||||
>
|
||||
<ErrorCard
|
||||
responseError={{
|
||||
message: error.message,
|
||||
}}
|
||||
context="chat session"
|
||||
onRetry={onRetry}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUpIcon } from "@phosphor-icons/react";
|
||||
import { useChatInput } from "./useChatInput";
|
||||
|
||||
export interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled = false,
|
||||
placeholder = "Type your message...",
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
const inputId = "chat-input";
|
||||
const { value, setValue, handleKeyDown, handleSend } = useChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
maxRows: 5,
|
||||
inputId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex-1", className)}>
|
||||
<Input
|
||||
id={inputId}
|
||||
label="Chat message input"
|
||||
hideLabel
|
||||
type="textarea"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
wrapperClassName="mb-0 relative"
|
||||
className="pr-12"
|
||||
/>
|
||||
<span id="chat-input-hint" className="sr-only">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
className={cn(
|
||||
"absolute right-3 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full",
|
||||
"border border-zinc-800 bg-zinc-800 text-white",
|
||||
"hover:border-zinc-900 hover:bg-zinc-900",
|
||||
"disabled:border-zinc-200 disabled:bg-zinc-200 disabled:text-white disabled:opacity-50",
|
||||
"transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950",
|
||||
"disabled:pointer-events-none",
|
||||
)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<ArrowUpIcon className="h-3 w-3" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { KeyboardEvent, useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface UseChatInputArgs {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
maxRows?: number;
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export function useChatInput({
|
||||
onSend,
|
||||
disabled = false,
|
||||
maxRows = 5,
|
||||
inputId = "chat-input",
|
||||
}: UseChatInputArgs) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = "auto";
|
||||
const lineHeight = parseInt(
|
||||
window.getComputedStyle(textarea).lineHeight,
|
||||
10,
|
||||
);
|
||||
const maxHeight = lineHeight * maxRows;
|
||||
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
textarea.style.overflowY =
|
||||
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}, [value, maxRows, inputId]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (disabled || !value.trim()) return;
|
||||
onSend(value.trim());
|
||||
setValue("");
|
||||
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
}
|
||||
}, [value, onSend, disabled, inputId]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
// Shift+Enter allows default behavior (new line) - no need to handle explicitly
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
handleKeyDown,
|
||||
handleSend,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ChatLoadingStateProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatLoadingState({ className }: ChatLoadingStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-1 items-center justify-center p-6", className)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import Avatar, {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
RobotIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { getToolActionPhrase } from "../../helpers";
|
||||
import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget";
|
||||
import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
||||
import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage";
|
||||
import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage";
|
||||
import { useChatMessage, type ChatMessageData } from "./useChatMessage";
|
||||
export interface ChatMessageProps {
|
||||
message: ChatMessageData;
|
||||
className?: string;
|
||||
onDismissLogin?: () => void;
|
||||
onDismissCredentials?: () => void;
|
||||
onSendMessage?: (content: string, isUserMessage?: boolean) => void;
|
||||
agentOutput?: ChatMessageData;
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
message,
|
||||
className,
|
||||
onDismissCredentials,
|
||||
onSendMessage,
|
||||
agentOutput,
|
||||
}: ChatMessageProps) {
|
||||
const { user } = useSupabase();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const {
|
||||
isUser,
|
||||
isToolCall,
|
||||
isToolResponse,
|
||||
isLoginNeeded,
|
||||
isCredentialsNeeded,
|
||||
} = useChatMessage(message);
|
||||
|
||||
const { data: profile } = useGetV2GetUserProfile({
|
||||
query: {
|
||||
select: (res) => (res.status === 200 ? res.data : null),
|
||||
enabled: isUser && !!user,
|
||||
queryKey: ["/api/store/profile", user?.id],
|
||||
},
|
||||
});
|
||||
|
||||
const handleAllCredentialsComplete = useCallback(
|
||||
function handleAllCredentialsComplete() {
|
||||
// Send a user message that explicitly asks to retry the setup
|
||||
// This ensures the LLM calls get_required_setup_info again and proceeds with execution
|
||||
if (onSendMessage) {
|
||||
onSendMessage(
|
||||
"I've configured the required credentials. Please check if everything is ready and proceed with setting up the agent.",
|
||||
);
|
||||
}
|
||||
// Optionally dismiss the credentials prompt
|
||||
if (onDismissCredentials) {
|
||||
onDismissCredentials();
|
||||
}
|
||||
},
|
||||
[onSendMessage, onDismissCredentials],
|
||||
);
|
||||
|
||||
function handleCancelCredentials() {
|
||||
// Dismiss the credentials prompt
|
||||
if (onDismissCredentials) {
|
||||
onDismissCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (message.type !== "message") return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const handleTryAgain = useCallback(() => {
|
||||
if (message.type !== "message" || !onSendMessage) return;
|
||||
onSendMessage(message.content, message.role === "user");
|
||||
}, [message, onSendMessage]);
|
||||
|
||||
// Render credentials needed messages
|
||||
if (isCredentialsNeeded && message.type === "credentials_needed") {
|
||||
return (
|
||||
<ChatCredentialsSetup
|
||||
credentials={message.credentials}
|
||||
agentName={message.agentName}
|
||||
message={message.message}
|
||||
onAllCredentialsComplete={handleAllCredentialsComplete}
|
||||
onCancel={handleCancelCredentials}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render login needed messages
|
||||
if (isLoginNeeded && message.type === "login_needed") {
|
||||
// If user is already logged in, show success message instead of auth prompt
|
||||
if (user) {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<div className="my-4 overflow-hidden rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-emerald-50">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-600">
|
||||
<CheckCircleIcon
|
||||
size={20}
|
||||
weight="fill"
|
||||
className="text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">
|
||||
Successfully Authenticated
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
You're now signed in and ready to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show auth prompt if not logged in
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<AuthPromptWidget
|
||||
message={message.message}
|
||||
sessionId={message.sessionId}
|
||||
agentInfo={message.agentInfo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render tool call messages
|
||||
if (isToolCall && message.type === "tool_call") {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<ToolCallMessage toolName={message.toolName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render tool response messages (but skip agent_output if it's being rendered inside assistant message)
|
||||
if (
|
||||
(isToolResponse && message.type === "tool_response") ||
|
||||
message.type === "no_results" ||
|
||||
message.type === "agent_carousel" ||
|
||||
message.type === "execution_started"
|
||||
) {
|
||||
// Check if this is an agent_output that should be rendered inside assistant message
|
||||
if (message.type === "tool_response" && message.result) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof message.result === "string"
|
||||
? JSON.parse(message.result)
|
||||
: (message.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
// Skip rendering - this will be rendered inside the assistant message
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<ToolResponseMessage
|
||||
toolName={getToolActionPhrase(message.toolName)}
|
||||
result={message.type === "tool_response" ? message.result : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render regular chat messages
|
||||
if (message.type === "message") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex w-full gap-3 px-4 py-3",
|
||||
isUser ? "justify-end" : "justify-start",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
{!isUser && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col",
|
||||
isUser && "items-end",
|
||||
)}
|
||||
>
|
||||
<MessageBubble variant={isUser ? "user" : "assistant"}>
|
||||
<MarkdownContent content={message.content} />
|
||||
{agentOutput &&
|
||||
agentOutput.type === "tool_response" &&
|
||||
!isUser && (
|
||||
<div className="mt-4">
|
||||
<ToolResponseMessage
|
||||
toolName={
|
||||
agentOutput.toolName
|
||||
? getToolActionPhrase(agentOutput.toolName)
|
||||
: "Agent Output"
|
||||
}
|
||||
result={agentOutput.result}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</MessageBubble>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex gap-1",
|
||||
isUser ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{isUser && onSendMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleTryAgain}
|
||||
aria-label="Try again"
|
||||
>
|
||||
<ArrowClockwise className="size-3 text-neutral-500" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy message"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3 text-green-600" />
|
||||
) : (
|
||||
<CopyIcon className="size-3 text-neutral-500" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUser && (
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage
|
||||
src={profile?.avatar_url ?? ""}
|
||||
alt={profile?.username ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-neutral-600">
|
||||
{profile?.username?.charAt(0)?.toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown message types
|
||||
return null;
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import type { ToolArguments, ToolResult } from "@/types/chat";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
export type ChatMessageData =
|
||||
| {
|
||||
type: "message";
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "tool_call";
|
||||
toolId: string;
|
||||
toolName: string;
|
||||
arguments?: ToolArguments;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "tool_response";
|
||||
toolId: string;
|
||||
toolName: string;
|
||||
result: ToolResult;
|
||||
success?: boolean;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "login_needed";
|
||||
toolName: string;
|
||||
message: string;
|
||||
sessionId: string;
|
||||
agentInfo?: {
|
||||
graph_id: string;
|
||||
name: string;
|
||||
trigger_type: string;
|
||||
};
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "credentials_needed";
|
||||
toolName: string;
|
||||
credentials: Array<{
|
||||
provider: string;
|
||||
providerName: string;
|
||||
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
|
||||
title: string;
|
||||
scopes?: string[];
|
||||
}>;
|
||||
message: string;
|
||||
agentName?: string;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "no_results";
|
||||
toolName: string;
|
||||
message: string;
|
||||
suggestions?: string[];
|
||||
sessionId?: string;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "agent_carousel";
|
||||
toolName: string;
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version?: number;
|
||||
image_url?: string;
|
||||
}>;
|
||||
totalCount?: number;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "execution_started";
|
||||
toolName: string;
|
||||
executionId: string;
|
||||
agentName?: string;
|
||||
message?: string;
|
||||
libraryAgentLink?: string;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "inputs_needed";
|
||||
toolName: string;
|
||||
agentName?: string;
|
||||
agentId?: string;
|
||||
graphVersion?: number;
|
||||
inputSchema: Record<string, any>;
|
||||
credentialsSchema?: Record<string, any>;
|
||||
message: string;
|
||||
timestamp?: string | Date;
|
||||
};
|
||||
|
||||
export function useChatMessage(message: ChatMessageData) {
|
||||
const formattedTimestamp = message.timestamp
|
||||
? formatDistanceToNow(new Date(message.timestamp), { addSuffix: true })
|
||||
: "Just now";
|
||||
|
||||
return {
|
||||
formattedTimestamp,
|
||||
isUser: message.type === "message" && message.role === "user",
|
||||
isAssistant: message.type === "message" && message.role === "assistant",
|
||||
isSystem: message.type === "message" && message.role === "system",
|
||||
isToolCall: message.type === "tool_call",
|
||||
isToolResponse: message.type === "tool_response",
|
||||
isLoginNeeded: message.type === "login_needed",
|
||||
isCredentialsNeeded: message.type === "credentials_needed",
|
||||
isNoResults: message.type === "no_results",
|
||||
isAgentCarousel: message.type === "agent_carousel",
|
||||
isExecutionStarted: message.type === "execution_started",
|
||||
isInputsNeeded: message.type === "inputs_needed",
|
||||
};
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowSquareOut, CheckCircle, Play } from "@phosphor-icons/react";
|
||||
|
||||
export interface ExecutionStartedMessageProps {
|
||||
executionId: string;
|
||||
agentName?: string;
|
||||
message?: string;
|
||||
onViewExecution?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExecutionStartedMessage({
|
||||
executionId,
|
||||
agentName,
|
||||
message = "Agent execution started successfully",
|
||||
onViewExecution,
|
||||
className,
|
||||
}: ExecutionStartedMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 my-2 flex flex-col gap-4 rounded-lg border border-green-200 bg-green-50 p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Icon & Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-green-500">
|
||||
<CheckCircle size={24} weight="bold" className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Text variant="h3" className="mb-1 text-green-900">
|
||||
Execution Started
|
||||
</Text>
|
||||
<Text variant="body" className="text-green-700">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="rounded-md bg-green-100 p-4">
|
||||
<div className="space-y-2">
|
||||
{agentName && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="small" className="font-semibold text-green-900">
|
||||
Agent:
|
||||
</Text>
|
||||
<Text variant="body" className="text-green-800">
|
||||
{agentName}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="small" className="font-semibold text-green-900">
|
||||
Execution ID:
|
||||
</Text>
|
||||
<Text variant="small" className="font-mono text-green-800">
|
||||
{executionId.slice(0, 16)}...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{onViewExecution && (
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={onViewExecution}
|
||||
variant="primary"
|
||||
className="flex flex-1 items-center justify-center gap-2"
|
||||
>
|
||||
<ArrowSquareOut size={20} weight="bold" />
|
||||
View Execution
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<Play size={16} weight="fill" />
|
||||
<Text variant="small">
|
||||
Your agent is now running. You can monitor its progress in the monitor
|
||||
page.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CodeProps extends React.HTMLAttributes<HTMLElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ListProps extends React.HTMLAttributes<HTMLUListElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={cn("markdown-content", className)}>
|
||||
<ReactMarkdown
|
||||
skipHtml={true}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code: ({ children, className, ...props }: CodeProps) => {
|
||||
const isInline = !className?.includes("language-");
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-sm text-zinc-800"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="font-mono text-sm text-zinc-100" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
className="my-2 overflow-x-auto rounded-md bg-zinc-900 p-3"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-600 underline decoration-1 underline-offset-2 hover:text-purple-700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className="font-semibold" {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children, ...props }) => (
|
||||
<em className="italic" {...props}>
|
||||
{children}
|
||||
</em>
|
||||
),
|
||||
del: ({ children, ...props }) => (
|
||||
<del className="line-through opacity-70" {...props}>
|
||||
{children}
|
||||
</del>
|
||||
),
|
||||
ul: ({ children, ...props }: ListProps) => (
|
||||
<ul
|
||||
className={cn(
|
||||
"my-2 space-y-1 pl-6",
|
||||
props.className?.includes("contains-task-list")
|
||||
? "list-none pl-0"
|
||||
: "list-disc",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol className="my-2 list-decimal space-y-1 pl-6" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children, ...props }: ListItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
props.className?.includes("task-list-item")
|
||||
? "flex items-start"
|
||||
: "",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
input: ({ ...props }: InputProps) => {
|
||||
if (props.type === "checkbox") {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2 h-4 w-4 rounded border-zinc-300 text-purple-600 focus:ring-purple-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
disabled
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <input {...props} />;
|
||||
},
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote
|
||||
className="my-2 border-l-4 border-zinc-300 pl-3 italic text-zinc-700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1 className="my-2 text-xl font-bold text-zinc-900" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2 className="my-2 text-lg font-semibold text-zinc-800" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3
|
||||
className="my-1 text-base font-semibold text-zinc-800"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4 className="my-1 text-sm font-medium text-zinc-700" {...props}>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children, ...props }) => (
|
||||
<h5 className="my-1 text-sm font-medium text-zinc-700" {...props}>
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children, ...props }) => (
|
||||
<h6 className="my-1 text-xs font-medium text-zinc-600" {...props}>
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
p: ({ children, ...props }) => (
|
||||
<p className="my-2 leading-relaxed" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
hr: ({ ...props }) => (
|
||||
<hr className="my-3 border-zinc-300" {...props} />
|
||||
),
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="my-2 overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full divide-y divide-zinc-200 rounded border border-zinc-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="bg-zinc-50 px-3 py-2 text-left text-xs font-semibold text-zinc-700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td
|
||||
className="border-t border-zinc-200 px-3 py-2 text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
children: ReactNode;
|
||||
variant: "user" | "assistant";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
children,
|
||||
variant,
|
||||
className,
|
||||
}: MessageBubbleProps) {
|
||||
const userTheme = {
|
||||
bg: "bg-slate-900",
|
||||
border: "border-slate-800",
|
||||
gradient: "from-slate-900/30 via-slate-800/20 to-transparent",
|
||||
text: "text-slate-50",
|
||||
};
|
||||
|
||||
const assistantTheme = {
|
||||
bg: "bg-slate-50/20",
|
||||
border: "border-slate-100",
|
||||
gradient: "from-slate-200/20 via-slate-300/10 to-transparent",
|
||||
text: "text-slate-900",
|
||||
};
|
||||
|
||||
const theme = variant === "user" ? userTheme : assistantTheme;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative min-w-20 overflow-hidden rounded-xl border px-6 py-2.5 text-sm leading-relaxed backdrop-blur-xl transition-all duration-500 ease-in-out",
|
||||
theme.bg,
|
||||
theme.border,
|
||||
variant === "user" && "text-right",
|
||||
variant === "assistant" && "text-left",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Gradient flare background */}
|
||||
<div
|
||||
className={cn("absolute inset-0 bg-gradient-to-br", theme.gradient)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 transition-all duration-500 ease-in-out",
|
||||
theme.text,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatMessage } from "../ChatMessage/ChatMessage";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
|
||||
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
|
||||
import { useMessageList } from "./useMessageList";
|
||||
|
||||
export interface MessageListProps {
|
||||
messages: ChatMessageData[];
|
||||
streamingChunks?: string[];
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
onStreamComplete?: () => void;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
streamingChunks = [],
|
||||
isStreaming = false,
|
||||
className,
|
||||
onStreamComplete,
|
||||
onSendMessage,
|
||||
}: MessageListProps) {
|
||||
const { messagesEndRef, messagesContainerRef } = useMessageList({
|
||||
messageCount: messages.length,
|
||||
isStreaming,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto",
|
||||
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-col py-4">
|
||||
{/* Render all persisted messages */}
|
||||
{messages.map((message, index) => {
|
||||
// Check if current message is an agent_output tool_response
|
||||
// and if previous message is an assistant message
|
||||
let agentOutput: ChatMessageData | undefined;
|
||||
|
||||
if (message.type === "tool_response" && message.result) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof message.result === "string"
|
||||
? JSON.parse(message.result)
|
||||
: (message.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
const prevMessage = messages[index - 1];
|
||||
if (
|
||||
prevMessage &&
|
||||
prevMessage.type === "message" &&
|
||||
prevMessage.role === "assistant"
|
||||
) {
|
||||
// This agent output will be rendered inside the previous assistant message
|
||||
// Skip rendering this message separately
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if next message is an agent_output tool_response to include in current assistant message
|
||||
if (message.type === "message" && message.role === "assistant") {
|
||||
const nextMessage = messages[index + 1];
|
||||
if (
|
||||
nextMessage &&
|
||||
nextMessage.type === "tool_response" &&
|
||||
nextMessage.result
|
||||
) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof nextMessage.result === "string"
|
||||
? JSON.parse(nextMessage.result)
|
||||
: (nextMessage.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
agentOutput = nextMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
message={message}
|
||||
onSendMessage={onSendMessage}
|
||||
agentOutput={agentOutput}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render thinking message when streaming but no chunks yet */}
|
||||
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
|
||||
|
||||
{/* Render streaming message if active */}
|
||||
{isStreaming && streamingChunks.length > 0 && (
|
||||
<StreamingMessage
|
||||
chunks={streamingChunks}
|
||||
onComplete={onStreamComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invisible div to scroll to */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface UseMessageListArgs {
|
||||
messageCount: number;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function useMessageList({
|
||||
messageCount,
|
||||
isStreaming,
|
||||
}: UseMessageListArgs) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messageCount, isStreaming, scrollToBottom]);
|
||||
|
||||
return {
|
||||
messagesEndRef,
|
||||
messagesContainerRef,
|
||||
scrollToBottom,
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MagnifyingGlass, X } from "@phosphor-icons/react";
|
||||
|
||||
export interface NoResultsMessageProps {
|
||||
message: string;
|
||||
suggestions?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NoResultsMessage({
|
||||
message,
|
||||
suggestions = [],
|
||||
className,
|
||||
}: NoResultsMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 my-2 flex flex-col items-center gap-4 rounded-lg border border-gray-200 bg-gray-50 p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
|
||||
<MagnifyingGlass size={32} weight="bold" className="text-gray-500" />
|
||||
</div>
|
||||
<div className="absolute -right-1 -top-1 flex h-8 w-8 items-center justify-center rounded-full bg-gray-400">
|
||||
<X size={20} weight="bold" className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="text-center">
|
||||
<Text variant="h3" className="mb-2 text-gray-900">
|
||||
No Results Found
|
||||
</Text>
|
||||
<Text variant="body" className="text-gray-700">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="w-full space-y-2">
|
||||
<Text variant="small" className="font-semibold text-gray-900">
|
||||
Try these suggestions:
|
||||
</Text>
|
||||
<ul className="space-y-1 rounded-md bg-gray-100 p-4">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-start gap-2 text-sm text-gray-700"
|
||||
>
|
||||
<span className="mt-1 text-gray-500">•</span>
|
||||
<span>{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface QuickActionsWelcomeProps {
|
||||
title: string;
|
||||
description: string;
|
||||
actions: string[];
|
||||
onActionClick: (action: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuickActionsWelcome({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
onActionClick,
|
||||
disabled = false,
|
||||
className,
|
||||
}: QuickActionsWelcomeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-1 items-center justify-center p-8", className)}
|
||||
>
|
||||
<div className="w-full max-w-3xl">
|
||||
<div className="mb-12 text-center">
|
||||
<Text
|
||||
variant="h2"
|
||||
className="mb-3 text-2xl font-semibold text-zinc-900"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{actions.map((action) => {
|
||||
// Use slate theme for all cards
|
||||
const theme = {
|
||||
bg: "bg-slate-50/10",
|
||||
border: "border-slate-100",
|
||||
hoverBg: "hover:bg-slate-50/20",
|
||||
hoverBorder: "hover:border-slate-200",
|
||||
gradient: "from-slate-200/20 via-slate-300/10 to-transparent",
|
||||
text: "text-slate-900",
|
||||
hoverText: "group-hover:text-slate-900",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action}
|
||||
onClick={() => onActionClick(action)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-xl border p-5 text-left backdrop-blur-xl",
|
||||
"transition-all duration-200",
|
||||
theme.bg,
|
||||
theme.border,
|
||||
theme.hoverBg,
|
||||
theme.hoverBorder,
|
||||
"hover:shadow-sm",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none",
|
||||
)}
|
||||
>
|
||||
{/* Gradient flare background */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gradient-to-br",
|
||||
theme.gradient,
|
||||
)}
|
||||
/>
|
||||
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn(
|
||||
"relative z-10 font-medium",
|
||||
theme.text,
|
||||
theme.hoverText,
|
||||
)}
|
||||
>
|
||||
{action}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Drawer } from "vaul";
|
||||
|
||||
interface SessionsDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
currentSessionId?: string | null;
|
||||
}
|
||||
|
||||
export function SessionsDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectSession,
|
||||
currentSessionId,
|
||||
}: SessionsDrawerProps) {
|
||||
const { data, isLoading } = useGetV2ListSessions(
|
||||
{ limit: 100 },
|
||||
{
|
||||
query: {
|
||||
enabled: isOpen,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sessions =
|
||||
data?.status === 200
|
||||
? data.data.sessions.filter((session) => {
|
||||
// Filter out sessions without messages (sessions that were never updated)
|
||||
// If updated_at equals created_at, the session was created but never had messages
|
||||
return session.updated_at !== session.created_at;
|
||||
})
|
||||
: [];
|
||||
|
||||
function handleSelectSession(sessionId: string) {
|
||||
onSelectSession(sessionId);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
direction="right"
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
|
||||
<Drawer.Content
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-[70] flex h-full w-96 flex-col border-l border-zinc-200 bg-white",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Drawer.Title className="text-lg font-semibold">
|
||||
Chat Sessions
|
||||
</Drawer.Title>
|
||||
<button
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
className="flex size-8 items-center justify-center rounded hover:bg-zinc-100"
|
||||
>
|
||||
<X width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading sessions...
|
||||
</Text>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
No sessions found
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => {
|
||||
const isActive = session.id === currentSessionId;
|
||||
const updatedAt = session.updated_at
|
||||
? formatDistanceToNow(new Date(session.updated_at), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-3 text-left transition-colors",
|
||||
isActive
|
||||
? "border-indigo-500 bg-zinc-50"
|
||||
: "border-zinc-200 bg-zinc-100/50 hover:border-zinc-300 hover:bg-zinc-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn(
|
||||
"font-medium",
|
||||
isActive ? "text-indigo-900" : "text-zinc-900",
|
||||
)}
|
||||
>
|
||||
{session.title || "Untitled Chat"}
|
||||
</Text>
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span>{session.id.slice(0, 8)}...</span>
|
||||
{updatedAt && <span>•</span>}
|
||||
<span>{updatedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RobotIcon } from "@phosphor-icons/react";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
||||
import { useStreamingMessage } from "./useStreamingMessage";
|
||||
|
||||
export interface StreamingMessageProps {
|
||||
chunks: string[];
|
||||
className?: string;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function StreamingMessage({
|
||||
chunks,
|
||||
className,
|
||||
onComplete,
|
||||
}: StreamingMessageProps) {
|
||||
const { displayText } = useStreamingMessage({ chunks, onComplete });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex w-full justify-start gap-3 px-4 py-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-600">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<MessageBubble variant="assistant">
|
||||
<MarkdownContent content={displayText} />
|
||||
</MessageBubble>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface UseStreamingMessageArgs {
|
||||
chunks: string[];
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function useStreamingMessage({
|
||||
chunks,
|
||||
onComplete,
|
||||
}: UseStreamingMessageArgs) {
|
||||
const [isComplete, _setIsComplete] = useState(false);
|
||||
const displayText = chunks.join("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}, [isComplete, onComplete]);
|
||||
|
||||
return {
|
||||
displayText,
|
||||
isComplete,
|
||||
};
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RobotIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
||||
|
||||
export interface ThinkingMessageProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
const [showSlowLoader, setShowSlowLoader] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current === null) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowSlowLoader(true);
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex w-full justify-start gap-3 px-4 py-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<MessageBubble variant="assistant">
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
{showSlowLoader ? (
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
<div className="loader" style={{ flexShrink: 0 }} />
|
||||
<p className="text-sm text-slate-700">
|
||||
Taking a bit longer to think, wait a moment please
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="inline-block bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-clip-text text-transparent"
|
||||
style={{
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer 2s ease-in-out infinite",
|
||||
}}
|
||||
>
|
||||
Thinking...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</MessageBubble>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WrenchIcon } from "@phosphor-icons/react";
|
||||
import { getToolActionPhrase } from "../../helpers";
|
||||
|
||||
export interface ToolCallMessageProps {
|
||||
toolName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolCallMessage({ toolName, className }: ToolCallMessageProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolResult } from "@/types/chat";
|
||||
import { WrenchIcon } from "@phosphor-icons/react";
|
||||
import { getToolActionPhrase } from "../../helpers";
|
||||
|
||||
export interface ToolResponseMessageProps {
|
||||
toolName: string;
|
||||
result?: ToolResult;
|
||||
success?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolResponseMessage({
|
||||
toolName,
|
||||
result,
|
||||
success: _success = true,
|
||||
className,
|
||||
}: ToolResponseMessageProps) {
|
||||
if (!result) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof result === "string"
|
||||
? JSON.parse(result)
|
||||
: (result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
|
||||
if (parsedResult && typeof parsedResult === "object") {
|
||||
const responseType = parsedResult.type as string | undefined;
|
||||
|
||||
if (responseType === "agent_output") {
|
||||
const execution = parsedResult.execution as
|
||||
| {
|
||||
outputs?: Record<string, unknown[]>;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const outputs = execution?.outputs || {};
|
||||
const message = parsedResult.message as string | undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4 px-4 py-2", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}
|
||||
</Text>
|
||||
</div>
|
||||
{message && (
|
||||
<div className="rounded border p-4">
|
||||
<Text variant="small" className="text-neutral-600">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(outputs).length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(outputs).map(([outputName, values]) =>
|
||||
values.map((value, index) => {
|
||||
const renderer = globalRegistry.getRenderer(value);
|
||||
if (renderer) {
|
||||
return (
|
||||
<OutputItem
|
||||
key={`${outputName}-${index}`}
|
||||
value={value}
|
||||
renderer={renderer}
|
||||
label={outputName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${outputName}-${index}`}
|
||||
className="rounded border p-4"
|
||||
>
|
||||
<Text variant="large-medium" className="mb-2 capitalize">
|
||||
{outputName}
|
||||
</Text>
|
||||
<pre className="overflow-auto text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (responseType === "block_output" && parsedResult.outputs) {
|
||||
const outputs = parsedResult.outputs as Record<string, unknown[]>;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4 px-4 py-2", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(outputs).map(([outputName, values]) =>
|
||||
values.map((value, index) => {
|
||||
const renderer = globalRegistry.getRenderer(value);
|
||||
if (renderer) {
|
||||
return (
|
||||
<OutputItem
|
||||
key={`${outputName}-${index}`}
|
||||
value={value}
|
||||
renderer={renderer}
|
||||
label={outputName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${outputName}-${index}`}
|
||||
className="rounded border p-4"
|
||||
>
|
||||
<Text variant="large-medium" className="mb-2 capitalize">
|
||||
{outputName}
|
||||
</Text>
|
||||
<pre className="overflow-auto text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle other response types with a message field (e.g., understanding_updated)
|
||||
if (parsedResult.message && typeof parsedResult.message === "string") {
|
||||
// Format tool name from snake_case to Title Case
|
||||
const formattedToolName = toolName
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
|
||||
// Clean up message - remove incomplete user_name references
|
||||
let cleanedMessage = parsedResult.message;
|
||||
// Remove "Updated understanding with: user_name" pattern if user_name is just a placeholder
|
||||
cleanedMessage = cleanedMessage.replace(
|
||||
/Updated understanding with:\s*user_name\.?\s*/gi,
|
||||
"",
|
||||
);
|
||||
// Remove standalone user_name references
|
||||
cleanedMessage = cleanedMessage.replace(/\buser_name\b\.?\s*/gi, "");
|
||||
cleanedMessage = cleanedMessage.trim();
|
||||
|
||||
// Only show message if it has content after cleaning
|
||||
if (!cleanedMessage) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{formattedToolName}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 px-4 py-2", className)}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{formattedToolName}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="rounded border p-4">
|
||||
<Text variant="small" className="text-neutral-600">
|
||||
{cleanedMessage}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renderer = globalRegistry.getRenderer(result);
|
||||
if (renderer) {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}
|
||||
</Text>
|
||||
</div>
|
||||
<OutputItem value={result} renderer={renderer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Maps internal tool names to user-friendly display names with emojis.
|
||||
* @deprecated Use getToolActionPhrase or getToolCompletionPhrase for status messages
|
||||
*
|
||||
* @param toolName - The internal tool name from the backend
|
||||
* @returns A user-friendly display name with an emoji prefix
|
||||
*/
|
||||
export function getToolDisplayName(toolName: string): string {
|
||||
const toolDisplayNames: Record<string, string> = {
|
||||
find_agent: "🔍 Search Marketplace",
|
||||
get_agent_details: "📋 Get Agent Details",
|
||||
check_credentials: "🔑 Check Credentials",
|
||||
setup_agent: "⚙️ Setup Agent",
|
||||
run_agent: "▶️ Run Agent",
|
||||
get_required_setup_info: "📝 Get Setup Requirements",
|
||||
};
|
||||
return toolDisplayNames[toolName] || toolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps internal tool names to human-friendly action phrases (present continuous).
|
||||
* Used for tool call messages to indicate what action is currently happening.
|
||||
*
|
||||
* @param toolName - The internal tool name from the backend
|
||||
* @returns A human-friendly action phrase in present continuous tense
|
||||
*/
|
||||
export function getToolActionPhrase(toolName: string): string {
|
||||
const toolActionPhrases: Record<string, string> = {
|
||||
find_agent: "Looking for agents in the marketplace",
|
||||
agent_carousel: "Looking for agents in the marketplace",
|
||||
get_agent_details: "Learning about the agent",
|
||||
check_credentials: "Checking your credentials",
|
||||
setup_agent: "Setting up the agent",
|
||||
execution_started: "Running the agent",
|
||||
run_agent: "Running the agent",
|
||||
get_required_setup_info: "Getting setup requirements",
|
||||
schedule_agent: "Scheduling the agent to run",
|
||||
};
|
||||
|
||||
// Return mapped phrase or generate human-friendly fallback
|
||||
return toolActionPhrases[toolName] || toolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps internal tool names to human-friendly completion phrases (past tense).
|
||||
* Used for tool response messages to indicate what action was completed.
|
||||
*
|
||||
* @param toolName - The internal tool name from the backend
|
||||
* @returns A human-friendly completion phrase in past tense
|
||||
*/
|
||||
export function getToolCompletionPhrase(toolName: string): string {
|
||||
const toolCompletionPhrases: Record<string, string> = {
|
||||
find_agent: "Finished searching the marketplace",
|
||||
get_agent_details: "Got agent details",
|
||||
check_credentials: "Checked credentials",
|
||||
setup_agent: "Agent setup complete",
|
||||
run_agent: "Agent execution started",
|
||||
get_required_setup_info: "Got setup requirements",
|
||||
};
|
||||
|
||||
// Return mapped phrase or generate human-friendly fallback
|
||||
return (
|
||||
toolCompletionPhrases[toolName] ||
|
||||
`Finished ${toolName.replace(/_/g, " ").replace("...", "")}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate UUID v4 format */
|
||||
export function isValidUUID(value: string): boolean {
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(value);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useChatSession } from "./useChatSession";
|
||||
import { useChatStream } from "./useChatStream";
|
||||
|
||||
export function useChat() {
|
||||
const hasCreatedSessionRef = useRef(false);
|
||||
const hasClaimedSessionRef = useRef(false);
|
||||
const { user } = useSupabase();
|
||||
const { sendMessage: sendStreamMessage } = useChatStream();
|
||||
|
||||
const {
|
||||
session,
|
||||
sessionId: sessionIdFromHook,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
createSession,
|
||||
refreshSession,
|
||||
claimSession,
|
||||
clearSession: clearSessionBase,
|
||||
loadSession,
|
||||
} = useChatSession({
|
||||
urlSessionId: null,
|
||||
autoCreate: false,
|
||||
});
|
||||
|
||||
useEffect(
|
||||
function autoCreateSession() {
|
||||
if (!hasCreatedSessionRef.current && !isCreating && !sessionIdFromHook) {
|
||||
hasCreatedSessionRef.current = true;
|
||||
createSession().catch((_err) => {
|
||||
hasCreatedSessionRef.current = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
[isCreating, sessionIdFromHook, createSession],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function autoClaimSession() {
|
||||
if (
|
||||
session &&
|
||||
!session.user_id &&
|
||||
user &&
|
||||
!hasClaimedSessionRef.current &&
|
||||
!isLoading &&
|
||||
sessionIdFromHook
|
||||
) {
|
||||
hasClaimedSessionRef.current = true;
|
||||
claimSession(sessionIdFromHook)
|
||||
.then(() => {
|
||||
sendStreamMessage(
|
||||
sessionIdFromHook,
|
||||
"User has successfully logged in.",
|
||||
() => {},
|
||||
false,
|
||||
).catch(() => {});
|
||||
})
|
||||
.catch(() => {
|
||||
hasClaimedSessionRef.current = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
session,
|
||||
user,
|
||||
isLoading,
|
||||
sessionIdFromHook,
|
||||
claimSession,
|
||||
sendStreamMessage,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(function monitorNetworkStatus() {
|
||||
function handleOnline() {
|
||||
toast.success("Connection restored", {
|
||||
description: "You're back online",
|
||||
});
|
||||
}
|
||||
|
||||
function handleOffline() {
|
||||
toast.error("You're offline", {
|
||||
description: "Check your internet connection",
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function clearSession() {
|
||||
clearSessionBase();
|
||||
hasCreatedSessionRef.current = false;
|
||||
hasClaimedSessionRef.current = false;
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
createSession,
|
||||
refreshSession,
|
||||
clearSession,
|
||||
loadSession,
|
||||
sessionId: sessionIdFromHook,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ChatDrawerState {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export const useChatDrawer = create<ChatDrawerState>((set) => ({
|
||||
isOpen: false,
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
}));
|
||||
@@ -1,271 +0,0 @@
|
||||
import {
|
||||
getGetV2GetSessionQueryKey,
|
||||
getGetV2GetSessionQueryOptions,
|
||||
postV2CreateSession,
|
||||
useGetV2GetSession,
|
||||
usePatchV2SessionAssignUser,
|
||||
usePostV2CreateSession,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { isValidUUID } from "./helpers";
|
||||
|
||||
interface UseChatSessionArgs {
|
||||
urlSessionId?: string | null;
|
||||
autoCreate?: boolean;
|
||||
}
|
||||
|
||||
export function useChatSession({
|
||||
urlSessionId,
|
||||
autoCreate = false,
|
||||
}: UseChatSessionArgs = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const justCreatedSessionIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlSessionId) {
|
||||
if (!isValidUUID(urlSessionId)) {
|
||||
console.error("Invalid session ID format:", urlSessionId);
|
||||
toast.error("Invalid session ID", {
|
||||
description:
|
||||
"The session ID in the URL is not valid. Starting a new session...",
|
||||
});
|
||||
setSessionId(null);
|
||||
storage.clean(Key.CHAT_SESSION_ID);
|
||||
return;
|
||||
}
|
||||
setSessionId(urlSessionId);
|
||||
storage.set(Key.CHAT_SESSION_ID, urlSessionId);
|
||||
} else {
|
||||
const storedSessionId = storage.get(Key.CHAT_SESSION_ID);
|
||||
if (storedSessionId) {
|
||||
if (!isValidUUID(storedSessionId)) {
|
||||
console.error("Invalid stored session ID:", storedSessionId);
|
||||
storage.clean(Key.CHAT_SESSION_ID);
|
||||
setSessionId(null);
|
||||
} else {
|
||||
setSessionId(storedSessionId);
|
||||
}
|
||||
} else if (autoCreate) {
|
||||
setSessionId(null);
|
||||
}
|
||||
}
|
||||
}, [urlSessionId, autoCreate]);
|
||||
|
||||
const {
|
||||
mutateAsync: createSessionMutation,
|
||||
isPending: isCreating,
|
||||
error: createError,
|
||||
} = usePostV2CreateSession();
|
||||
|
||||
const {
|
||||
data: sessionData,
|
||||
isLoading: isLoadingSession,
|
||||
error: loadError,
|
||||
refetch,
|
||||
} = useGetV2GetSession(sessionId || "", {
|
||||
query: {
|
||||
enabled: !!sessionId,
|
||||
select: okData,
|
||||
staleTime: Infinity, // Never mark as stale
|
||||
refetchOnMount: false, // Don't refetch on component mount
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
refetchOnReconnect: false, // Don't refetch when network reconnects
|
||||
retry: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: claimSessionMutation } = usePatchV2SessionAssignUser();
|
||||
|
||||
const session = useMemo(() => {
|
||||
if (sessionData) return sessionData;
|
||||
|
||||
if (sessionId && justCreatedSessionIdRef.current === sessionId) {
|
||||
return {
|
||||
id: sessionId,
|
||||
user_id: null,
|
||||
messages: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as SessionDetailResponse;
|
||||
}
|
||||
return null;
|
||||
}, [sessionData, sessionId]);
|
||||
|
||||
const messages = session?.messages || [];
|
||||
const isLoading = isCreating || isLoadingSession;
|
||||
|
||||
useEffect(() => {
|
||||
if (createError) {
|
||||
setError(
|
||||
createError instanceof Error
|
||||
? createError
|
||||
: new Error("Failed to create session"),
|
||||
);
|
||||
} else if (loadError) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError
|
||||
: new Error("Failed to load session"),
|
||||
);
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
}, [createError, loadError]);
|
||||
|
||||
const createSession = useCallback(
|
||||
async function createSession() {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await postV2CreateSession({
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to create session");
|
||||
}
|
||||
const newSessionId = response.data.id;
|
||||
setSessionId(newSessionId);
|
||||
storage.set(Key.CHAT_SESSION_ID, newSessionId);
|
||||
justCreatedSessionIdRef.current = newSessionId;
|
||||
setTimeout(() => {
|
||||
if (justCreatedSessionIdRef.current === newSessionId) {
|
||||
justCreatedSessionIdRef.current = null;
|
||||
}
|
||||
}, 10000);
|
||||
return newSessionId;
|
||||
} catch (err) {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error("Failed to create session");
|
||||
setError(error);
|
||||
toast.error("Failed to create chat session", {
|
||||
description: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[createSessionMutation],
|
||||
);
|
||||
|
||||
const loadSession = useCallback(
|
||||
async function loadSession(id: string) {
|
||||
try {
|
||||
setError(null);
|
||||
// Invalidate the query cache for this session to force a fresh fetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(id),
|
||||
});
|
||||
// Set sessionId after invalidation to ensure the hook refetches
|
||||
setSessionId(id);
|
||||
storage.set(Key.CHAT_SESSION_ID, id);
|
||||
// Force fetch with fresh data (bypass cache)
|
||||
const queryOptions = getGetV2GetSessionQueryOptions(id, {
|
||||
query: {
|
||||
staleTime: 0, // Force fresh fetch
|
||||
retry: 1,
|
||||
},
|
||||
});
|
||||
const result = await queryClient.fetchQuery(queryOptions);
|
||||
if (!result || ("status" in result && result.status !== 200)) {
|
||||
console.warn("Session not found on server, clearing local state");
|
||||
storage.clean(Key.CHAT_SESSION_ID);
|
||||
setSessionId(null);
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
} catch (err) {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error("Failed to load session");
|
||||
setError(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const refreshSession = useCallback(
|
||||
async function refreshSession() {
|
||||
if (!sessionId) {
|
||||
console.log("[refreshSession] Skipping - no session ID");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(null);
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error("Failed to refresh session");
|
||||
setError(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[sessionId, refetch],
|
||||
);
|
||||
|
||||
const claimSession = useCallback(
|
||||
async function claimSession(id: string) {
|
||||
try {
|
||||
setError(null);
|
||||
await claimSessionMutation({ sessionId: id });
|
||||
if (justCreatedSessionIdRef.current === id) {
|
||||
justCreatedSessionIdRef.current = null;
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(id),
|
||||
});
|
||||
await refetch();
|
||||
toast.success("Session claimed successfully", {
|
||||
description: "Your chat history has been saved to your account",
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error("Failed to claim session");
|
||||
const is404 =
|
||||
(typeof err === "object" &&
|
||||
err !== null &&
|
||||
"status" in err &&
|
||||
err.status === 404) ||
|
||||
(typeof err === "object" &&
|
||||
err !== null &&
|
||||
"response" in err &&
|
||||
typeof err.response === "object" &&
|
||||
err.response !== null &&
|
||||
"status" in err.response &&
|
||||
err.response.status === 404);
|
||||
if (!is404) {
|
||||
setError(error);
|
||||
toast.error("Failed to claim session", {
|
||||
description: error.message || "Unable to claim session",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[claimSessionMutation, queryClient, refetch],
|
||||
);
|
||||
|
||||
const clearSession = useCallback(function clearSession() {
|
||||
setSessionId(null);
|
||||
setError(null);
|
||||
storage.clean(Key.CHAT_SESSION_ID);
|
||||
justCreatedSessionIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionId,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
createSession,
|
||||
loadSession,
|
||||
refreshSession,
|
||||
claimSession,
|
||||
clearSession,
|
||||
};
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ToolArguments, ToolResult } from "@/types/chat";
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const INITIAL_RETRY_DELAY = 1000;
|
||||
|
||||
export interface StreamChunk {
|
||||
type:
|
||||
| "text_chunk"
|
||||
| "text_ended"
|
||||
| "tool_call"
|
||||
| "tool_call_start"
|
||||
| "tool_response"
|
||||
| "login_needed"
|
||||
| "need_login"
|
||||
| "credentials_needed"
|
||||
| "error"
|
||||
| "usage"
|
||||
| "stream_end";
|
||||
timestamp?: string;
|
||||
content?: string;
|
||||
message?: string;
|
||||
tool_id?: string;
|
||||
tool_name?: string;
|
||||
arguments?: ToolArguments;
|
||||
result?: ToolResult;
|
||||
success?: boolean;
|
||||
idx?: number;
|
||||
session_id?: string;
|
||||
agent_info?: {
|
||||
graph_id: string;
|
||||
name: string;
|
||||
trigger_type: string;
|
||||
};
|
||||
provider?: string;
|
||||
provider_name?: string;
|
||||
credential_type?: string;
|
||||
scopes?: string[];
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function useChatStream() {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const retryCountRef = useRef<number>(0);
|
||||
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
retryCountRef.current = 0;
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopStreaming();
|
||||
};
|
||||
}, [stopStreaming]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
onChunk: (chunk: StreamChunk) => void,
|
||||
isUserMessage: boolean = true,
|
||||
context?: { url: string; content: string },
|
||||
) => {
|
||||
stopStreaming();
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return Promise.reject(new Error("Request aborted"));
|
||||
}
|
||||
|
||||
retryCountRef.current = 0;
|
||||
setIsStreaming(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = `/api/chat/sessions/${sessionId}/stream`;
|
||||
const body = JSON.stringify({
|
||||
message,
|
||||
is_user_message: isUserMessage,
|
||||
context: context || null,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
reader.cancel().catch(() => {
|
||||
// Ignore cancel errors
|
||||
});
|
||||
};
|
||||
|
||||
const readStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
cleanup();
|
||||
stopStreaming();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") {
|
||||
cleanup();
|
||||
stopStreaming();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(data) as StreamChunk;
|
||||
|
||||
if (retryCountRef.current > 0) {
|
||||
retryCountRef.current = 0;
|
||||
}
|
||||
|
||||
// Call the chunk handler
|
||||
onChunk(chunk);
|
||||
|
||||
// Handle stream lifecycle
|
||||
if (chunk.type === "stream_end") {
|
||||
cleanup();
|
||||
stopStreaming();
|
||||
resolve();
|
||||
return;
|
||||
} else if (chunk.type === "error") {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
chunk.message || chunk.content || "Stream error",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Skip invalid JSON lines
|
||||
console.warn("Failed to parse SSE chunk:", err, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const streamError =
|
||||
err instanceof Error ? err : new Error("Failed to read stream");
|
||||
|
||||
if (retryCountRef.current < MAX_RETRIES) {
|
||||
retryCountRef.current += 1;
|
||||
const retryDelay =
|
||||
INITIAL_RETRY_DELAY * Math.pow(2, retryCountRef.current - 1);
|
||||
|
||||
toast.info("Connection interrupted", {
|
||||
description: `Retrying in ${retryDelay / 1000} seconds...`,
|
||||
});
|
||||
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
sendMessage(
|
||||
sessionId,
|
||||
message,
|
||||
onChunk,
|
||||
isUserMessage,
|
||||
context,
|
||||
).catch((_err) => {
|
||||
// Retry failed
|
||||
});
|
||||
}, retryDelay);
|
||||
} else {
|
||||
setError(streamError);
|
||||
toast.error("Connection Failed", {
|
||||
description:
|
||||
"Unable to connect to chat service. Please try again.",
|
||||
});
|
||||
cleanup();
|
||||
stopStreaming();
|
||||
reject(streamError);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
readStream();
|
||||
});
|
||||
} catch (err) {
|
||||
const streamError =
|
||||
err instanceof Error ? err : new Error("Failed to start stream");
|
||||
setError(streamError);
|
||||
setIsStreaming(false);
|
||||
throw streamError;
|
||||
}
|
||||
},
|
||||
[stopStreaming],
|
||||
);
|
||||
|
||||
return {
|
||||
isStreaming,
|
||||
error,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface PageContext {
|
||||
url: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to capture the current page context (URL + full page content)
|
||||
*/
|
||||
export function usePageContext() {
|
||||
const capturePageContext = useCallback((): PageContext => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return { url: "", content: "" };
|
||||
}
|
||||
|
||||
const url = window.location.href;
|
||||
|
||||
// Capture full page text content
|
||||
// Remove script and style elements, then get text
|
||||
const clone = document.cloneNode(true) as Document;
|
||||
const scripts = clone.querySelectorAll("script, style, noscript");
|
||||
scripts.forEach((el) => el.remove());
|
||||
|
||||
// Get text content from body
|
||||
const body = clone.body;
|
||||
const content = body?.textContent || body?.innerText || "";
|
||||
|
||||
// Clean up whitespace
|
||||
const cleanedContent = content
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\n\s*\n/g, "\n")
|
||||
.trim();
|
||||
|
||||
return {
|
||||
url,
|
||||
content: cleanedContent,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { capturePageContext };
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
import {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toDisplayName } from "@/providers/agent-credentials/helper";
|
||||
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
|
||||
import { CredentialRow } from "./components/CredentialRow/CredentialRow";
|
||||
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
|
||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
|
||||
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
|
||||
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
|
||||
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
|
||||
import { getCredentialDisplayName } from "./helpers";
|
||||
import {
|
||||
CredentialsInputState,
|
||||
useCredentialsInput,
|
||||
} from "./useCredentialsInput";
|
||||
|
||||
function isLoaded(
|
||||
data: CredentialsInputState,
|
||||
): data is Extract<CredentialsInputState, { isLoading: false }> {
|
||||
return data.isLoading === false;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
className?: string;
|
||||
selectedCredentials?: CredentialsMetaInput;
|
||||
siblingInputs?: Record<string, any>;
|
||||
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
|
||||
onLoaded?: (loaded: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
showTitle?: boolean;
|
||||
};
|
||||
|
||||
export function CredentialsInput({
|
||||
schema,
|
||||
className,
|
||||
selectedCredentials: selectedCredential,
|
||||
onSelectCredentials: onSelectCredential,
|
||||
siblingInputs,
|
||||
onLoaded,
|
||||
readOnly = false,
|
||||
showTitle = true,
|
||||
}: Props) {
|
||||
const hookData = useCredentialsInput({
|
||||
schema,
|
||||
selectedCredential,
|
||||
onSelectCredential,
|
||||
siblingInputs,
|
||||
onLoaded,
|
||||
readOnly,
|
||||
});
|
||||
|
||||
if (!isLoaded(hookData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
provider,
|
||||
providerName,
|
||||
supportsApiKey,
|
||||
supportsOAuth2,
|
||||
supportsUserPassword,
|
||||
supportsHostScoped,
|
||||
credentialsToShow,
|
||||
oAuthError,
|
||||
isAPICredentialsModalOpen,
|
||||
isUserPasswordCredentialsModalOpen,
|
||||
isHostScopedCredentialsModalOpen,
|
||||
isOAuth2FlowInProgress,
|
||||
oAuthPopupController,
|
||||
credentialToDelete,
|
||||
deleteCredentialsMutation,
|
||||
actionButtonText,
|
||||
setAPICredentialsModalOpen,
|
||||
setUserPasswordCredentialsModalOpen,
|
||||
setHostScopedCredentialsModalOpen,
|
||||
setCredentialToDelete,
|
||||
handleActionButtonClick,
|
||||
handleCredentialSelect,
|
||||
handleDeleteCredential,
|
||||
handleDeleteConfirm,
|
||||
} = hookData;
|
||||
|
||||
const displayName = toDisplayName(provider);
|
||||
const hasCredentialsToShow = credentialsToShow.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("mb-6", className)}>
|
||||
{showTitle && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Text variant="large-medium">{displayName} credentials</Text>
|
||||
{schema.description && (
|
||||
<InformationTooltip description={schema.description} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCredentialsToShow ? (
|
||||
<>
|
||||
{credentialsToShow.length > 1 && !readOnly ? (
|
||||
<CredentialsSelect
|
||||
credentials={credentialsToShow}
|
||||
provider={provider}
|
||||
displayName={displayName}
|
||||
selectedCredentials={selectedCredential}
|
||||
onSelectCredential={handleCredentialSelect}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-4 space-y-2">
|
||||
{credentialsToShow.map((credential) => {
|
||||
return (
|
||||
<CredentialRow
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
provider={provider}
|
||||
displayName={displayName}
|
||||
onSelect={() => handleCredentialSelect(credential.id)}
|
||||
onDelete={() =>
|
||||
handleDeleteCredential({
|
||||
id: credential.id,
|
||||
title: getCredentialDisplayName(
|
||||
credential,
|
||||
displayName,
|
||||
),
|
||||
})
|
||||
}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleActionButtonClick}
|
||||
className="w-fit"
|
||||
type="button"
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!readOnly && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleActionButtonClick}
|
||||
className="w-fit"
|
||||
type="button"
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
{supportsApiKey ? (
|
||||
<APIKeyCredentialsModal
|
||||
schema={schema}
|
||||
open={isAPICredentialsModalOpen}
|
||||
onClose={() => setAPICredentialsModalOpen(false)}
|
||||
onCredentialsCreate={(credsMeta) => {
|
||||
onSelectCredential(credsMeta);
|
||||
setAPICredentialsModalOpen(false);
|
||||
}}
|
||||
siblingInputs={siblingInputs}
|
||||
/>
|
||||
) : null}
|
||||
{supportsOAuth2 ? (
|
||||
<OAuthFlowWaitingModal
|
||||
open={isOAuth2FlowInProgress}
|
||||
onClose={() => oAuthPopupController?.abort("canceled")}
|
||||
providerName={providerName}
|
||||
/>
|
||||
) : null}
|
||||
{supportsUserPassword ? (
|
||||
<PasswordCredentialsModal
|
||||
schema={schema}
|
||||
open={isUserPasswordCredentialsModalOpen}
|
||||
onClose={() => setUserPasswordCredentialsModalOpen(false)}
|
||||
onCredentialsCreate={(creds) => {
|
||||
onSelectCredential(creds);
|
||||
setUserPasswordCredentialsModalOpen(false);
|
||||
}}
|
||||
siblingInputs={siblingInputs}
|
||||
/>
|
||||
) : null}
|
||||
{supportsHostScoped ? (
|
||||
<HostScopedCredentialsModal
|
||||
schema={schema}
|
||||
open={isHostScopedCredentialsModalOpen}
|
||||
onClose={() => setHostScopedCredentialsModalOpen(false)}
|
||||
onCredentialsCreate={(creds) => {
|
||||
onSelectCredential(creds);
|
||||
setHostScopedCredentialsModalOpen(false);
|
||||
}}
|
||||
siblingInputs={siblingInputs}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{oAuthError ? (
|
||||
<Text variant="body" className="mt-2 text-red-500">
|
||||
Error: {oAuthError}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<DeleteConfirmationModal
|
||||
credentialToDelete={credentialToDelete}
|
||||
isDeleting={deleteCredentialsMutation.isPending}
|
||||
onClose={() => setCredentialToDelete(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import {
|
||||
Form,
|
||||
FormDescription,
|
||||
FormField,
|
||||
} from "@/components/__legacy__/ui/form";
|
||||
import {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { useAPIKeyCredentialsModal } from "./useAPIKeyCredentialsModal";
|
||||
|
||||
type Props = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCredentialsCreate: (creds: CredentialsMetaInput) => void;
|
||||
siblingInputs?: Record<string, any>;
|
||||
};
|
||||
|
||||
export function APIKeyCredentialsModal({
|
||||
schema,
|
||||
open,
|
||||
onClose,
|
||||
onCredentialsCreate,
|
||||
siblingInputs,
|
||||
}: Props) {
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
supportsApiKey,
|
||||
providerName,
|
||||
schemaDescription,
|
||||
onSubmit,
|
||||
} = useAPIKeyCredentialsModal({ schema, siblingInputs, onCredentialsCreate });
|
||||
|
||||
if (isLoading || !supportsApiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={`Add new API key for ${providerName ?? ""}`}
|
||||
controlled={{
|
||||
isOpen: open,
|
||||
set: (isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{
|
||||
maxWidth: "25rem",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
{schemaDescription && (
|
||||
<p className="mb-4 text-sm text-zinc-600">{schemaDescription}</p>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<Input
|
||||
id="apiKey"
|
||||
label="API Key"
|
||||
type="password"
|
||||
placeholder="Enter API key..."
|
||||
size="small"
|
||||
hint={
|
||||
schema.credentials_scopes ? (
|
||||
<FormDescription>
|
||||
Required scope(s) for this block:{" "}
|
||||
{schema.credentials_scopes?.map((s, i, a) => (
|
||||
<span key={i}>
|
||||
<code className="text-xs font-bold">{s}</code>
|
||||
{i < a.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</FormDescription>
|
||||
) : null
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="title"
|
||||
label="Name"
|
||||
type="text"
|
||||
placeholder="Enter a name for this API key..."
|
||||
size="small"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresAt"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="expiresAt"
|
||||
label="Expiration Date"
|
||||
type="datetime-local"
|
||||
placeholder="Select expiration date..."
|
||||
size="small"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" size="small" className="min-w-68">
|
||||
Save & use this API key
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { useForm, type UseFormReturn } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import useCredentials from "@/hooks/useCredentials";
|
||||
import {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
|
||||
export type APIKeyFormValues = {
|
||||
apiKey: string;
|
||||
title: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
siblingInputs?: Record<string, any>;
|
||||
onCredentialsCreate: (creds: CredentialsMetaInput) => void;
|
||||
};
|
||||
|
||||
export function useAPIKeyCredentialsModal({
|
||||
schema,
|
||||
siblingInputs,
|
||||
onCredentialsCreate,
|
||||
}: Args): {
|
||||
form: UseFormReturn<APIKeyFormValues>;
|
||||
isLoading: boolean;
|
||||
supportsApiKey: boolean;
|
||||
provider?: string;
|
||||
providerName?: string;
|
||||
schemaDescription?: string;
|
||||
onSubmit: (values: APIKeyFormValues) => Promise<void>;
|
||||
} {
|
||||
const credentials = useCredentials(schema, siblingInputs);
|
||||
|
||||
const formSchema = z.object({
|
||||
apiKey: z.string().min(1, "API Key is required"),
|
||||
title: z.string().min(1, "Name is required"),
|
||||
expiresAt: z.string().optional(),
|
||||
});
|
||||
|
||||
const form = useForm<APIKeyFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
apiKey: "",
|
||||
title: "",
|
||||
expiresAt: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: APIKeyFormValues) {
|
||||
if (!credentials || credentials.isLoading) return;
|
||||
const expiresAt = values.expiresAt
|
||||
? new Date(values.expiresAt).getTime() / 1000
|
||||
: undefined;
|
||||
const newCredentials = await credentials.createAPIKeyCredentials({
|
||||
api_key: values.apiKey,
|
||||
title: values.title,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
onCredentialsCreate({
|
||||
provider: credentials.provider,
|
||||
id: newCredentials.id,
|
||||
type: "api_key",
|
||||
title: newCredentials.title,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
isLoading: !credentials || credentials.isLoading,
|
||||
supportsApiKey: !!credentials?.supportsApiKey,
|
||||
provider: credentials?.provider,
|
||||
providerName:
|
||||
!credentials || credentials.isLoading
|
||||
? undefined
|
||||
: credentials.providerName,
|
||||
schemaDescription: schema.description,
|
||||
onSubmit,
|
||||
};
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { IconKey } from "@/components/__legacy__/ui/icons";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CaretDown, DotsThreeVertical } from "@phosphor-icons/react";
|
||||
import {
|
||||
fallbackIcon,
|
||||
getCredentialDisplayName,
|
||||
MASKED_KEY_LENGTH,
|
||||
providerIcons,
|
||||
} from "../../helpers";
|
||||
|
||||
type CredentialRowProps = {
|
||||
credential: {
|
||||
id: string;
|
||||
title?: string;
|
||||
username?: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
};
|
||||
provider: string;
|
||||
displayName: string;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
readOnly?: boolean;
|
||||
showCaret?: boolean;
|
||||
asSelectTrigger?: boolean;
|
||||
};
|
||||
|
||||
export function CredentialRow({
|
||||
credential,
|
||||
provider,
|
||||
displayName,
|
||||
onSelect,
|
||||
onDelete,
|
||||
readOnly = false,
|
||||
showCaret = false,
|
||||
asSelectTrigger = false,
|
||||
}: CredentialRowProps) {
|
||||
const ProviderIcon = providerIcons[provider] || fallbackIcon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
|
||||
asSelectTrigger ? "border-0 bg-transparent" : readOnly ? "w-fit" : "",
|
||||
)}
|
||||
onClick={readOnly || showCaret || asSelectTrigger ? undefined : onSelect}
|
||||
style={
|
||||
readOnly || showCaret || asSelectTrigger
|
||||
? { cursor: showCaret || asSelectTrigger ? "pointer" : "default" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900">
|
||||
<ProviderIcon className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<IconKey className="h-5 w-5 shrink-0 text-zinc-800" />
|
||||
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-4">
|
||||
<Text
|
||||
variant="body"
|
||||
className="line-clamp-1 flex-[0_0_50%] text-ellipsis tracking-tight"
|
||||
>
|
||||
{getCredentialDisplayName(credential, displayName)}
|
||||
</Text>
|
||||
<Text
|
||||
variant="large"
|
||||
className="lex-[0_0_40%] relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
|
||||
>
|
||||
{"*".repeat(MASKED_KEY_LENGTH)}
|
||||
</Text>
|
||||
</div>
|
||||
{showCaret && !asSelectTrigger && (
|
||||
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
|
||||
)}
|
||||
{!readOnly && !showCaret && !asSelectTrigger && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/__legacy__/ui/select";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import { useEffect } from "react";
|
||||
import { getCredentialDisplayName } from "../../helpers";
|
||||
import { CredentialRow } from "../CredentialRow/CredentialRow";
|
||||
|
||||
interface Props {
|
||||
credentials: Array<{
|
||||
id: string;
|
||||
title?: string;
|
||||
username?: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
}>;
|
||||
provider: string;
|
||||
displayName: string;
|
||||
selectedCredentials?: CredentialsMetaInput;
|
||||
onSelectCredential: (credentialId: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function CredentialsSelect({
|
||||
credentials,
|
||||
provider,
|
||||
displayName,
|
||||
selectedCredentials,
|
||||
onSelectCredential,
|
||||
readOnly = false,
|
||||
}: Props) {
|
||||
// Auto-select first credential if none is selected
|
||||
useEffect(() => {
|
||||
if (!selectedCredentials && credentials.length > 0) {
|
||||
onSelectCredential(credentials[0].id);
|
||||
}
|
||||
}, [selectedCredentials, credentials, onSelectCredential]);
|
||||
|
||||
return (
|
||||
<div className="mb-4 w-full">
|
||||
<Select
|
||||
value={selectedCredentials?.id || ""}
|
||||
onValueChange={(value) => onSelectCredential(value)}
|
||||
>
|
||||
<SelectTrigger className="h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none">
|
||||
{selectedCredentials ? (
|
||||
<SelectValue key={selectedCredentials.id} asChild>
|
||||
<CredentialRow
|
||||
credential={{
|
||||
id: selectedCredentials.id,
|
||||
title: selectedCredentials.title || undefined,
|
||||
type: selectedCredentials.type,
|
||||
provider: selectedCredentials.provider,
|
||||
}}
|
||||
provider={provider}
|
||||
displayName={displayName}
|
||||
onSelect={() => {}}
|
||||
onDelete={() => {}}
|
||||
readOnly={readOnly}
|
||||
asSelectTrigger={true}
|
||||
/>
|
||||
</SelectValue>
|
||||
) : (
|
||||
<SelectValue key="placeholder" placeholder="Select credential" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{credentials.map((credential) => (
|
||||
<SelectItem key={credential.id} value={credential.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Text variant="body" className="tracking-tight">
|
||||
{getCredentialDisplayName(credential, displayName)}
|
||||
</Text>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
|
||||
interface Props {
|
||||
credentialToDelete: { id: string; title: string } | null;
|
||||
isDeleting: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmationModal({
|
||||
credentialToDelete,
|
||||
isDeleting,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
return (
|
||||
<Dialog
|
||||
controlled={{
|
||||
isOpen: credentialToDelete !== null,
|
||||
set: (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
title="Delete credential"
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Text variant="large">
|
||||
Are you sure you want to delete "{credentialToDelete?.title}
|
||||
"? This action cannot be undone.
|
||||
</Text>
|
||||
<Dialog.Footer>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
loading={isDeleting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import {
|
||||
Form,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormLabel,
|
||||
} from "@/components/__legacy__/ui/form";
|
||||
import useCredentials from "@/hooks/useCredentials";
|
||||
import {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { getHostFromUrl } from "@/lib/utils/url";
|
||||
import { PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
|
||||
type Props = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCredentialsCreate: (creds: CredentialsMetaInput) => void;
|
||||
siblingInputs?: Record<string, any>;
|
||||
};
|
||||
|
||||
export function HostScopedCredentialsModal({
|
||||
schema,
|
||||
open,
|
||||
onClose,
|
||||
onCredentialsCreate,
|
||||
siblingInputs,
|
||||
}: Props) {
|
||||
const credentials = useCredentials(schema, siblingInputs);
|
||||
|
||||
// Get current host from siblingInputs or discriminator_values
|
||||
const currentUrl = credentials?.discriminatorValue;
|
||||
const currentHost = currentUrl ? getHostFromUrl(currentUrl) : "";
|
||||
|
||||
const formSchema = z.object({
|
||||
host: z.string().min(1, "Host is required"),
|
||||
title: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
host: currentHost || "",
|
||||
title: currentHost || "Manual Entry",
|
||||
headers: {},
|
||||
},
|
||||
});
|
||||
|
||||
const [headerPairs, setHeaderPairs] = useState<
|
||||
Array<{ key: string; value: string }>
|
||||
>([{ key: "", value: "" }]);
|
||||
|
||||
// Update form values when siblingInputs change
|
||||
useEffect(() => {
|
||||
if (currentHost) {
|
||||
form.setValue("host", currentHost);
|
||||
form.setValue("title", currentHost);
|
||||
} else {
|
||||
// Reset to empty when no current host
|
||||
form.setValue("host", "");
|
||||
form.setValue("title", "Manual Entry");
|
||||
}
|
||||
}, [currentHost, form]);
|
||||
|
||||
if (
|
||||
!credentials ||
|
||||
credentials.isLoading ||
|
||||
!credentials.supportsHostScoped
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { provider, providerName, createHostScopedCredentials } = credentials;
|
||||
|
||||
const addHeaderPair = () => {
|
||||
setHeaderPairs([...headerPairs, { key: "", value: "" }]);
|
||||
};
|
||||
|
||||
const removeHeaderPair = (index: number) => {
|
||||
if (headerPairs.length > 1) {
|
||||
setHeaderPairs(headerPairs.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateHeaderPair = (
|
||||
index: number,
|
||||
field: "key" | "value",
|
||||
value: string,
|
||||
) => {
|
||||
const newPairs = [...headerPairs];
|
||||
newPairs[index][field] = value;
|
||||
setHeaderPairs(newPairs);
|
||||
};
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
// Convert header pairs to object, filtering out empty pairs
|
||||
const headers = headerPairs.reduce(
|
||||
(acc, pair) => {
|
||||
if (pair.key.trim() && pair.value.trim()) {
|
||||
acc[pair.key.trim()] = pair.value.trim();
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
const newCredentials = await createHostScopedCredentials({
|
||||
host: values.host,
|
||||
title: currentHost || values.host,
|
||||
headers,
|
||||
});
|
||||
|
||||
onCredentialsCreate({
|
||||
provider,
|
||||
id: newCredentials.id,
|
||||
type: "host_scoped",
|
||||
title: newCredentials.title,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={`Add sensitive headers for ${providerName}`}
|
||||
controlled={{
|
||||
isOpen: open,
|
||||
set: (isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{
|
||||
maxWidth: "25rem",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
{schema.description && (
|
||||
<p className="mb-4 text-sm text-zinc-600">{schema.description}</p>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="host"
|
||||
label="Host Pattern"
|
||||
type="text"
|
||||
size="small"
|
||||
readOnly={!!currentHost}
|
||||
hint={
|
||||
currentHost
|
||||
? "Auto-populated from the URL field. Headers will be applied to requests to this host."
|
||||
: "Enter the host/domain to match against request URLs (e.g., api.example.com)."
|
||||
}
|
||||
placeholder={
|
||||
currentHost
|
||||
? undefined
|
||||
: "Enter host (e.g., api.example.com)"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Headers</FormLabel>
|
||||
<FormDescription className="max-w-md">
|
||||
Add sensitive headers (like Authorization, X-API-Key) that
|
||||
should be automatically included in requests to the specified
|
||||
host.
|
||||
</FormDescription>
|
||||
|
||||
{headerPairs.map((pair, index) => (
|
||||
<div key={index} className="flex w-full items-center gap-4">
|
||||
<Input
|
||||
id={`header-${index}-key`}
|
||||
label="Header Name"
|
||||
placeholder="Header name (e.g., Authorization)"
|
||||
size="small"
|
||||
value={pair.key}
|
||||
className="flex-1"
|
||||
onChange={(e) =>
|
||||
updateHeaderPair(index, "key", e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id={`header-${index}-value`}
|
||||
label="Header Value"
|
||||
size="small"
|
||||
type="password"
|
||||
className="flex-2"
|
||||
placeholder="Header value (e.g., Bearer token123)"
|
||||
value={pair.value}
|
||||
onChange={(e) =>
|
||||
updateHeaderPair(index, "value", e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => removeHeaderPair(index)}
|
||||
disabled={headerPairs.length === 1}
|
||||
>
|
||||
<TrashIcon className="size-4" /> Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={addHeaderPair}
|
||||
>
|
||||
<PlusIcon className="size-4" /> Add Another Header
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-8">
|
||||
<Button type="submit" className="w-full" size="small">
|
||||
Save & use these credentials
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
providerName: string;
|
||||
};
|
||||
|
||||
export function OAuthFlowWaitingModal({ open, onClose, providerName }: Props) {
|
||||
return (
|
||||
<Dialog
|
||||
title={`Waiting on ${providerName} sign-in process...`}
|
||||
controlled={{
|
||||
isOpen: open,
|
||||
set: (isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<p className="text-sm text-zinc-600">
|
||||
Complete the sign-in process in the pop-up window.
|
||||
<br />
|
||||
Closing this dialog will cancel the sign-in process.
|
||||
</p>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import useCredentials from "@/hooks/useCredentials";
|
||||
import {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
type Props = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCredentialsCreate: (creds: CredentialsMetaInput) => void;
|
||||
siblingInputs?: Record<string, any>;
|
||||
};
|
||||
|
||||
export function PasswordCredentialsModal({
|
||||
schema,
|
||||
open,
|
||||
onClose,
|
||||
onCredentialsCreate,
|
||||
siblingInputs,
|
||||
}: Props) {
|
||||
const credentials = useCredentials(schema, siblingInputs);
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
title: z.string().min(1, "Name is required"),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
title: "",
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!credentials ||
|
||||
credentials.isLoading ||
|
||||
!credentials.supportsUserPassword
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { provider, providerName, createUserPasswordCredentials } = credentials;
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const newCredentials = await createUserPasswordCredentials({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
title: values.title,
|
||||
});
|
||||
onCredentialsCreate({
|
||||
provider,
|
||||
id: newCredentials.id,
|
||||
type: "user_password",
|
||||
title: newCredentials.title,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={`Add new username & password for ${providerName}`}
|
||||
controlled={{
|
||||
isOpen: open,
|
||||
set: (isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{
|
||||
maxWidth: "25rem",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="w-[98%] space-y-2 pt-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="username"
|
||||
label="Username"
|
||||
type="text"
|
||||
placeholder="Enter username..."
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter password..."
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="title"
|
||||
label="Name"
|
||||
type="text"
|
||||
placeholder="Enter a name for this user login..."
|
||||
className="mb-8"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full">
|
||||
Save & use this user login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { KeyIcon } from "@phosphor-icons/react";
|
||||
import { NotionLogoIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
FaDiscord,
|
||||
FaGithub,
|
||||
FaGoogle,
|
||||
FaHubspot,
|
||||
FaMedium,
|
||||
FaTwitter,
|
||||
} from "react-icons/fa";
|
||||
|
||||
export const fallbackIcon = KeyIcon;
|
||||
|
||||
export const providerIcons: Partial<
|
||||
Record<string, React.FC<{ className?: string }>>
|
||||
> = {
|
||||
aiml_api: fallbackIcon,
|
||||
anthropic: fallbackIcon,
|
||||
apollo: fallbackIcon,
|
||||
e2b: fallbackIcon,
|
||||
github: FaGithub,
|
||||
google: FaGoogle,
|
||||
groq: fallbackIcon,
|
||||
http: fallbackIcon,
|
||||
notion: NotionLogoIcon,
|
||||
nvidia: fallbackIcon,
|
||||
discord: FaDiscord,
|
||||
d_id: fallbackIcon,
|
||||
google_maps: FaGoogle,
|
||||
jina: fallbackIcon,
|
||||
ideogram: fallbackIcon,
|
||||
linear: fallbackIcon,
|
||||
medium: FaMedium,
|
||||
mem0: fallbackIcon,
|
||||
ollama: fallbackIcon,
|
||||
openai: fallbackIcon,
|
||||
openweathermap: fallbackIcon,
|
||||
open_router: fallbackIcon,
|
||||
llama_api: fallbackIcon,
|
||||
pinecone: fallbackIcon,
|
||||
enrichlayer: fallbackIcon,
|
||||
slant3d: fallbackIcon,
|
||||
screenshotone: fallbackIcon,
|
||||
smtp: fallbackIcon,
|
||||
replicate: fallbackIcon,
|
||||
reddit: fallbackIcon,
|
||||
fal: fallbackIcon,
|
||||
revid: fallbackIcon,
|
||||
twitter: FaTwitter,
|
||||
unreal_speech: fallbackIcon,
|
||||
exa: fallbackIcon,
|
||||
hubspot: FaHubspot,
|
||||
smartlead: fallbackIcon,
|
||||
todoist: fallbackIcon,
|
||||
zerobounce: fallbackIcon,
|
||||
};
|
||||
|
||||
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
|
||||
| {
|
||||
success: true;
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
message: string;
|
||||
}
|
||||
);
|
||||
|
||||
export function getActionButtonText(
|
||||
supportsOAuth2: boolean,
|
||||
supportsApiKey: boolean,
|
||||
supportsUserPassword: boolean,
|
||||
supportsHostScoped: boolean,
|
||||
hasExistingCredentials: boolean,
|
||||
): string {
|
||||
if (hasExistingCredentials) {
|
||||
if (supportsOAuth2) return "Connect another account";
|
||||
if (supportsApiKey) return "Use a new API key";
|
||||
if (supportsUserPassword) return "Add a new username and password";
|
||||
if (supportsHostScoped) return "Add new headers";
|
||||
return "Add new credentials";
|
||||
} else {
|
||||
if (supportsOAuth2) return "Add account";
|
||||
if (supportsApiKey) return "Add API key";
|
||||
if (supportsUserPassword) return "Add username and password";
|
||||
if (supportsHostScoped) return "Add headers";
|
||||
return "Add credentials";
|
||||
}
|
||||
}
|
||||
|
||||
export function getCredentialDisplayName(
|
||||
credential: { title?: string; username?: string },
|
||||
displayName: string,
|
||||
): string {
|
||||
return (
|
||||
credential.title || credential.username || `Your ${displayName} account`
|
||||
);
|
||||
}
|
||||
|
||||
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
export const MASKED_KEY_LENGTH = 30;
|
||||
@@ -1,315 +0,0 @@
|
||||
import { useDeleteV1DeleteCredentials } from "@/app/api/__generated__/endpoints/integrations/integrations";
|
||||
import useCredentials from "@/hooks/useCredentials";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getActionButtonText,
|
||||
OAUTH_TIMEOUT_MS,
|
||||
OAuthPopupResultMessage,
|
||||
} from "./helpers";
|
||||
|
||||
export type CredentialsInputState = ReturnType<typeof useCredentialsInput>;
|
||||
|
||||
type Params = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
selectedCredential?: CredentialsMetaInput;
|
||||
onSelectCredential: (newValue?: CredentialsMetaInput) => void;
|
||||
siblingInputs?: Record<string, any>;
|
||||
onLoaded?: (loaded: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export function useCredentialsInput({
|
||||
schema,
|
||||
selectedCredential,
|
||||
onSelectCredential,
|
||||
siblingInputs,
|
||||
onLoaded,
|
||||
readOnly = false,
|
||||
}: Params) {
|
||||
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
|
||||
useState(false);
|
||||
const [
|
||||
isUserPasswordCredentialsModalOpen,
|
||||
setUserPasswordCredentialsModalOpen,
|
||||
] = useState(false);
|
||||
const [isHostScopedCredentialsModalOpen, setHostScopedCredentialsModalOpen] =
|
||||
useState(false);
|
||||
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
|
||||
const [oAuthPopupController, setOAuthPopupController] =
|
||||
useState<AbortController | null>(null);
|
||||
const [oAuthError, setOAuthError] = useState<string | null>(null);
|
||||
const [credentialToDelete, setCredentialToDelete] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
const api = useBackendAPI();
|
||||
const queryClient = useQueryClient();
|
||||
const credentials = useCredentials(schema, siblingInputs);
|
||||
|
||||
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["/api/integrations/credentials"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [`/api/integrations/${credentials?.provider}/credentials`],
|
||||
});
|
||||
setCredentialToDelete(null);
|
||||
if (selectedCredential?.id === credentialToDelete?.id) {
|
||||
onSelectCredential(undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onLoaded) {
|
||||
onLoaded(Boolean(credentials && credentials.isLoading === false));
|
||||
}
|
||||
}, [credentials, onLoaded]);
|
||||
|
||||
// Unselect credential if not available
|
||||
useEffect(() => {
|
||||
if (readOnly) return;
|
||||
if (!credentials || !("savedCredentials" in credentials)) return;
|
||||
if (
|
||||
selectedCredential &&
|
||||
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id)
|
||||
) {
|
||||
onSelectCredential(undefined);
|
||||
}
|
||||
}, [credentials, selectedCredential, onSelectCredential, readOnly]);
|
||||
|
||||
// The available credential, if there is only one
|
||||
const singleCredential = useMemo(() => {
|
||||
if (!credentials || !("savedCredentials" in credentials)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return credentials.savedCredentials.length === 1
|
||||
? credentials.savedCredentials[0]
|
||||
: null;
|
||||
}, [credentials]);
|
||||
|
||||
// Auto-select the one available credential
|
||||
useEffect(() => {
|
||||
if (readOnly) return;
|
||||
if (singleCredential && !selectedCredential) {
|
||||
onSelectCredential(singleCredential);
|
||||
}
|
||||
}, [singleCredential, selectedCredential, onSelectCredential, readOnly]);
|
||||
|
||||
if (
|
||||
!credentials ||
|
||||
credentials.isLoading ||
|
||||
!("savedCredentials" in credentials)
|
||||
) {
|
||||
return {
|
||||
isLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
provider,
|
||||
providerName,
|
||||
supportsApiKey,
|
||||
supportsOAuth2,
|
||||
supportsUserPassword,
|
||||
supportsHostScoped,
|
||||
savedCredentials,
|
||||
oAuthCallback,
|
||||
} = credentials;
|
||||
|
||||
async function handleOAuthLogin() {
|
||||
setOAuthError(null);
|
||||
const { login_url, state_token } = await api.oAuthLogin(
|
||||
provider,
|
||||
schema.credentials_scopes,
|
||||
);
|
||||
setOAuth2FlowInProgress(true);
|
||||
const popup = window.open(login_url, "_blank", "popup=true");
|
||||
|
||||
if (!popup) {
|
||||
throw new Error(
|
||||
"Failed to open popup window. Please allow popups for this site.",
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setOAuthPopupController(controller);
|
||||
controller.signal.onabort = () => {
|
||||
console.debug("OAuth flow aborted");
|
||||
setOAuth2FlowInProgress(false);
|
||||
popup.close();
|
||||
};
|
||||
|
||||
const handleMessage = async (e: MessageEvent<OAuthPopupResultMessage>) => {
|
||||
console.debug("Message received:", e.data);
|
||||
if (
|
||||
typeof e.data != "object" ||
|
||||
!("message_type" in e.data) ||
|
||||
e.data.message_type !== "oauth_popup_result"
|
||||
) {
|
||||
console.debug("Ignoring irrelevant message");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.data.success) {
|
||||
console.error("OAuth flow failed:", e.data.message);
|
||||
setOAuthError(`OAuth flow failed: ${e.data.message}`);
|
||||
setOAuth2FlowInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.data.state !== state_token) {
|
||||
console.error("Invalid state token received");
|
||||
setOAuthError("Invalid state token received");
|
||||
setOAuth2FlowInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug("Processing OAuth callback");
|
||||
const credentials = await oAuthCallback(e.data.code, e.data.state);
|
||||
console.debug("OAuth callback processed successfully");
|
||||
|
||||
// Check if the credential's scopes match the required scopes
|
||||
const requiredScopes = schema.credentials_scopes;
|
||||
if (requiredScopes && requiredScopes.length > 0) {
|
||||
const grantedScopes = new Set(credentials.scopes || []);
|
||||
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
|
||||
grantedScopes,
|
||||
);
|
||||
|
||||
if (!hasAllRequiredScopes) {
|
||||
console.error(
|
||||
`Newly created OAuth credential for ${providerName} has insufficient scopes. Required:`,
|
||||
requiredScopes,
|
||||
"Granted:",
|
||||
credentials.scopes,
|
||||
);
|
||||
setOAuthError(
|
||||
"Connection failed: the granted permissions don't match what's required. " +
|
||||
"Please contact the application administrator.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onSelectCredential({
|
||||
id: credentials.id,
|
||||
type: "oauth2",
|
||||
title: credentials.title,
|
||||
provider,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in OAuth callback:", error);
|
||||
setOAuthError(
|
||||
`Error in OAuth callback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
console.debug("Finalizing OAuth flow");
|
||||
setOAuth2FlowInProgress(false);
|
||||
controller.abort("success");
|
||||
}
|
||||
};
|
||||
|
||||
console.debug("Adding message event listener");
|
||||
window.addEventListener("message", handleMessage, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.debug("OAuth flow timed out");
|
||||
controller.abort("timeout");
|
||||
setOAuth2FlowInProgress(false);
|
||||
setOAuthError("OAuth flow timed out");
|
||||
}, OAUTH_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function handleActionButtonClick() {
|
||||
if (supportsOAuth2) {
|
||||
handleOAuthLogin();
|
||||
} else if (supportsApiKey) {
|
||||
setAPICredentialsModalOpen(true);
|
||||
} else if (supportsUserPassword) {
|
||||
setUserPasswordCredentialsModalOpen(true);
|
||||
} else if (supportsHostScoped) {
|
||||
setHostScopedCredentialsModalOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCredentialSelect(credentialId: string) {
|
||||
const selectedCreds = savedCredentials.find((c) => c.id === credentialId);
|
||||
if (selectedCreds) {
|
||||
onSelectCredential({
|
||||
id: selectedCreds.id,
|
||||
type: selectedCreds.type,
|
||||
provider: provider,
|
||||
title: (selectedCreds as any).title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteCredential(credential: { id: string; title: string }) {
|
||||
setCredentialToDelete(credential);
|
||||
}
|
||||
|
||||
function handleDeleteConfirm() {
|
||||
if (credentialToDelete && credentials) {
|
||||
deleteCredentialsMutation.mutate({
|
||||
provider: credentials.provider,
|
||||
credId: credentialToDelete.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading: false as const,
|
||||
provider,
|
||||
providerName,
|
||||
supportsApiKey,
|
||||
supportsOAuth2,
|
||||
supportsUserPassword,
|
||||
supportsHostScoped,
|
||||
credentialsToShow: savedCredentials,
|
||||
selectedCredential,
|
||||
oAuthError,
|
||||
isAPICredentialsModalOpen,
|
||||
isUserPasswordCredentialsModalOpen,
|
||||
isHostScopedCredentialsModalOpen,
|
||||
isOAuth2FlowInProgress,
|
||||
oAuthPopupController,
|
||||
credentialToDelete,
|
||||
deleteCredentialsMutation,
|
||||
actionButtonText: getActionButtonText(
|
||||
supportsOAuth2,
|
||||
supportsApiKey,
|
||||
supportsUserPassword,
|
||||
supportsHostScoped,
|
||||
savedCredentials.length > 0,
|
||||
),
|
||||
setAPICredentialsModalOpen,
|
||||
setUserPasswordCredentialsModalOpen,
|
||||
setHostScopedCredentialsModalOpen,
|
||||
setCredentialToDelete,
|
||||
handleActionButtonClick,
|
||||
handleCredentialSelect,
|
||||
handleDeleteCredential,
|
||||
handleDeleteConfirm,
|
||||
handleOAuthLogin,
|
||||
onSelectCredential,
|
||||
schema,
|
||||
siblingInputs,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInputs/CredentialsInputs";
|
||||
import { CircleNotchIcon, FolderOpenIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Props as BaseProps,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import React, { useCallback } from "react";
|
||||
import { GoogleDrivePicker } from "./GoogleDrivePicker";
|
||||
import { isValidFile } from "./helpers";
|
||||
|
||||
export interface Props {
|
||||
config: GoogleDrivePickerConfig;
|
||||
@@ -27,13 +28,15 @@ export function GoogleDrivePickerInput({
|
||||
const hasAutoCredentials = !!config.auto_credentials;
|
||||
|
||||
// Strip _credentials_id from value for display purposes
|
||||
const currentFiles = isMultiSelect
|
||||
? Array.isArray(value)
|
||||
? value
|
||||
: []
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
// Only show files section when there are valid file objects
|
||||
const currentFiles = React.useMemo(() => {
|
||||
if (isMultiSelect) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isValidFile);
|
||||
}
|
||||
if (!value || !isValidFile(value)) return [];
|
||||
return [value];
|
||||
}, [value, isMultiSelect]);
|
||||
|
||||
const handlePicked = useCallback(
|
||||
(files: any[], credentialId?: string) => {
|
||||
@@ -85,23 +88,27 @@ export function GoogleDrivePickerInput({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{/* Picker Button */}
|
||||
<GoogleDrivePicker
|
||||
multiselect={config.multiselect || false}
|
||||
views={config.allowed_views || ["DOCS"]}
|
||||
scopes={config.scopes || ["https://www.googleapis.com/auth/drive.file"]}
|
||||
disabled={false}
|
||||
requirePlatformCredentials={hasAutoCredentials}
|
||||
onPicked={handlePicked}
|
||||
onCanceled={() => {
|
||||
// User canceled - no action needed
|
||||
}}
|
||||
onError={handleError}
|
||||
/>
|
||||
<div className="mb-4">
|
||||
{/* Picker Button */}
|
||||
<GoogleDrivePicker
|
||||
multiselect={config.multiselect || false}
|
||||
views={config.allowed_views || ["DOCS"]}
|
||||
scopes={
|
||||
config.scopes || ["https://www.googleapis.com/auth/drive.file"]
|
||||
}
|
||||
disabled={false}
|
||||
requirePlatformCredentials={hasAutoCredentials}
|
||||
onPicked={handlePicked}
|
||||
onCanceled={() => {
|
||||
// User canceled - no action needed
|
||||
}}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display Selected Files */}
|
||||
{currentFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="mb-8 space-y-1">
|
||||
{currentFiles.map((file: any, idx: number) => (
|
||||
<div
|
||||
key={file.id || idx}
|
||||
|
||||
@@ -119,3 +119,14 @@ export function getCredentialsSchema(scopes: string[]) {
|
||||
secret: true,
|
||||
} satisfies BlockIOCredentialsSubSchema;
|
||||
}
|
||||
|
||||
export function isValidFile(
|
||||
file: unknown,
|
||||
): file is { id?: string; name?: string } {
|
||||
return (
|
||||
typeof file === "object" &&
|
||||
file !== null &&
|
||||
(typeof (file as { id?: unknown }).id === "string" ||
|
||||
typeof (file as { name?: unknown }).name === "string")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { CheckIcon, CopyIcon, DownloadIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { OutputRenderer, OutputMetadata } from "../types";
|
||||
import { downloadOutputs } from "../utils/download";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OutputActionsProps {
|
||||
items: Array<{
|
||||
value: unknown;
|
||||
metadata?: OutputMetadata;
|
||||
renderer: OutputRenderer;
|
||||
}>;
|
||||
isPrimary?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OutputActions({
|
||||
items,
|
||||
isPrimary = false,
|
||||
}: OutputActionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyAll = async () => {
|
||||
const textContents: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const copyContent = item.renderer.getCopyContent(
|
||||
item.value,
|
||||
item.metadata,
|
||||
);
|
||||
if (
|
||||
copyContent &&
|
||||
item.renderer.isConcatenable(item.value, item.metadata)
|
||||
) {
|
||||
// For concatenable items, extract the text
|
||||
let text: string;
|
||||
if (typeof copyContent.data === "string") {
|
||||
text = copyContent.data;
|
||||
} else if (copyContent.fallbackText) {
|
||||
text = copyContent.fallbackText;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
textContents.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
if (textContents.length > 0) {
|
||||
const combinedText = textContents.join("\n\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(combinedText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
downloadOutputs(items);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={isPrimary ? "primary" : "ghost"}
|
||||
size={isPrimary ? "small" : "icon"}
|
||||
onClick={handleCopyAll}
|
||||
aria-label="Copy all text outputs"
|
||||
className={cn(isPrimary ? "min-w-0" : "")}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4 text-green-600" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
isPrimary ? "text-white" : "text-neutral-500",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isPrimary && <span>Copy All</span>}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isPrimary ? "primary" : "ghost"}
|
||||
size={isPrimary ? "small" : "icon"}
|
||||
onClick={handleDownloadAll}
|
||||
aria-label="Download outputs"
|
||||
className={cn(isPrimary ? "min-w-0" : "")}
|
||||
>
|
||||
<DownloadIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
isPrimary ? "text-white" : "text-neutral-500",
|
||||
)}
|
||||
/>
|
||||
{isPrimary && <span>Download All</span>}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { OutputMetadata, OutputRenderer } from "../types";
|
||||
|
||||
interface OutputItemProps {
|
||||
value: any;
|
||||
metadata?: OutputMetadata;
|
||||
renderer: OutputRenderer;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function OutputItem({
|
||||
value,
|
||||
metadata,
|
||||
renderer,
|
||||
label,
|
||||
}: OutputItemProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{label && (
|
||||
<Text variant="large-medium" className="capitalize">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="relative">{renderer.render(value, metadata)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { globalRegistry } from "./types";
|
||||
import { textRenderer } from "./renderers/TextRenderer";
|
||||
import { codeRenderer } from "./renderers/CodeRenderer";
|
||||
import { imageRenderer } from "./renderers/ImageRenderer";
|
||||
import { videoRenderer } from "./renderers/VideoRenderer";
|
||||
import { jsonRenderer } from "./renderers/JSONRenderer";
|
||||
import { markdownRenderer } from "./renderers/MarkdownRenderer";
|
||||
|
||||
// Register all renderers in priority order
|
||||
globalRegistry.register(videoRenderer);
|
||||
globalRegistry.register(imageRenderer);
|
||||
globalRegistry.register(codeRenderer);
|
||||
globalRegistry.register(markdownRenderer);
|
||||
globalRegistry.register(jsonRenderer);
|
||||
globalRegistry.register(textRenderer);
|
||||
|
||||
export { globalRegistry };
|
||||
export type { OutputRenderer, OutputMetadata, DownloadContent } from "./types";
|
||||
export { OutputItem } from "./components/OutputItem";
|
||||
export { OutputActions } from "./components/OutputActions";
|
||||
@@ -1,135 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
OutputRenderer,
|
||||
OutputMetadata,
|
||||
DownloadContent,
|
||||
CopyContent,
|
||||
} from "../types";
|
||||
|
||||
function getFileExtension(language: string): string {
|
||||
const extensionMap: Record<string, string> = {
|
||||
javascript: "js",
|
||||
typescript: "ts",
|
||||
python: "py",
|
||||
java: "java",
|
||||
csharp: "cs",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
html: "html",
|
||||
css: "css",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
markdown: "md",
|
||||
sql: "sql",
|
||||
bash: "sh",
|
||||
shell: "sh",
|
||||
plaintext: "txt",
|
||||
};
|
||||
|
||||
return extensionMap[language.toLowerCase()] || "txt";
|
||||
}
|
||||
|
||||
function canRenderCode(value: unknown, metadata?: OutputMetadata): boolean {
|
||||
if (metadata?.type === "code" || metadata?.language) {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
if (typeof value !== "string") return false;
|
||||
|
||||
const markdownIndicators = [
|
||||
/^#{1,6}\s+/m,
|
||||
/\*\*[^*]+\*\*/,
|
||||
/\[([^\]]+)\]\(([^)]+)\)/,
|
||||
/^>\s+/m,
|
||||
/^\s*[-*+]\s+\w+/m,
|
||||
/!\[([^\]]*)\]\(([^)]+)\)/,
|
||||
];
|
||||
|
||||
let markdownMatches = 0;
|
||||
for (const pattern of markdownIndicators) {
|
||||
if (pattern.test(value)) {
|
||||
markdownMatches++;
|
||||
if (markdownMatches >= 2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const codeIndicators = [
|
||||
/^(function|const|let|var|class|import|export|if|for|while)\s/m,
|
||||
/^def\s+\w+\s*\(/m,
|
||||
/^import\s+/m,
|
||||
/^from\s+\w+\s+import/m,
|
||||
/^\s*<[^>]+>/,
|
||||
/[{}[\]();]/,
|
||||
];
|
||||
|
||||
return codeIndicators.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function renderCode(
|
||||
value: unknown,
|
||||
metadata?: OutputMetadata,
|
||||
): React.ReactNode {
|
||||
const codeValue = String(value);
|
||||
const language = metadata?.language || "plaintext";
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
{metadata?.language && (
|
||||
<div className="absolute right-2 top-2 rounded bg-background/80 px-2 py-1 text-xs text-muted-foreground">
|
||||
{language}
|
||||
</div>
|
||||
)}
|
||||
<pre className="overflow-x-auto rounded-md bg-muted p-3">
|
||||
<code className="font-mono text-sm">{codeValue}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCopyContentCode(
|
||||
value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): CopyContent | null {
|
||||
const codeValue = String(value);
|
||||
return {
|
||||
mimeType: "text/plain",
|
||||
data: codeValue,
|
||||
fallbackText: codeValue,
|
||||
};
|
||||
}
|
||||
|
||||
function getDownloadContentCode(
|
||||
value: unknown,
|
||||
metadata?: OutputMetadata,
|
||||
): DownloadContent | null {
|
||||
const codeValue = String(value);
|
||||
const language = metadata?.language || "txt";
|
||||
const extension = getFileExtension(language);
|
||||
const blob = new Blob([codeValue], { type: "text/plain" });
|
||||
|
||||
return {
|
||||
data: blob,
|
||||
filename: metadata?.filename || `code.${extension}`,
|
||||
mimeType: "text/plain",
|
||||
};
|
||||
}
|
||||
|
||||
function isConcatenableCode(
|
||||
_value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const codeRenderer: OutputRenderer = {
|
||||
name: "CodeRenderer",
|
||||
priority: 30,
|
||||
canRender: canRenderCode,
|
||||
render: renderCode,
|
||||
getCopyContent: getCopyContentCode,
|
||||
getDownloadContent: getDownloadContentCode,
|
||||
isConcatenable: isConcatenableCode,
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
OutputRenderer,
|
||||
OutputMetadata,
|
||||
DownloadContent,
|
||||
CopyContent,
|
||||
} from "../types";
|
||||
|
||||
const imageExtensions = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".bmp",
|
||||
".svg",
|
||||
".webp",
|
||||
".ico",
|
||||
];
|
||||
|
||||
const imageMimeTypes = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/svg+xml",
|
||||
"image/webp",
|
||||
"image/x-icon",
|
||||
];
|
||||
|
||||
function guessMimeType(url: string): string | null {
|
||||
const extension = url.split(".").pop()?.toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
svg: "image/svg+xml",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
};
|
||||
return extension ? mimeMap[extension] || null : null;
|
||||
}
|
||||
|
||||
function canRenderImage(value: unknown, metadata?: OutputMetadata): boolean {
|
||||
if (
|
||||
metadata?.type === "image" ||
|
||||
(metadata?.mimeType && imageMimeTypes.includes(metadata.mimeType))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const obj = value as any;
|
||||
if (obj.url || obj.data || obj.path) {
|
||||
const urlOrData = obj.url || obj.data || obj.path;
|
||||
|
||||
if (typeof urlOrData === "string") {
|
||||
if (urlOrData.startsWith("data:image/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
urlOrData.startsWith("http://") ||
|
||||
urlOrData.startsWith("https://")
|
||||
) {
|
||||
const hasImageExt = imageExtensions.some((ext) =>
|
||||
urlOrData.toLowerCase().includes(ext),
|
||||
);
|
||||
return hasImageExt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.filename) {
|
||||
const hasImageExt = imageExtensions.some((ext) =>
|
||||
obj.filename.toLowerCase().endsWith(ext),
|
||||
);
|
||||
return hasImageExt;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith("data:image/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
const hasImageExt = imageExtensions.some((ext) =>
|
||||
value.toLowerCase().includes(ext),
|
||||
);
|
||||
return hasImageExt;
|
||||
}
|
||||
|
||||
if (metadata?.filename) {
|
||||
const hasImageExt = imageExtensions.some((ext) =>
|
||||
metadata.filename!.toLowerCase().endsWith(ext),
|
||||
);
|
||||
return hasImageExt;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderImage(
|
||||
value: unknown,
|
||||
metadata?: OutputMetadata,
|
||||
): React.ReactNode {
|
||||
const imageUrl = String(value);
|
||||
const altText = metadata?.filename || "Output image";
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={altText}
|
||||
className="h-auto max-w-full rounded-md border border-gray-200"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCopyContentImage(
|
||||
value: unknown,
|
||||
metadata?: OutputMetadata,
|
||||
): CopyContent | null {
|
||||
const imageUrl = String(value);
|
||||
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
const mimeMatch = imageUrl.match(/data:([^;]+)/);
|
||||
const mimeType = mimeMatch?.[1] || "image/png";
|
||||
|
||||
return {
|
||||
mimeType: mimeType,
|
||||
data: async () => {
|
||||
const response = await fetch(imageUrl);
|
||||
return await response.blob();
|
||||
},
|
||||
alternativeMimeTypes: ["image/png", "text/plain"],
|
||||
fallbackText: imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const mimeType = metadata?.mimeType || guessMimeType(imageUrl) || "image/png";
|
||||
|
||||
return {
|
||||
mimeType: mimeType,
|
||||
data: async () => {
|
||||
const response = await fetch(imageUrl);
|
||||
return await response.blob();
|
||||
},
|
||||
alternativeMimeTypes: ["image/png", "text/plain"],
|
||||
fallbackText: imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function getDownloadContentImage(
|
||||
value: unknown,
|
||||
metadata?: OutputMetadata,
|
||||
): DownloadContent | null {
|
||||
const imageUrl = String(value);
|
||||
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
const [mimeInfo, base64Data] = imageUrl.split(",");
|
||||
const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "image/png";
|
||||
const byteCharacters = atob(base64Data);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: mimeType });
|
||||
|
||||
const extension = mimeType.split("/")[1] || "png";
|
||||
return {
|
||||
data: blob,
|
||||
filename: metadata?.filename || `image.${extension}`,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: imageUrl,
|
||||
filename: metadata?.filename || "image.png",
|
||||
mimeType: metadata?.mimeType || "image/png",
|
||||
};
|
||||
}
|
||||
|
||||
function isConcatenableImage(
|
||||
_value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export const imageRenderer: OutputRenderer = {
|
||||
name: "ImageRenderer",
|
||||
priority: 40,
|
||||
canRender: canRenderImage,
|
||||
render: renderImage,
|
||||
getCopyContent: getCopyContentImage,
|
||||
getDownloadContent: getDownloadContentImage,
|
||||
isConcatenable: isConcatenableImage,
|
||||
};
|
||||
@@ -1,204 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { CaretDown, CaretRight } from "@phosphor-icons/react";
|
||||
import {
|
||||
OutputRenderer,
|
||||
OutputMetadata,
|
||||
DownloadContent,
|
||||
CopyContent,
|
||||
} from "../types";
|
||||
|
||||
function canRenderJSON(value: unknown, _metadata?: OutputMetadata): boolean {
|
||||
if (_metadata?.type === "json") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderJSON(
|
||||
value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): React.ReactNode {
|
||||
let jsonData = value;
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
jsonData = JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return <JSONViewer data={jsonData} />;
|
||||
}
|
||||
|
||||
function getCopyContentJSON(
|
||||
value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): CopyContent | null {
|
||||
const jsonString =
|
||||
typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
|
||||
return {
|
||||
mimeType: "application/json",
|
||||
data: jsonString,
|
||||
alternativeMimeTypes: ["text/plain"],
|
||||
fallbackText: jsonString,
|
||||
};
|
||||
}
|
||||
|
||||
function getDownloadContentJSON(
|
||||
value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): DownloadContent | null {
|
||||
const jsonString =
|
||||
typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
return {
|
||||
data: blob,
|
||||
filename: _metadata?.filename || "output.json",
|
||||
mimeType: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
function isConcatenableJSON(
|
||||
_value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const jsonRenderer: OutputRenderer = {
|
||||
name: "JSONRenderer",
|
||||
priority: 20,
|
||||
canRender: canRenderJSON,
|
||||
render: renderJSON,
|
||||
getCopyContent: getCopyContentJSON,
|
||||
getDownloadContent: getDownloadContentJSON,
|
||||
isConcatenable: isConcatenableJSON,
|
||||
};
|
||||
|
||||
function JSONViewer({ data }: { data: any }) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleCollapse = (key: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
const renderValue = (value: any, key: string = ""): React.ReactNode => {
|
||||
if (value === null)
|
||||
return <span className="text-muted-foreground">null</span>;
|
||||
if (value === undefined)
|
||||
return <span className="text-muted-foreground">undefined</span>;
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return <span className="text-blue-600">{value.toString()}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return <span className="text-green-600">{value}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return <span className="text-orange-600">"{value}"</span>;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const isCollapsed = collapsed[key];
|
||||
const itemCount = value.length;
|
||||
|
||||
if (itemCount === 0) {
|
||||
return <span className="text-muted-foreground">[]</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-block">
|
||||
<button
|
||||
onClick={() => toggleCollapse(key)}
|
||||
className="inline-flex items-center rounded px-1 hover:bg-muted"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<CaretRight className="size-3" />
|
||||
) : (
|
||||
<CaretDown className="size-3" />
|
||||
)}
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
Array({itemCount})
|
||||
</span>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div className="ml-4 mt-1">
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className="flex">
|
||||
<span className="mr-2 text-muted-foreground">{index}:</span>
|
||||
{renderValue(item, `${key}[${index}]`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const isCollapsed = collapsed[key];
|
||||
const keys = Object.keys(value);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return <span className="text-muted-foreground">{"{}"}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-block">
|
||||
<button
|
||||
onClick={() => toggleCollapse(key)}
|
||||
className="inline-flex items-center rounded px-1 hover:bg-muted"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<CaretRight className="size-3" />
|
||||
) : (
|
||||
<CaretDown className="size-3" />
|
||||
)}
|
||||
<span className="ml-1 text-muted-foreground">Object</span>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div className="ml-4 mt-1">
|
||||
{keys.map((objKey) => (
|
||||
<div key={objKey} className="flex">
|
||||
<span className="mr-2 text-purple-600">
|
||||
"{objKey}":
|
||||
</span>
|
||||
{renderValue(value[objKey], `${key}.${objKey}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-muted-foreground">{String(value)}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
|
||||
{renderValue(data, "root")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
import {
|
||||
OutputRenderer,
|
||||
OutputMetadata,
|
||||
DownloadContent,
|
||||
CopyContent,
|
||||
} from "../types";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
const markdownPatterns = [
|
||||
/```[\s\S]*?```/u, // Fenced code blocks (check first)
|
||||
/^#{1,6}\s+\S+/gmu, // ATX headers (require content)
|
||||
/\*\*[^*\n]+?\*\*/u, // **bold**
|
||||
/__(?!_)[^_\n]+?__(?!_)/u, // __bold__ (avoid ___/snake_case_)
|
||||
/(?<!\*)\*(?!\*)(?:[^*\n]|(?<=\\)\*)+?(?<!\\)\*(?!\*)/u, // *italic* (try to avoid **)
|
||||
/(?<!_)_(?!_)(?:[^_\n]|(?<=\\)_)+?(?<!\\)_(?!_)/u, // _italic_ with guards
|
||||
/\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/u, // Links with optional title (simple)
|
||||
/!\[([^\]\n]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/u, // Images with optional title (simple)
|
||||
/`[^`\n]+`/u, // Inline code
|
||||
/^(?:\s*[-*+]\s+\S.*)(?:\n\s*[-*+]\s+\S.*)+$/gmu, // UL list (≥2 items)
|
||||
/^(?:\s*\d+\.\s+\S.*)(?:\n\s*\d+\.\s+\S.*)+$/gmu, // OL list (≥2 items)
|
||||
/^>\s+\S.*/gm, // Blockquotes
|
||||
/^\|[^|\n]+(\|[^|\n]+)+\|\s*$/gm, // Table row (at least two cells)
|
||||
/^\s*\|(?:\s*:?[-=]{3,}\s*\|)+\s*$/gm, // Table separator row
|
||||
/\$\$[\s\S]+?\$\$/u, // Display math
|
||||
/(?<!\\)(?<!\w)\$[^$\n]+?\$(?!\w)/u, // Inline math: avoid prices/ids
|
||||
];
|
||||
|
||||
const videoExtensions = [".mp4", ".webm", ".ogg", ".mov", ".avi"];
|
||||
|
||||
function isVideoUrl(url: string): boolean {
|
||||
if (url.includes("youtube.com/watch") || url.includes("youtu.be/")) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (
|
||||
parsed.hostname === "vimeo.com" ||
|
||||
parsed.hostname === "www.vimeo.com"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, treat as not a Vimeo URL.
|
||||
}
|
||||
return videoExtensions.some((ext) => url.toLowerCase().includes(ext));
|
||||
}
|
||||
|
||||
function getVideoEmbedUrl(url: string): string | null {
|
||||
const youtubeMatch = url.match(
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\s]+)/,
|
||||
);
|
||||
if (youtubeMatch) {
|
||||
return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||
}
|
||||
|
||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (vimeoMatch) {
|
||||
return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
||||
}
|
||||
|
||||
if (videoExtensions.some((ext) => url.toLowerCase().includes(ext))) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderVideoEmbed(url: string): React.ReactNode {
|
||||
const embedUrl = getVideoEmbedUrl(url);
|
||||
|
||||
if (!embedUrl) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (videoExtensions.some((ext) => embedUrl.toLowerCase().includes(ext))) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<video
|
||||
controls
|
||||
className="w-full max-w-2xl rounded-lg shadow-md"
|
||||
preload="metadata"
|
||||
>
|
||||
<source src={embedUrl} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
<div className="relative aspect-video">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title="Embedded video player"
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg shadow-md"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function canRenderMarkdown(value: unknown, metadata?: OutputMetadata): boolean {
|
||||
if (
|
||||
metadata?.type === "markdown" ||
|
||||
metadata?.mimeType === "text/markdown" ||
|
||||
metadata?.mimeType === "text/x-markdown"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (metadata?.filename?.toLowerCase().endsWith(".md")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let matchCount = 0;
|
||||
const requiredMatches = 2;
|
||||
|
||||
for (const pattern of markdownPatterns) {
|
||||
if (pattern.test(value)) {
|
||||
matchCount++;
|
||||
if (matchCount >= requiredMatches) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderMarkdown(
|
||||
value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): React.ReactNode {
|
||||
const markdownContent = String(value);
|
||||
|
||||
return (
|
||||
<div className="markdown-output">
|
||||
<ReactMarkdown
|
||||
className="prose prose-sm dark:prose-invert max-w-none"
|
||||
remarkPlugins={[
|
||||
remarkGfm, // GitHub Flavored Markdown (tables, task lists, strikethrough)
|
||||
[remarkMath, { singleDollarTextMath: false }], // Math support for LaTeX
|
||||
]}
|
||||
rehypePlugins={[
|
||||
rehypeKatex, // Render math with KaTeX
|
||||
rehypeHighlight, // Syntax highlighting for code blocks
|
||||
rehypeSlug, // Add IDs to headings
|
||||
[rehypeAutolinkHeadings, { behavior: "wrap" }], // Make headings clickable
|
||||
]}
|
||||
components={{
|
||||
// Custom components for better rendering
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
className="my-4 overflow-x-auto rounded-md bg-gray-900 p-4 dark:bg-gray-950"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, ...props }: any) => {
|
||||
// Check if it's inline code by looking at the parent
|
||||
const isInline = !props.className?.includes("language-");
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
// Block code is handled by rehype-highlight
|
||||
return (
|
||||
<code className="font-mono text-sm text-gray-100" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-black underline decoration-1 underline-offset-2 transition-colors"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote
|
||||
className="my-4 border-l-4 border-blue-500 pl-4 italic text-gray-700 dark:border-blue-400 dark:text-gray-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-gray-700 dark:border-gray-700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td
|
||||
className="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
// GitHub Flavored Markdown task lists
|
||||
input: ({ ...props }: any) => {
|
||||
if (props.type === "checkbox") {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
disabled
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <input {...props} />;
|
||||
},
|
||||
// Better list styling
|
||||
ul: ({ children, ...props }: any) => (
|
||||
<ul
|
||||
className={`my-4 list-disc space-y-2 pl-6 ${
|
||||
props.className?.includes("contains-task-list")
|
||||
? "list-none pl-0"
|
||||
: ""
|
||||
}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol className="my-4 list-decimal space-y-2 pl-6" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children, ...props }: any) => (
|
||||
<li
|
||||
className={`text-gray-700 dark:text-gray-300 ${
|
||||
props.className?.includes("task-list-item")
|
||||
? "flex items-start"
|
||||
: ""
|
||||
}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
// Better heading styles
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1
|
||||
className="my-6 text-3xl font-bold text-gray-900 dark:text-gray-100"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2
|
||||
className="my-5 text-2xl font-semibold text-gray-800 dark:text-gray-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3
|
||||
className="my-4 text-xl font-semibold text-gray-800 dark:text-gray-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4
|
||||
className="my-3 text-lg font-medium text-gray-700 dark:text-gray-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children, ...props }) => (
|
||||
<h5
|
||||
className="my-2 text-base font-medium text-gray-700 dark:text-gray-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children, ...props }) => (
|
||||
<h6
|
||||
className="my-2 text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
// Horizontal rule
|
||||
hr: ({ ...props }) => (
|
||||
<hr
|
||||
className="my-6 border-gray-300 dark:border-gray-700"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
// Strikethrough (GFM)
|
||||
del: ({ children, ...props }) => (
|
||||
<del
|
||||
className="text-gray-500 line-through dark:text-gray-500"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</del>
|
||||
),
|
||||
// Image handling
|
||||
img: ({ src, alt, ...props }) => {
|
||||
// Check if it's a video URL pattern
|
||||
if (src && isVideoUrl(src)) {
|
||||
return renderVideoEmbed(src);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="my-4 h-auto max-w-full rounded-lg shadow-md"
|
||||
loading="lazy"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Custom paragraph to handle standalone video URLs
|
||||
p: ({ children, ...props }) => {
|
||||
// Check if paragraph contains just a video URL
|
||||
if (typeof children === "string" && isVideoUrl(children.trim())) {
|
||||
return renderVideoEmbed(children.trim());
|
||||
}
|
||||
|
||||
// Check for video URLs in link children
|
||||
if (React.Children.count(children) === 1) {
|
||||
const child = React.Children.toArray(children)[0];
|
||||
if (React.isValidElement(child) && child.type === "a") {
|
||||
const href = child.props.href;
|
||||
if (href && isVideoUrl(href)) {
|
||||
return renderVideoEmbed(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
className="my-3 leading-relaxed text-gray-700 dark:text-gray-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCopyContentMarkdown(
|
||||
value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): CopyContent | null {
|
||||
const markdownText = String(value);
|
||||
return {
|
||||
mimeType: "text/markdown",
|
||||
data: markdownText,
|
||||
fallbackText: markdownText,
|
||||
alternativeMimeTypes: ["text/plain"],
|
||||
};
|
||||
}
|
||||
|
||||
function getDownloadContentMarkdown(
|
||||
value: unknown,
|
||||
metadata?: OutputMetadata,
|
||||
): DownloadContent | null {
|
||||
const markdownText = String(value);
|
||||
const blob = new Blob([markdownText], { type: "text/markdown" });
|
||||
|
||||
return {
|
||||
data: blob,
|
||||
filename: metadata?.filename || "output.md",
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
}
|
||||
|
||||
function isConcatenableMarkdown(
|
||||
_value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const markdownRenderer: OutputRenderer = {
|
||||
name: "MarkdownRenderer",
|
||||
priority: 35,
|
||||
canRender: canRenderMarkdown,
|
||||
render: renderMarkdown,
|
||||
getCopyContent: getCopyContentMarkdown,
|
||||
getDownloadContent: getDownloadContentMarkdown,
|
||||
isConcatenable: isConcatenableMarkdown,
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
OutputRenderer,
|
||||
OutputMetadata,
|
||||
DownloadContent,
|
||||
CopyContent,
|
||||
} from "../types";
|
||||
|
||||
function canRenderText(value: unknown, _metadata?: OutputMetadata): boolean {
|
||||
return (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function renderText(
|
||||
value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): React.ReactNode {
|
||||
const textValue = String(value);
|
||||
|
||||
return (
|
||||
<p className="resize-none overflow-x-auto whitespace-pre-wrap break-words border-none text-sm text-neutral-700">
|
||||
{textValue}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function getCopyContentText(
|
||||
value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): CopyContent | null {
|
||||
const textValue = String(value);
|
||||
return {
|
||||
mimeType: "text/plain",
|
||||
data: textValue,
|
||||
fallbackText: textValue,
|
||||
};
|
||||
}
|
||||
|
||||
function getDownloadContentText(
|
||||
value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): DownloadContent | null {
|
||||
const textValue = String(value);
|
||||
const blob = new Blob([textValue], { type: "text/plain" });
|
||||
|
||||
return {
|
||||
data: blob,
|
||||
filename: _metadata?.filename || "output.txt",
|
||||
mimeType: "text/plain",
|
||||
};
|
||||
}
|
||||
|
||||
function isConcatenableText(
|
||||
_value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const textRenderer: OutputRenderer = {
|
||||
name: "TextRenderer",
|
||||
priority: 0,
|
||||
canRender: canRenderText,
|
||||
render: renderText,
|
||||
getCopyContent: getCopyContentText,
|
||||
getDownloadContent: getDownloadContentText,
|
||||
isConcatenable: isConcatenableText,
|
||||
};
|
||||
@@ -1,169 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
OutputRenderer,
|
||||
OutputMetadata,
|
||||
DownloadContent,
|
||||
CopyContent,
|
||||
} from "../types";
|
||||
|
||||
const videoExtensions = [
|
||||
".mp4",
|
||||
".webm",
|
||||
".ogg",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".m4v",
|
||||
];
|
||||
|
||||
const videoMimeTypes = [
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/ogg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/x-matroska",
|
||||
];
|
||||
|
||||
function guessMimeType(url: string): string | null {
|
||||
const extension = url.split(".").pop()?.toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
ogg: "video/ogg",
|
||||
mov: "video/quicktime",
|
||||
avi: "video/x-msvideo",
|
||||
mkv: "video/x-matroska",
|
||||
m4v: "video/mp4",
|
||||
};
|
||||
return extension ? mimeMap[extension] || null : null;
|
||||
}
|
||||
|
||||
function canRenderVideo(value: unknown, metadata?: OutputMetadata): boolean {
|
||||
if (
|
||||
metadata?.type === "video" ||
|
||||
(metadata?.mimeType && videoMimeTypes.includes(metadata.mimeType))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith("data:video/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
return videoExtensions.some((ext) => value.toLowerCase().includes(ext));
|
||||
}
|
||||
|
||||
if (metadata?.filename) {
|
||||
return videoExtensions.some((ext) =>
|
||||
metadata.filename!.toLowerCase().endsWith(ext),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderVideo(
|
||||
value: unknown,
|
||||
metadata?: OutputMetadata,
|
||||
): React.ReactNode {
|
||||
const videoUrl = String(value);
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<video
|
||||
controls
|
||||
className="h-auto max-w-full rounded-md border border-gray-200"
|
||||
preload="metadata"
|
||||
>
|
||||
<source src={videoUrl} type={metadata?.mimeType || "video/mp4"} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCopyContentVideo(
|
||||
value: unknown,
|
||||
metadata?: OutputMetadata,
|
||||
): CopyContent | null {
|
||||
const videoUrl = String(value);
|
||||
|
||||
if (videoUrl.startsWith("data:")) {
|
||||
const mimeMatch = videoUrl.match(/data:([^;]+)/);
|
||||
const mimeType = mimeMatch?.[1] || "video/mp4";
|
||||
|
||||
return {
|
||||
mimeType: mimeType,
|
||||
data: videoUrl,
|
||||
alternativeMimeTypes: ["text/plain"],
|
||||
fallbackText: videoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const mimeType = metadata?.mimeType || guessMimeType(videoUrl) || "video/mp4";
|
||||
|
||||
return {
|
||||
mimeType: mimeType,
|
||||
data: async () => {
|
||||
const response = await fetch(videoUrl);
|
||||
return await response.blob();
|
||||
},
|
||||
alternativeMimeTypes: ["text/plain"],
|
||||
fallbackText: videoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function getDownloadContentVideo(
|
||||
value: unknown,
|
||||
metadata?: OutputMetadata,
|
||||
): DownloadContent | null {
|
||||
const videoUrl = String(value);
|
||||
|
||||
if (videoUrl.startsWith("data:")) {
|
||||
const [mimeInfo, base64Data] = videoUrl.split(",");
|
||||
const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "video/mp4";
|
||||
const byteCharacters = atob(base64Data);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: mimeType });
|
||||
|
||||
const extension = mimeType.split("/")[1] || "mp4";
|
||||
return {
|
||||
data: blob,
|
||||
filename: metadata?.filename || `video.${extension}`,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: videoUrl,
|
||||
filename: metadata?.filename || "video.mp4",
|
||||
mimeType: metadata?.mimeType || "video/mp4",
|
||||
};
|
||||
}
|
||||
|
||||
function isConcatenableVideo(
|
||||
_value: unknown,
|
||||
_metadata?: OutputMetadata,
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export const videoRenderer: OutputRenderer = {
|
||||
name: "VideoRenderer",
|
||||
priority: 45,
|
||||
canRender: canRenderVideo,
|
||||
render: renderVideo,
|
||||
getCopyContent: getCopyContentVideo,
|
||||
getDownloadContent: getDownloadContentVideo,
|
||||
isConcatenable: isConcatenableVideo,
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface OutputMetadata {
|
||||
type?: string;
|
||||
language?: string;
|
||||
mimeType?: string;
|
||||
filename?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DownloadContent {
|
||||
data: Blob | string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface CopyContent {
|
||||
mimeType: string; // Primary MIME type to try
|
||||
data: Blob | string | (() => Promise<Blob | string>); // Data or async function to get data
|
||||
fallbackText?: string; // Optional fallback text if rich copy fails
|
||||
alternativeMimeTypes?: string[]; // Alternative MIME types to try if primary isn't supported
|
||||
}
|
||||
|
||||
export interface OutputRenderer {
|
||||
name: string;
|
||||
priority: number;
|
||||
canRender(value: any, metadata?: OutputMetadata): boolean;
|
||||
render(value: any, metadata?: OutputMetadata): ReactNode;
|
||||
getCopyContent(value: any, metadata?: OutputMetadata): CopyContent | null;
|
||||
getDownloadContent(
|
||||
value: any,
|
||||
metadata?: OutputMetadata,
|
||||
): DownloadContent | null;
|
||||
isConcatenable(value: any, metadata?: OutputMetadata): boolean;
|
||||
}
|
||||
|
||||
export class OutputRendererRegistry {
|
||||
private renderers: OutputRenderer[] = [];
|
||||
|
||||
register(renderer: OutputRenderer): void {
|
||||
const index = this.renderers.findIndex(
|
||||
(r) => r.priority < renderer.priority,
|
||||
);
|
||||
if (index === -1) {
|
||||
this.renderers.push(renderer);
|
||||
} else {
|
||||
this.renderers.splice(index, 0, renderer);
|
||||
}
|
||||
}
|
||||
|
||||
getRenderer(value: any, metadata?: OutputMetadata): OutputRenderer | null {
|
||||
return this.renderers.find((r) => r.canRender(value, metadata)) || null;
|
||||
}
|
||||
|
||||
getAllRenderers(): OutputRenderer[] {
|
||||
return [...this.renderers];
|
||||
}
|
||||
}
|
||||
|
||||
export const globalRegistry = new OutputRendererRegistry();
|
||||
@@ -1,115 +0,0 @@
|
||||
import { CopyContent } from "../types";
|
||||
|
||||
export function isClipboardTypeSupported(mimeType: string): boolean {
|
||||
// ClipboardItem.supports() is the proper way to check
|
||||
if ("ClipboardItem" in window && "supports" in ClipboardItem) {
|
||||
return ClipboardItem.supports(mimeType);
|
||||
}
|
||||
|
||||
// Fallback for browsers that don't support the supports() method
|
||||
// These are generally supported
|
||||
const fallbackSupported = ["text/plain", "text/html", "image/png"];
|
||||
|
||||
return fallbackSupported.includes(mimeType);
|
||||
}
|
||||
|
||||
export function getSupportedClipboardType(
|
||||
preferredTypes: string[],
|
||||
): string | null {
|
||||
for (const type of preferredTypes) {
|
||||
if (isClipboardTypeSupported(type)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function copyToClipboard(copyContent: CopyContent): Promise<void> {
|
||||
try {
|
||||
// Determine the best supported MIME type
|
||||
const supportedTypes = [
|
||||
copyContent.mimeType,
|
||||
...(copyContent.alternativeMimeTypes || []),
|
||||
];
|
||||
const bestType = getSupportedClipboardType(supportedTypes);
|
||||
|
||||
if (!bestType) {
|
||||
// No supported type found, use fallback text if available
|
||||
if (copyContent.fallbackText) {
|
||||
await navigator.clipboard.writeText(copyContent.fallbackText);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`None of the MIME types are supported: ${supportedTypes.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the data (resolve if it's a function)
|
||||
let data = copyContent.data;
|
||||
if (typeof data === "function") {
|
||||
data = await data();
|
||||
}
|
||||
|
||||
// If data is already a Blob, use it directly
|
||||
if (data instanceof Blob) {
|
||||
// If we need a different MIME type than the blob has, recreate it
|
||||
if (bestType !== data.type && bestType !== copyContent.mimeType) {
|
||||
data = new Blob([data], { type: bestType });
|
||||
}
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[bestType]: data,
|
||||
}),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// If data is a string
|
||||
if (typeof data === "string") {
|
||||
// For plain text, use the simpler writeText API
|
||||
if (bestType === "text/plain") {
|
||||
await navigator.clipboard.writeText(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other text formats (HTML, JSON, etc.), create a blob
|
||||
const blob = new Blob([data], { type: bestType });
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[bestType]: blob,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
} catch (_error) {
|
||||
// If rich copy fails and we have fallback text, try that
|
||||
if (copyContent.fallbackText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyContent.fallbackText);
|
||||
return;
|
||||
} catch {
|
||||
// Even fallback failed
|
||||
}
|
||||
}
|
||||
throw _error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAndCopyImage(imageUrl: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) throw new Error("Failed to fetch image");
|
||||
|
||||
const blob = await response.blob();
|
||||
const mimeType = blob.type || "image/png";
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[mimeType]: blob,
|
||||
}),
|
||||
]);
|
||||
} catch (_error) {
|
||||
// If fetching fails (e.g., CORS), fall back to copying the URL
|
||||
await navigator.clipboard.writeText(imageUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { OutputRenderer, OutputMetadata } from "../types";
|
||||
|
||||
export interface DownloadItem {
|
||||
value: any;
|
||||
metadata?: OutputMetadata;
|
||||
renderer: OutputRenderer;
|
||||
}
|
||||
|
||||
export async function downloadOutputs(items: DownloadItem[]) {
|
||||
const concatenableTexts: string[] = [];
|
||||
const nonConcatenableDownloads: Array<{ blob: Blob; filename: string }> = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.renderer.isConcatenable(item.value, item.metadata)) {
|
||||
const copyContent = item.renderer.getCopyContent(
|
||||
item.value,
|
||||
item.metadata,
|
||||
);
|
||||
if (copyContent) {
|
||||
// Extract text from CopyContent
|
||||
let text: string;
|
||||
if (typeof copyContent.data === "string") {
|
||||
text = copyContent.data;
|
||||
} else if (copyContent.fallbackText) {
|
||||
text = copyContent.fallbackText;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
concatenableTexts.push(text);
|
||||
}
|
||||
} else {
|
||||
const downloadContent = item.renderer.getDownloadContent(
|
||||
item.value,
|
||||
item.metadata,
|
||||
);
|
||||
if (downloadContent) {
|
||||
if (typeof downloadContent.data === "string") {
|
||||
if (downloadContent.data.startsWith("http")) {
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadContent.data;
|
||||
link.download = downloadContent.filename;
|
||||
link.click();
|
||||
}
|
||||
} else {
|
||||
nonConcatenableDownloads.push({
|
||||
blob: downloadContent.data as Blob,
|
||||
filename: downloadContent.filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (concatenableTexts.length > 0) {
|
||||
const combinedText = concatenableTexts.join("\n\n---\n\n");
|
||||
const blob = new Blob([combinedText], { type: "text/plain" });
|
||||
downloadBlob(blob, "combined_output.txt");
|
||||
}
|
||||
|
||||
for (const download of nonConcatenableDownloads) {
|
||||
downloadBlob(download.blob, download.filename);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
import { format } from "date-fns";
|
||||
import React from "react";
|
||||
|
||||
import { Input as DSInput } from "@/components/atoms/Input/Input";
|
||||
import { Select as DSSelect } from "@/components/atoms/Select/Select";
|
||||
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
|
||||
// Removed shadcn Select usage in favor of DS Select for time picker
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { FileInput } from "@/components/atoms/FileInput/FileInput";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
import { TimePicker } from "@/components/molecules/TimePicker/TimePicker";
|
||||
import {
|
||||
BlockIOObjectSubSchema,
|
||||
BlockIOSubSchema,
|
||||
BlockIOTableSubSchema,
|
||||
DataType,
|
||||
determineDataType,
|
||||
TableRow,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { useRunAgentInputs } from "./useRunAgentInputs";
|
||||
|
||||
/**
|
||||
* A generic prop structure for the TypeBasedInput.
|
||||
*
|
||||
* onChange expects an event-like object with e.target.value so the parent
|
||||
* can do something like setInputValues(e.target.value).
|
||||
*/
|
||||
interface Props {
|
||||
schema: BlockIOSubSchema;
|
||||
value?: any;
|
||||
placeholder?: string;
|
||||
onChange: (value: any) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic, data-type-based input component that uses Shadcn UI.
|
||||
* It inspects the schema via `determineDataType` and renders
|
||||
* the correct UI component.
|
||||
*/
|
||||
export function RunAgentInputs({
|
||||
schema,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
...props
|
||||
}: Props & React.HTMLAttributes<HTMLElement>) {
|
||||
const { handleUploadFile, uploadProgress } = useRunAgentInputs();
|
||||
|
||||
const dataType = determineDataType(schema);
|
||||
|
||||
const baseId = String(schema.title ?? "input")
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
|
||||
let innerInputElement: React.ReactNode = null;
|
||||
switch (dataType) {
|
||||
case DataType.NUMBER:
|
||||
innerInputElement = (
|
||||
<DSInput
|
||||
id={`${baseId}-number`}
|
||||
label={schema.title ?? placeholder ?? "Number"}
|
||||
hideLabel
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder || "Enter number"}
|
||||
onChange={(e) =>
|
||||
onChange(Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case DataType.LONG_TEXT:
|
||||
innerInputElement = (
|
||||
<DSInput
|
||||
id={`${baseId}-textarea`}
|
||||
label={schema.title ?? placeholder ?? "Text"}
|
||||
hideLabel
|
||||
type="textarea"
|
||||
rows={3}
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder || "Enter text"}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case DataType.GOOGLE_DRIVE_PICKER: {
|
||||
const pickerSchema = schema as any;
|
||||
const config: import("@/lib/autogpt-server-api/types").GoogleDrivePickerConfig =
|
||||
pickerSchema.google_drive_picker_config || {};
|
||||
|
||||
innerInputElement = (
|
||||
<GoogleDrivePickerInput
|
||||
config={config}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
showRemoveButton={!readOnly}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case DataType.BOOLEAN:
|
||||
innerInputElement = (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">
|
||||
{placeholder || (value ? "Enabled" : "Disabled")}
|
||||
</span>
|
||||
<Switch
|
||||
className="ml-auto"
|
||||
checked={!!value}
|
||||
onCheckedChange={(checked: boolean) => onChange(checked)}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
case DataType.DATE:
|
||||
innerInputElement = (
|
||||
<DSInput
|
||||
id={`${baseId}-date`}
|
||||
label={schema.title ?? placeholder ?? "Date"}
|
||||
hideLabel
|
||||
type="date"
|
||||
value={value ? format(value as Date, "yyyy-MM-dd") : ""}
|
||||
onChange={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
if (!v) onChange(undefined);
|
||||
else {
|
||||
const [y, m, d] = v.split("-").map(Number);
|
||||
onChange(new Date(y, m - 1, d));
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || "Pick a date"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case DataType.TIME:
|
||||
innerInputElement = (
|
||||
<TimePicker value={value?.toString()} onChange={onChange} />
|
||||
);
|
||||
break;
|
||||
|
||||
case DataType.DATE_TIME:
|
||||
innerInputElement = (
|
||||
<DSInput
|
||||
id={`${baseId}-datetime`}
|
||||
label={schema.title ?? placeholder ?? "Date time"}
|
||||
hideLabel
|
||||
type="datetime-local"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
placeholder={placeholder || "Enter date and time"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case DataType.FILE:
|
||||
innerInputElement = (
|
||||
<FileInput
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
onUploadFile={handleUploadFile}
|
||||
uploadProgress={uploadProgress}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case DataType.SELECT:
|
||||
if (
|
||||
"enum" in schema &&
|
||||
Array.isArray(schema.enum) &&
|
||||
schema.enum.length > 0
|
||||
) {
|
||||
innerInputElement = (
|
||||
<DSSelect
|
||||
id={`${baseId}-select`}
|
||||
label={schema.title ?? placeholder ?? "Select"}
|
||||
hideLabel
|
||||
value={value ?? ""}
|
||||
onValueChange={(val: string) => onChange(val)}
|
||||
placeholder={placeholder || "Select an option"}
|
||||
options={schema.enum
|
||||
.filter((opt) => opt)
|
||||
.map((opt) => ({ value: opt, label: String(opt) }))}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case DataType.MULTI_SELECT: {
|
||||
const _schema = schema as BlockIOObjectSubSchema;
|
||||
const properties = _schema.properties || {};
|
||||
const allKeys = Object.keys(properties);
|
||||
const selectedValues = Object.entries(value || {})
|
||||
.filter(([_, v]) => v)
|
||||
.map(([k]) => k);
|
||||
|
||||
innerInputElement = (
|
||||
<MultiToggle
|
||||
items={allKeys.map((key) => ({
|
||||
value: key,
|
||||
label: properties[key]?.title ?? key,
|
||||
}))}
|
||||
selectedValues={selectedValues}
|
||||
onChange={(values: string[]) =>
|
||||
onChange(
|
||||
Object.fromEntries(
|
||||
allKeys.map((opt) => [opt, values.includes(opt)]),
|
||||
),
|
||||
)
|
||||
}
|
||||
className="nodrag"
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case DataType.TABLE: {
|
||||
// Render a simple table UI for the run modal
|
||||
const tableSchema = schema as BlockIOTableSubSchema;
|
||||
const headers = tableSchema.items?.properties
|
||||
? Object.keys(tableSchema.items.properties)
|
||||
: ["Column 1", "Column 2", "Column 3"];
|
||||
|
||||
const tableData: TableRow[] = Array.isArray(value) ? value : [];
|
||||
|
||||
const updateRow = (index: number, header: string, newValue: string) => {
|
||||
const newData = [...tableData];
|
||||
if (!newData[index]) {
|
||||
newData[index] = {};
|
||||
}
|
||||
newData[index][header] = newValue;
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
const newRow: TableRow = {};
|
||||
headers.forEach((header) => {
|
||||
newRow[header] = "";
|
||||
});
|
||||
onChange([...tableData, newRow]);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const newData = tableData.filter((_, i) => i !== index);
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
innerInputElement = (
|
||||
<div className="w-full space-y-2">
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800">
|
||||
{headers.map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-10 px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t dark:border-gray-700">
|
||||
{headers.map((header) => (
|
||||
<td key={header} className="px-3 py-1">
|
||||
<input
|
||||
type="text"
|
||||
value={String(row[header] || "")}
|
||||
onChange={(e) =>
|
||||
updateRow(rowIndex, header, e.target.value)
|
||||
}
|
||||
className="w-full rounded border px-2 py-1 dark:border-gray-700 dark:bg-gray-900"
|
||||
placeholder={`Enter ${header}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XIcon className="h-4 w-4" weight="bold" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={addRow}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" weight="bold" />
|
||||
Add Row
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case DataType.SHORT_TEXT:
|
||||
default:
|
||||
innerInputElement = (
|
||||
<DSInput
|
||||
id={`${baseId}-text`}
|
||||
label={schema.title ?? placeholder ?? "Text"}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
placeholder={placeholder || "Enter text"}
|
||||
disabled={readOnly}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-0 space-y-2">
|
||||
<label className="large-medium flex items-center gap-1 font-medium">
|
||||
{schema.title || placeholder}
|
||||
<InformationTooltip description={schema.description} />
|
||||
</label>
|
||||
<div className="no-drag relative flex w-full">
|
||||
{readOnly ? (
|
||||
<div style={{ pointerEvents: "none", opacity: 0.7 }}>
|
||||
{innerInputElement}
|
||||
</div>
|
||||
) : (
|
||||
innerInputElement
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { useState } from "react";
|
||||
|
||||
export function useRunAgentInputs() {
|
||||
const api = new BackendAPI();
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
async function handleUploadFile(file: File) {
|
||||
const result = await api.uploadFile(file, "gcs", 24, (progress) =>
|
||||
setUploadProgress(progress),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
uploadProgress,
|
||||
handleUploadFile,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IconLaptop } from "@/components/__legacy__/ui/icons";
|
||||
import { useChatDrawer } from "@/components/contextual/Chat/useChatDrawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import {
|
||||
@@ -25,22 +24,9 @@ export function NavbarLink({ name, href }: Props) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname.includes(href);
|
||||
const chat_enabled = useGetFlag(Flag.CHAT);
|
||||
const { open: openChatDrawer } = useChatDrawer();
|
||||
const isChat = href === "/chat";
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
if (isChat && chat_enabled) {
|
||||
e.preventDefault();
|
||||
openChatDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
data-testid={`navbar-link-${name.toLowerCase()}`}
|
||||
>
|
||||
<Link href={href} data-testid={`navbar-link-${name.toLowerCase()}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-start gap-1 p-1 md:p-2",
|
||||
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
TemplatesType,
|
||||
} from "@rjsf/utils";
|
||||
import { AnyOfField } from "./anyof/AnyOfField";
|
||||
import { AddButton, CopyButton, RemoveButton } from "./standard/buttons";
|
||||
import {
|
||||
ArrayFieldItemTemplate,
|
||||
ArraySchemaField,
|
||||
ArrayFieldTemplate,
|
||||
ArraySchemaField,
|
||||
} from "./array";
|
||||
import {
|
||||
ObjectFieldTemplate,
|
||||
@@ -16,14 +15,16 @@ import {
|
||||
WrapIfAdditionalTemplate,
|
||||
} from "./object";
|
||||
import { DescriptionField, FieldTemplate, TitleField } from "./standard";
|
||||
import { AddButton, CopyButton, RemoveButton } from "./standard/buttons";
|
||||
import {
|
||||
TextWidget,
|
||||
SelectWidget,
|
||||
CheckboxWidget,
|
||||
FileWidget,
|
||||
DateWidget,
|
||||
TimeWidget,
|
||||
DateTimeWidget,
|
||||
DateWidget,
|
||||
FileWidget,
|
||||
GoogleDrivePickerWidget,
|
||||
SelectWidget,
|
||||
TextWidget,
|
||||
TimeWidget,
|
||||
} from "./standard/widgets";
|
||||
|
||||
const NoButton = () => null;
|
||||
@@ -65,5 +66,6 @@ export function generateBaseWidgets(): RegistryWidgetsType {
|
||||
DateWidget,
|
||||
TimeWidget,
|
||||
DateTimeWidget,
|
||||
GoogleDrivePickerWidget,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
|
||||
import { getFieldErrorKey } from "@/components/renderers/InputRenderer/utils/helpers";
|
||||
import type { GoogleDrivePickerConfig } from "@/lib/autogpt-server-api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
|
||||
function hasGoogleDrivePickerConfig(
|
||||
schema: unknown,
|
||||
): schema is { google_drive_picker_config?: GoogleDrivePickerConfig } {
|
||||
return (
|
||||
typeof schema === "object" &&
|
||||
schema !== null &&
|
||||
"google_drive_picker_config" in schema
|
||||
);
|
||||
}
|
||||
|
||||
export function GoogleDrivePickerWidget(props: WidgetProps) {
|
||||
const { onChange, disabled, readonly, value, schema, id, formContext } =
|
||||
props;
|
||||
const { nodeId } = formContext || {};
|
||||
|
||||
const nodeErrors = useNodeStore((state) => {
|
||||
const node = state.nodes.find((n) => n.id === nodeId);
|
||||
return node?.data?.errors;
|
||||
});
|
||||
|
||||
const fieldErrorKey = getFieldErrorKey(id ?? "");
|
||||
const fieldError =
|
||||
nodeErrors?.[fieldErrorKey] ||
|
||||
nodeErrors?.[fieldErrorKey.replace(/_/g, ".")] ||
|
||||
nodeErrors?.[fieldErrorKey.replace(/\./g, "_")] ||
|
||||
undefined;
|
||||
|
||||
const config: GoogleDrivePickerConfig = hasGoogleDrivePickerConfig(schema)
|
||||
? schema.google_drive_picker_config || {}
|
||||
: {};
|
||||
|
||||
function handleChange(newValue: unknown) {
|
||||
onChange(newValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<GoogleDrivePickerInput
|
||||
config={config}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
error={fieldError}
|
||||
className={cn(
|
||||
disabled || readonly ? "pointer-events-none opacity-50" : undefined,
|
||||
)}
|
||||
showRemoveButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GoogleDrivePickerWidget } from "./GoogleDrivePicketWidget";
|
||||
@@ -1,7 +1,8 @@
|
||||
export { default as TextWidget } from "./TextInput";
|
||||
export { SelectWidget } from "./SelectInput";
|
||||
export { CheckboxWidget } from "./CheckboxInput";
|
||||
export { FileWidget } from "./FileInput";
|
||||
export { DateWidget } from "./DateInput";
|
||||
export { TimeWidget } from "./TimeInput";
|
||||
export { DateTimeWidget } from "./DateTimeInput";
|
||||
export { FileWidget } from "./FileInput";
|
||||
export { GoogleDrivePickerWidget } from "./GoogleDrivePicker";
|
||||
export { SelectWidget } from "./SelectInput";
|
||||
export { default as TextWidget } from "./TextInput";
|
||||
export { TimeWidget } from "./TimeInput";
|
||||
|
||||
Reference in New Issue
Block a user