Compare commits

..

6 Commits

Author SHA1 Message Date
Zamil Majdy
f482eb668b hotfix(backend): resolve tool pin name mismatch in SmartDecisionMakerBlock (#11749)
## Root Cause

Execution a40bdb4a-964d-4684-94e8-b148eb6bcfc2 and all similar
executions have been failing since Nov 12, 2025 when tool pin routing
was refactored to use node IDs. The SmartDecisionMakerBlock was
double-sanitizing field names when emitting tool call outputs:

```python
# Original field name from link: "Max Keyword Difficulty"
original_field_name = field_mapping.get(clean_arg_name)  #  Retrieved correctly
sanitized_arg_name = self.cleanup(original_field_name)   #  Sanitized AGAIN!
emit_key = f"tools_^_{node_id}_~_{sanitized_arg_name}"   # Emits "max_keyword_difficulty"
```

But the parser expected original names from graph links:
```python
# Parser expects: "Max Keyword Difficulty" (from link.sink_name)
# Emit provides: "max_keyword_difficulty" (sanitized)
# Result: Mismatch → Tool never executes
```

### Changes 🏗️

**1. Fixed Emit Logic** (`smart_decision_maker.py` line 1135)
- Removed double sanitization: `sanitized_arg_name =
self.cleanup(original_field_name)`
- Now emits with original field names: `emit_key =
f"tools_^_{node_id}_~_{original_field_name}"`

**2. Made Agent Nodes Consistent** (`smart_decision_maker.py` lines
497-530)
- Added `field_mapping` to agent function signatures (was missing)
- Agent signatures now sanitize property keys for Anthropic API (like
block signatures)
- Stores field_mapping for use during emit

### Impact

**Fixes:**
-  All graphs with multi-word field names (e.g., "Max Keyword
Difficulty", "Minimum Volume")
-  All graphs with special characters in field names (e.g., "API-Key")
-  Both block nodes AND agent nodes now work consistently

**Unaffected:**
- Single-word lowercase field names (e.g., "keyword", "url") - these
were already working

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified parse_execution_output handles exact match correctly
  - [x] Verified emit uses original field names
  - [x] Verified field_mapping works for both block and agent nodes
- [x] Re-run execution a40bdb4a-964d-4684-94e8-b148eb6bcfc2 after
deployment to verify fix

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
(no changes)
- [x] `docker-compose.yml` is updated or already compatible with my
changes (no changes)
- [x] No configuration changes in this PR

### Test Plan

1. **Unit test validation** (completed):
- Field name cleanup: "Max Keyword Difficulty" →
"max_keyword_difficulty" 
   - Parse with exact match: Success 
   - Parse with mismatch: Returns None 

2. **Production validation** (to be done after deployment):
   - Re-run execution a40bdb4a-964d-4684-94e8-b148eb6bcfc2
- Verify AgentExecutor (node 767682f5-694f-4b2a-bf52-fbdcad6a4a4f)
executes successfully
   - Verify execution completes with high correctness score (not 0.20)
   - Monitor for any regressions in existing graphs

### Files Changed

- `backend/blocks/smart_decision_maker.py`: Remove double sanitization,
add agent field_mapping

### Related Issues

- Resolves execution failure a40bdb4a-964d-4684-94e8-b148eb6bcfc2
- Fixes bug introduced in commit 536e2a5ec (Nov 12, 2025)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved field name mapping consistency in the SmartDecisionMaker
block to ensure proper handling of field names throughout function
signatures and tool execution workflows.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 02:08:12 +07:00
Bentlybro
fc8434fb30 Merge branch 'master' into dev 2026-01-07 12:02:15 +00:00
Ubbe
3ae08cd48e feat(frontend): use Google Drive Picker on new builder (#11702)
## Changes 🏗️

<img width="600" height="960" alt="Screenshot 2026-01-06 at 17 40 23"
src="https://github.com/user-attachments/assets/61085ec5-a367-45c7-acaa-e3fc0f0af647"
/>

- So when using Google Blocks on the new builder, it shows Google Drive
Picket 🏁

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
  - [x] Run app locally and test the above


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a Google Drive picker field and widget for forms with an
always-visible remove button and improved single/multi selection
handling.

* **Bug Fixes**
* Better validation and normalization of selected files and consolidated
error messaging.
* Adjusted layout spacing around the picker and selected files for
clearer display.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-07 17:07:09 +07:00
Swifty
4db13837b9 Revert "extracted frontend changes out of the hackathon/copilot branch"
This reverts commit df87867625.
2026-01-07 09:27:25 +01:00
Swifty
df87867625 extracted frontend changes out of the hackathon/copilot branch 2026-01-07 09:25:10 +01:00
Ubbe
4a7bc006a8 hotfix(frontend): chat should be disabled by default (#11639)
### Changes 🏗️

Chat should be disabled by default; otherwise, it flashes, and if Launch
Darkly fails to fail, it is dangerous.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Run locally with Launch Darkly disabled and test the above
2025-12-18 19:04:13 +01:00
98 changed files with 1749 additions and 10376 deletions

View File

@@ -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",

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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({

View File

@@ -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";

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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();

View File

@@ -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>
);
}

View File

@@ -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 = {

View File

@@ -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";

View File

@@ -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>>;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
}
};
}

View File

@@ -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;
}
}

View File

@@ -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 = [];
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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;
}

View File

@@ -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",
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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,
};
}

View File

@@ -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 })),
}));

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 &quot;{credentialToDelete?.title}
&quot;? 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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")
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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">&quot;{value}&quot;</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">
&quot;{objKey}&quot;:
</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>
);
}

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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",

View File

@@ -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,
};
}

View File

@@ -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}
/>
);
}

View File

@@ -0,0 +1 @@
export { GoogleDrivePickerWidget } from "./GoogleDrivePicketWidget";

View File

@@ -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";