mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'dev' into otto/secrt-2035-thinking-indicator-between-streams
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { BookOpenIcon, PencilSimpleIcon } from "@phosphor-icons/react";
|
||||
import Image from "next/image";
|
||||
import sparklesImg from "../MiniGame/assets/sparkles.png";
|
||||
|
||||
interface Props {
|
||||
agentName: string;
|
||||
message: string;
|
||||
libraryAgentLink: string;
|
||||
agentPageLink: string;
|
||||
}
|
||||
|
||||
export function AgentSavedCard({
|
||||
agentName,
|
||||
message,
|
||||
libraryAgentLink,
|
||||
agentPageLink,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Image
|
||||
src={sparklesImg}
|
||||
alt="sparkles"
|
||||
width={24}
|
||||
height={24}
|
||||
className="relative top-1"
|
||||
/>
|
||||
<Text variant="body-medium" className="mb-2 text-[16px] text-black">
|
||||
Agent <span className="text-violet-600">{agentName}</span> {message}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
as="NextLink"
|
||||
href={libraryAgentLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpenIcon size={14} weight="regular" />
|
||||
Open in library
|
||||
</Button>
|
||||
<Button
|
||||
as="NextLink"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
href={agentPageLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<PencilSimpleIcon size={14} weight="regular" />
|
||||
Open in builder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,13 +32,16 @@ import { ViewAgentOutputTool } from "../../tools/ViewAgentOutput/ViewAgentOutput
|
||||
|
||||
/**
|
||||
* Resolve workspace:// URLs in markdown text to proxy download URLs.
|
||||
* Detects MIME type from the hash fragment (e.g. workspace://id#video/mp4)
|
||||
* and prefixes the alt text with "video:" so the custom img component can
|
||||
* render a <video> element instead.
|
||||
*
|
||||
* Handles both image syntax `` and regular link
|
||||
* syntax `[text](workspace://id)`. For images the MIME type hash fragment is
|
||||
* inspected so that videos can be rendered with a `<video>` element via the
|
||||
* custom img component.
|
||||
*/
|
||||
function resolveWorkspaceUrls(text: string): string {
|
||||
return text.replace(
|
||||
/!\[([^\]]*)\]\(workspace:\/\/([^)#\s]+)(?:#([^)\s]*))?\)/g,
|
||||
// Handle image links: 
|
||||
let resolved = text.replace(
|
||||
/!\[([^\]]*)\]\(workspace:\/\/([^)#\s]+)(?:#([^)#\s]*))?\)/g,
|
||||
(_match, alt: string, fileId: string, mimeHint?: string) => {
|
||||
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
|
||||
const url = `/api/proxy${apiPath}`;
|
||||
@@ -48,6 +51,21 @@ function resolveWorkspaceUrls(text: string): string {
|
||||
return ``;
|
||||
},
|
||||
);
|
||||
|
||||
// Handle regular links: [text](workspace://id) — without the leading "!"
|
||||
// These are blocked by Streamdown's rehype-harden sanitizer because
|
||||
// "workspace://" is not in the allowed URL-scheme whitelist, which causes
|
||||
// "[blocked]" to appear next to the link text.
|
||||
resolved = resolved.replace(
|
||||
/(?<!!)\[([^\]]*)\]\(workspace:\/\/([^)#\s]+)(?:#[^)#\s]*)?\)/g,
|
||||
(_match, linkText: string, fileId: string) => {
|
||||
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
|
||||
const url = `/api/proxy${apiPath}`;
|
||||
return `[${linkText || "Download file"}](${url})`;
|
||||
},
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { WarningDiamondIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface Props {
|
||||
message?: string;
|
||||
fallbackMessage: string;
|
||||
error?: string;
|
||||
details?: string;
|
||||
actions: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "outline" | "ghost";
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ToolErrorCard({
|
||||
message,
|
||||
fallbackMessage,
|
||||
error,
|
||||
details,
|
||||
actions,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<WarningDiamondIcon
|
||||
size={20}
|
||||
weight="regular"
|
||||
className="mt-0.5 shrink-0 text-red-500"
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Text variant="body-medium" className="text-red-900">
|
||||
{message || fallbackMessage}
|
||||
</Text>
|
||||
{error && (
|
||||
<details className="text-xs text-red-700">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2">
|
||||
{error}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{details && (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2 text-xs text-red-700">
|
||||
{details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-3">
|
||||
{actions.map((action, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={action.variant ?? "outline"}
|
||||
size="small"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -921,26 +921,29 @@ export default function StyleguidePage() {
|
||||
output: {
|
||||
type: ResponseType.agent_details,
|
||||
agent: {
|
||||
id: "agent-yt-1",
|
||||
name: "YouTube Summarizer",
|
||||
description:
|
||||
"Summarizes YouTube videos into key points.",
|
||||
inputs: [
|
||||
{
|
||||
name: "video_url",
|
||||
title: "Video URL",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "The YouTube video URL to summarize",
|
||||
inputs: {
|
||||
type: "object",
|
||||
properties: {
|
||||
video_url: {
|
||||
type: "string",
|
||||
title: "Video URL",
|
||||
description: "The YouTube video URL to summarize",
|
||||
default: "https://youtube.com/watch?v=example",
|
||||
},
|
||||
language: {
|
||||
type: "string",
|
||||
title: "Output Language",
|
||||
description:
|
||||
"Language for the summary (default: English)",
|
||||
default: "English",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "language",
|
||||
title: "Output Language",
|
||||
type: "string",
|
||||
required: false,
|
||||
description:
|
||||
"Language for the summary (default: English)",
|
||||
},
|
||||
],
|
||||
required: ["video_url"],
|
||||
},
|
||||
},
|
||||
message: "This agent requires inputs to run.",
|
||||
},
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
BookOpenIcon,
|
||||
PencilSimpleIcon,
|
||||
WarningDiamondIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import Image from "next/image";
|
||||
import NextLink from "next/link";
|
||||
import { AgentSavedCard } from "../../components/AgentSavedCard/AgentSavedCard";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { ToolErrorCard } from "../../components/ToolErrorCard/ToolErrorCard";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import {
|
||||
ContentCardDescription,
|
||||
@@ -20,11 +13,7 @@ import {
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
ClarificationQuestionsCard,
|
||||
ClarifyingQuestion,
|
||||
} from "./components/ClarificationQuestionsCard";
|
||||
import sparklesImg from "../../components/MiniGame/assets/sparkles.png";
|
||||
import { ClarificationQuestionsCard } from "./components/ClarificationQuestionsCard";
|
||||
import { MiniGame } from "../../components/MiniGame/MiniGame";
|
||||
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
|
||||
import {
|
||||
@@ -39,6 +28,7 @@ import {
|
||||
isSuggestedGoalOutput,
|
||||
ToolIcon,
|
||||
truncateText,
|
||||
normalizeClarifyingQuestions,
|
||||
type CreateAgentToolOutput,
|
||||
} from "./helpers";
|
||||
|
||||
@@ -66,9 +56,6 @@ function getAccordionMeta(output: CreateAgentToolOutput | null) {
|
||||
};
|
||||
}
|
||||
|
||||
if (isAgentSavedOutput(output)) {
|
||||
return { icon, title: output.agent_name, expanded: true };
|
||||
}
|
||||
if (isAgentPreviewOutput(output)) {
|
||||
return {
|
||||
icon,
|
||||
@@ -92,13 +79,7 @@ function getAccordionMeta(output: CreateAgentToolOutput | null) {
|
||||
expanded: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: (
|
||||
<WarningDiamondIcon size={32} weight="light" className="text-red-500" />
|
||||
),
|
||||
title: "Error",
|
||||
titleClassName: "text-red-500",
|
||||
};
|
||||
return { icon, title: "" };
|
||||
}
|
||||
|
||||
export function CreateAgentTool({ part }: Props) {
|
||||
@@ -154,154 +135,79 @@ export function CreateAgentTool({ part }: Props) {
|
||||
)}
|
||||
|
||||
{isError && output && isErrorOutput(output) && (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<WarningDiamondIcon
|
||||
size={20}
|
||||
weight="regular"
|
||||
className="mt-0.5 shrink-0 text-red-500"
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Text variant="body-medium" className="text-red-900">
|
||||
{output.message ||
|
||||
"Failed to generate the agent. Please try again."}
|
||||
</Text>
|
||||
{output.error && (
|
||||
<details className="text-xs text-red-700">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2">
|
||||
{formatMaybeJson(output.error)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{output.details && (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2 text-xs text-red-700">
|
||||
{formatMaybeJson(output.details)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => onSend("Please try creating the agent again.")}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => onSend("Can you help me simplify this goal?")}
|
||||
>
|
||||
Simplify goal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolErrorCard
|
||||
message={output.message}
|
||||
fallbackMessage="Failed to generate the agent. Please try again."
|
||||
error={output.error ? formatMaybeJson(output.error) : undefined}
|
||||
details={output.details ? formatMaybeJson(output.details) : undefined}
|
||||
actions={[
|
||||
{
|
||||
label: "Try again",
|
||||
onClick: () => onSend("Please try creating the agent again."),
|
||||
},
|
||||
{
|
||||
label: "Simplify goal",
|
||||
variant: "ghost",
|
||||
onClick: () => onSend("Can you help me simplify this goal?"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasExpandableContent && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isOperating && (
|
||||
<ContentGrid>
|
||||
<MiniGame />
|
||||
<ContentHint>
|
||||
This could take a few minutes — play while you wait!
|
||||
</ContentHint>
|
||||
</ContentGrid>
|
||||
)}
|
||||
{hasExpandableContent &&
|
||||
!(output && isClarificationNeededOutput(output)) &&
|
||||
!(output && isAgentSavedOutput(output)) && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isOperating && (
|
||||
<ContentGrid>
|
||||
<MiniGame />
|
||||
<ContentHint>
|
||||
This could take a few minutes — play while you wait!
|
||||
</ContentHint>
|
||||
</ContentGrid>
|
||||
)}
|
||||
|
||||
{output && isAgentSavedOutput(output) && (
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Image
|
||||
src={sparklesImg}
|
||||
alt="sparkles"
|
||||
width={24}
|
||||
height={24}
|
||||
className="relative top-1"
|
||||
/>
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="mb-2 text-[16px] text-black"
|
||||
>
|
||||
Agent{" "}
|
||||
<span className="text-violet-600">{output.agent_name}</span>{" "}
|
||||
has been saved to your library!
|
||||
</Text>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-4">
|
||||
<Button variant="outline" size="small">
|
||||
<NextLink
|
||||
href={output.library_agent_link}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpenIcon size={14} weight="regular" />
|
||||
Open in library
|
||||
</NextLink>
|
||||
</Button>
|
||||
<Button variant="outline" size="small">
|
||||
<NextLink
|
||||
href={output.agent_page_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<PencilSimpleIcon size={14} weight="regular" />
|
||||
Open in builder
|
||||
</NextLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{output && isAgentPreviewOutput(output) && (
|
||||
<ContentGrid>
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
{output.description?.trim() && (
|
||||
<ContentCardDescription>
|
||||
{output.description}
|
||||
</ContentCardDescription>
|
||||
)}
|
||||
<ContentCodeBlock>
|
||||
{truncateText(formatMaybeJson(output.agent_json), 1600)}
|
||||
</ContentCodeBlock>
|
||||
</ContentGrid>
|
||||
)}
|
||||
|
||||
{output && isAgentPreviewOutput(output) && (
|
||||
<ContentGrid>
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
{output.description?.trim() && (
|
||||
<ContentCardDescription>
|
||||
{output.description}
|
||||
</ContentCardDescription>
|
||||
)}
|
||||
<ContentCodeBlock>
|
||||
{truncateText(formatMaybeJson(output.agent_json), 1600)}
|
||||
</ContentCodeBlock>
|
||||
</ContentGrid>
|
||||
)}
|
||||
{output && isSuggestedGoalOutput(output) && (
|
||||
<SuggestedGoalCard
|
||||
message={output.message}
|
||||
suggestedGoal={output.suggested_goal}
|
||||
reason={output.reason}
|
||||
goalType={output.goal_type ?? "vague"}
|
||||
onUseSuggestedGoal={handleUseSuggestedGoal}
|
||||
/>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
|
||||
{output && isClarificationNeededOutput(output) && (
|
||||
<ClarificationQuestionsCard
|
||||
questions={(output.questions ?? []).map((q) => {
|
||||
const item: ClarifyingQuestion = {
|
||||
question: q.question,
|
||||
keyword: q.keyword,
|
||||
};
|
||||
const example =
|
||||
typeof q.example === "string" && q.example.trim()
|
||||
? q.example.trim()
|
||||
: null;
|
||||
if (example) item.example = example;
|
||||
return item;
|
||||
})}
|
||||
message={output.message}
|
||||
onSubmitAnswers={handleClarificationAnswers}
|
||||
/>
|
||||
)}
|
||||
{output && isAgentSavedOutput(output) && (
|
||||
<AgentSavedCard
|
||||
agentName={output.agent_name}
|
||||
message="has been saved to your library!"
|
||||
libraryAgentLink={output.library_agent_link}
|
||||
agentPageLink={output.agent_page_link}
|
||||
/>
|
||||
)}
|
||||
|
||||
{output && isSuggestedGoalOutput(output) && (
|
||||
<SuggestedGoalCard
|
||||
message={output.message}
|
||||
suggestedGoal={output.suggested_goal}
|
||||
reason={output.reason}
|
||||
goalType={output.goal_type ?? "vague"}
|
||||
onUseSuggestedGoal={handleUseSuggestedGoal}
|
||||
/>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
{output && isClarificationNeededOutput(output) && (
|
||||
<ClarificationQuestionsCard
|
||||
questions={normalizeClarifyingQuestions(output.questions ?? [])}
|
||||
message={output.message}
|
||||
onSubmitAnswers={handleClarificationAnswers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,9 @@ import { Card } from "@/components/atoms/Card/Card";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircleIcon, QuestionIcon } from "@phosphor-icons/react";
|
||||
import { ChatTeardropDotsIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface ClarifyingQuestion {
|
||||
question: string;
|
||||
keyword: string;
|
||||
example?: string;
|
||||
}
|
||||
import type { ClarifyingQuestion } from "../helpers";
|
||||
|
||||
interface Props {
|
||||
questions: ClarifyingQuestion[];
|
||||
@@ -133,29 +128,26 @@ export function ClarificationQuestionsCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex w-full justify-start gap-3 px-4 py-3",
|
||||
"group relative flex w-full justify-start gap-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">
|
||||
<QuestionIcon className="h-4 w-4 text-indigo-50" weight="bold" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Card className="space-y-4 p-4">
|
||||
<Card className="space-y-6 p-8">
|
||||
<div>
|
||||
<Text variant="h4" className="mb-1 text-slate-900">
|
||||
I need more information
|
||||
</Text>
|
||||
<Text variant="small" className="text-slate-600">
|
||||
<div className="flex gap-3">
|
||||
<ChatTeardropDotsIcon className="size-6" />
|
||||
<Text variant="h4" className="mb-1 text-slate-900">
|
||||
I need more information
|
||||
</Text>
|
||||
</div>
|
||||
<Text variant="body" className="text-slate-600">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-6">
|
||||
{questions.map((q, index) => {
|
||||
const isAnswered = !!answers[q.keyword]?.trim();
|
||||
|
||||
@@ -163,34 +155,34 @@ export function ClarificationQuestionsCard({
|
||||
<div
|
||||
key={`${q.keyword}-${index}`}
|
||||
className={cn(
|
||||
"relative rounded-lg border p-3",
|
||||
"relative rounded-lg border border-dotted p-3",
|
||||
isAnswered
|
||||
? "border-green-500 bg-green-50/50"
|
||||
: "border-slate-200 bg-white/50",
|
||||
: "border-slate-100 bg-slate-50/50",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
{isAnswered ? (
|
||||
<CheckCircleIcon
|
||||
size={16}
|
||||
size={20}
|
||||
className="mt-0.5 text-green-500"
|
||||
weight="bold"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-0.5 flex h-4 w-4 items-center justify-center rounded-full border border-slate-300 bg-white text-xs text-slate-500">
|
||||
<div className="mt-0 flex h-6 w-6 items-center justify-center rounded-full border border-slate-300 font-mono">
|
||||
{index + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Text
|
||||
variant="small"
|
||||
variant="h5"
|
||||
className="mb-2 font-semibold text-slate-900"
|
||||
>
|
||||
{q.question}
|
||||
</Text>
|
||||
{q.example && (
|
||||
<Text
|
||||
variant="small"
|
||||
variant="body"
|
||||
className="mb-2 italic text-slate-500"
|
||||
>
|
||||
Example: {q.example}
|
||||
@@ -215,11 +207,11 @@ export function ClarificationQuestionsCard({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex max-w-[25rem] gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allAnswered}
|
||||
className="flex-1"
|
||||
className="w-auto flex-1"
|
||||
variant="primary"
|
||||
>
|
||||
Submit Answers
|
||||
|
||||
@@ -157,3 +157,41 @@ export function truncateText(text: string, maxChars: number): string {
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
return `${trimmed.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export interface ClarifyingQuestion {
|
||||
question: string;
|
||||
keyword: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
export function normalizeClarifyingQuestions(
|
||||
questions: Array<{ question: string; keyword: string; example?: unknown }>,
|
||||
): ClarifyingQuestion[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return questions.map((q, index) => {
|
||||
let keyword = q.keyword?.trim().toLowerCase() || "";
|
||||
if (!keyword) {
|
||||
keyword = `question-${index}`;
|
||||
}
|
||||
|
||||
let unique = keyword;
|
||||
let suffix = 1;
|
||||
while (seen.has(unique)) {
|
||||
unique = `${keyword}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
seen.add(unique);
|
||||
|
||||
const item: ClarifyingQuestion = {
|
||||
question: q.question,
|
||||
keyword: unique,
|
||||
};
|
||||
const example =
|
||||
typeof q.example === "string" && q.example.trim()
|
||||
? q.example.trim()
|
||||
: null;
|
||||
if (example) item.example = example;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
BookOpenIcon,
|
||||
PencilSimpleIcon,
|
||||
WarningDiamondIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import Image from "next/image";
|
||||
import NextLink from "next/link";
|
||||
import { AgentSavedCard } from "../../components/AgentSavedCard/AgentSavedCard";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import sparklesImg from "../../components/MiniGame/assets/sparkles.png";
|
||||
import { ToolErrorCard } from "../../components/ToolErrorCard/ToolErrorCard";
|
||||
import { MiniGame } from "../../components/MiniGame/MiniGame";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import {
|
||||
@@ -22,10 +14,8 @@ import {
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
ClarificationQuestionsCard,
|
||||
ClarifyingQuestion,
|
||||
} from "../CreateAgent/components/ClarificationQuestionsCard";
|
||||
import { ClarificationQuestionsCard } from "../CreateAgent/components/ClarificationQuestionsCard";
|
||||
import { normalizeClarifyingQuestions } from "../CreateAgent/helpers";
|
||||
import {
|
||||
AccordionIcon,
|
||||
formatMaybeJson,
|
||||
@@ -69,9 +59,6 @@ function getAccordionMeta(output: EditAgentToolOutput | null): {
|
||||
};
|
||||
}
|
||||
|
||||
if (isAgentSavedOutput(output)) {
|
||||
return { icon, title: output.agent_name, expanded: true };
|
||||
}
|
||||
if (isAgentPreviewOutput(output)) {
|
||||
return {
|
||||
icon,
|
||||
@@ -87,13 +74,7 @@ function getAccordionMeta(output: EditAgentToolOutput | null): {
|
||||
description: `${questions.length} question${questions.length === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: (
|
||||
<WarningDiamondIcon size={32} weight="light" className="text-red-500" />
|
||||
),
|
||||
title: "Error",
|
||||
titleClassName: "text-red-500",
|
||||
};
|
||||
return { icon, title: "" };
|
||||
}
|
||||
|
||||
export function EditAgentTool({ part }: Props) {
|
||||
@@ -143,135 +124,64 @@ export function EditAgentTool({ part }: Props) {
|
||||
)}
|
||||
|
||||
{isError && output && isErrorOutput(output) && (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<WarningDiamondIcon
|
||||
size={20}
|
||||
weight="regular"
|
||||
className="mt-0.5 shrink-0 text-red-500"
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Text variant="body-medium" className="text-red-900">
|
||||
{output.message ||
|
||||
"Failed to edit the agent. Please try again."}
|
||||
</Text>
|
||||
{output.error && (
|
||||
<details className="text-xs text-red-700">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2">
|
||||
{formatMaybeJson(output.error)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{output.details && (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2 text-xs text-red-700">
|
||||
{formatMaybeJson(output.details)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => onSend("Please try editing the agent again.")}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
<ToolErrorCard
|
||||
message={output.message}
|
||||
fallbackMessage="Failed to edit the agent. Please try again."
|
||||
error={output.error ? formatMaybeJson(output.error) : undefined}
|
||||
details={output.details ? formatMaybeJson(output.details) : undefined}
|
||||
actions={[
|
||||
{
|
||||
label: "Try again",
|
||||
onClick: () => onSend("Please try editing the agent again."),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasExpandableContent && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isOperating && (
|
||||
<ContentGrid>
|
||||
<MiniGame />
|
||||
<ContentHint>
|
||||
This could take a few minutes — play while you wait!
|
||||
</ContentHint>
|
||||
</ContentGrid>
|
||||
)}
|
||||
{hasExpandableContent &&
|
||||
!(output && isClarificationNeededOutput(output)) &&
|
||||
!(output && isAgentSavedOutput(output)) && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isOperating && (
|
||||
<ContentGrid>
|
||||
<MiniGame />
|
||||
<ContentHint>
|
||||
This could take a few minutes — play while you wait!
|
||||
</ContentHint>
|
||||
</ContentGrid>
|
||||
)}
|
||||
|
||||
{output && isAgentSavedOutput(output) && (
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Image
|
||||
src={sparklesImg}
|
||||
alt="sparkles"
|
||||
width={24}
|
||||
height={24}
|
||||
className="relative top-1"
|
||||
/>
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="mb-2 text-[16px] text-black"
|
||||
>
|
||||
Agent{" "}
|
||||
<span className="text-violet-600">{output.agent_name}</span>{" "}
|
||||
has been updated!
|
||||
</Text>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-4">
|
||||
<Button variant="outline" size="small">
|
||||
<NextLink
|
||||
href={output.library_agent_link}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpenIcon size={14} weight="regular" />
|
||||
Open in library
|
||||
</NextLink>
|
||||
</Button>
|
||||
<Button variant="outline" size="small">
|
||||
<NextLink
|
||||
href={output.agent_page_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<PencilSimpleIcon size={14} weight="regular" />
|
||||
Open in builder
|
||||
</NextLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{output && isAgentPreviewOutput(output) && (
|
||||
<ContentGrid>
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
{output.description?.trim() && (
|
||||
<ContentCardDescription>
|
||||
{output.description}
|
||||
</ContentCardDescription>
|
||||
)}
|
||||
<ContentCodeBlock>
|
||||
{truncateText(formatMaybeJson(output.agent_json), 1600)}
|
||||
</ContentCodeBlock>
|
||||
</ContentGrid>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
|
||||
{output && isAgentPreviewOutput(output) && (
|
||||
<ContentGrid>
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
{output.description?.trim() && (
|
||||
<ContentCardDescription>
|
||||
{output.description}
|
||||
</ContentCardDescription>
|
||||
)}
|
||||
<ContentCodeBlock>
|
||||
{truncateText(formatMaybeJson(output.agent_json), 1600)}
|
||||
</ContentCodeBlock>
|
||||
</ContentGrid>
|
||||
)}
|
||||
{output && isAgentSavedOutput(output) && (
|
||||
<AgentSavedCard
|
||||
agentName={output.agent_name}
|
||||
message="has been updated!"
|
||||
libraryAgentLink={output.library_agent_link}
|
||||
agentPageLink={output.agent_page_link}
|
||||
/>
|
||||
)}
|
||||
|
||||
{output && isClarificationNeededOutput(output) && (
|
||||
<ClarificationQuestionsCard
|
||||
questions={(output.questions ?? []).map((q) => {
|
||||
const item: ClarifyingQuestion = {
|
||||
question: q.question,
|
||||
keyword: q.keyword,
|
||||
};
|
||||
const example =
|
||||
typeof q.example === "string" && q.example.trim()
|
||||
? q.example.trim()
|
||||
: null;
|
||||
if (example) item.example = example;
|
||||
return item;
|
||||
})}
|
||||
message={output.message}
|
||||
onSubmitAnswers={handleClarificationAnswers}
|
||||
/>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
{output && isClarificationNeededOutput(output) && (
|
||||
<ClarificationQuestionsCard
|
||||
questions={normalizeClarifyingQuestions(output.questions ?? [])}
|
||||
message={output.message}
|
||||
onSubmitAnswers={handleClarificationAnswers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import type { AgentDetailsResponse } from "@/app/api/__generated__/models/agentDetailsResponse";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { ContentMessage } from "../../../../components/ToolAccordion/AccordionContent";
|
||||
import { buildInputSchema } from "./helpers";
|
||||
import { buildInputSchema, extractDefaults, isFormValid } from "./helpers";
|
||||
|
||||
interface Props {
|
||||
output: AgentDetailsResponse;
|
||||
@@ -16,16 +14,25 @@ interface Props {
|
||||
|
||||
export function AgentDetailsCard({ output }: Props) {
|
||||
const { onSend } = useCopilotChatActions();
|
||||
const [showInputForm, setShowInputForm] = useState(false);
|
||||
const [inputValues, setInputValues] = useState<Record<string, unknown>>({});
|
||||
const schema = buildInputSchema(output.agent.inputs);
|
||||
|
||||
function handleRunWithExamples() {
|
||||
onSend(
|
||||
`Run the agent "${output.agent.name}" with placeholder/example values so I can test it.`,
|
||||
);
|
||||
const defaults = schema ? extractDefaults(schema) : {};
|
||||
|
||||
const [inputValues, setInputValues] =
|
||||
useState<Record<string, unknown>>(defaults);
|
||||
const [valid, setValid] = useState(() =>
|
||||
schema ? isFormValid(schema, defaults) : false,
|
||||
);
|
||||
|
||||
function handleChange(v: { formData?: Record<string, unknown> }) {
|
||||
const data = v.formData ?? {};
|
||||
setInputValues(data);
|
||||
if (schema) {
|
||||
setValid(isFormValid(schema, data));
|
||||
}
|
||||
}
|
||||
|
||||
function handleRunWithInputs() {
|
||||
function handleProceed() {
|
||||
const nonEmpty = Object.fromEntries(
|
||||
Object.entries(inputValues).filter(
|
||||
([, v]) => v !== undefined && v !== null && v !== "",
|
||||
@@ -34,83 +41,61 @@ export function AgentDetailsCard({ output }: Props) {
|
||||
onSend(
|
||||
`Run the agent "${output.agent.name}" with these inputs: ${JSON.stringify(nonEmpty, null, 2)}`,
|
||||
);
|
||||
setShowInputForm(false);
|
||||
setInputValues({});
|
||||
}
|
||||
|
||||
if (!schema) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<ContentMessage>This agent has no configurable inputs.</ContentMessage>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
size="small"
|
||||
className="w-fit"
|
||||
onClick={() =>
|
||||
onSend(
|
||||
`Run the agent "${output.agent.name}" with placeholder/example values so I can test it.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<ContentMessage>
|
||||
Run this agent with example values or your own inputs.
|
||||
Review the inputs below and press Proceed to run.
|
||||
</ContentMessage>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button size="small" className="w-fit" onClick={handleRunWithExamples}>
|
||||
Run with example values
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
className="w-fit"
|
||||
onClick={() => setShowInputForm((prev) => !prev)}
|
||||
>
|
||||
Run with my inputs
|
||||
</Button>
|
||||
<div className="mt-2 rounded-2xl border bg-background p-3 pt-4">
|
||||
<FormRenderer
|
||||
jsonSchema={schema}
|
||||
handleChange={handleChange}
|
||||
uiSchema={{
|
||||
"ui:submitButtonOptions": { norender: true },
|
||||
}}
|
||||
initialValues={inputValues}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "small",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{showInputForm && buildInputSchema(output.agent.inputs) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0, filter: "blur(6px)" }}
|
||||
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ height: 0, opacity: 0, filter: "blur(6px)" }}
|
||||
transition={{
|
||||
height: { type: "spring", bounce: 0.15, duration: 0.5 },
|
||||
opacity: { duration: 0.25 },
|
||||
filter: { duration: 0.2 },
|
||||
}}
|
||||
className="overflow-hidden"
|
||||
style={{ willChange: "height, opacity, filter" }}
|
||||
>
|
||||
<div className="mt-4 rounded-2xl border bg-background p-3 pt-4">
|
||||
<Text variant="body-medium">Enter your inputs</Text>
|
||||
<FormRenderer
|
||||
jsonSchema={buildInputSchema(output.agent.inputs)!}
|
||||
handleChange={(v) => setInputValues(v.formData ?? {})}
|
||||
uiSchema={{
|
||||
"ui:submitButtonOptions": { norender: true },
|
||||
}}
|
||||
initialValues={inputValues}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "small",
|
||||
}}
|
||||
/>
|
||||
<div className="-mt-8 flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="w-fit"
|
||||
onClick={handleRunWithInputs}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="w-fit"
|
||||
onClick={() => {
|
||||
setShowInputForm(false);
|
||||
setInputValues({});
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="w-fit"
|
||||
disabled={!valid}
|
||||
onClick={handleProceed}
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RJSFSchema } from "@rjsf/utils";
|
||||
import { customValidator } from "@/components/renderers/InputRenderer/utils/custom-validator";
|
||||
|
||||
export function buildInputSchema(inputs: unknown): RJSFSchema | null {
|
||||
if (!inputs || typeof inputs !== "object") return null;
|
||||
@@ -6,3 +7,31 @@ export function buildInputSchema(inputs: unknown): RJSFSchema | null {
|
||||
if (!properties || Object.keys(properties).length === 0) return null;
|
||||
return inputs as RJSFSchema;
|
||||
}
|
||||
|
||||
export function extractDefaults(schema: RJSFSchema): Record<string, unknown> {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
const props = schema.properties;
|
||||
if (!props || typeof props !== "object") return defaults;
|
||||
|
||||
for (const [key, prop] of Object.entries(props)) {
|
||||
if (typeof prop !== "object" || prop === null) continue;
|
||||
if ("default" in prop && prop.default !== undefined) {
|
||||
defaults[key] = prop.default;
|
||||
} else if (
|
||||
"examples" in prop &&
|
||||
Array.isArray(prop.examples) &&
|
||||
prop.examples.length > 0
|
||||
) {
|
||||
defaults[key] = prop.examples[0];
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function isFormValid(
|
||||
schema: RJSFSchema,
|
||||
formData: Record<string, unknown>,
|
||||
): boolean {
|
||||
const { errors } = customValidator.validateFormData(formData, schema);
|
||||
return errors.length === 0;
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
import type { BlockDetailsResponse } from "../../helpers";
|
||||
import { BlockDetailsCard } from "./BlockDetailsCard";
|
||||
|
||||
const meta: Meta<typeof BlockDetailsCard> = {
|
||||
title: "Copilot/RunBlock/BlockDetailsCard",
|
||||
component: BlockDetailsCard,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const baseBlock: BlockDetailsResponse = {
|
||||
type: ResponseType.block_details,
|
||||
message:
|
||||
"Here are the details for the GetWeather block. Provide the required inputs to run it.",
|
||||
session_id: "session-123",
|
||||
user_authenticated: true,
|
||||
block: {
|
||||
id: "block-abc-123",
|
||||
name: "GetWeather",
|
||||
description: "Fetches current weather data for a given location.",
|
||||
inputs: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: {
|
||||
title: "Location",
|
||||
type: "string",
|
||||
description:
|
||||
"City name or coordinates (e.g. 'London' or '51.5,-0.1')",
|
||||
},
|
||||
units: {
|
||||
title: "Units",
|
||||
type: "string",
|
||||
description: "Temperature units: 'metric' or 'imperial'",
|
||||
},
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
outputs: {
|
||||
type: "object",
|
||||
properties: {
|
||||
temperature: {
|
||||
title: "Temperature",
|
||||
type: "number",
|
||||
description: "Current temperature in the requested units",
|
||||
},
|
||||
condition: {
|
||||
title: "Condition",
|
||||
type: "string",
|
||||
description: "Weather condition description (e.g. 'Sunny', 'Rain')",
|
||||
},
|
||||
},
|
||||
},
|
||||
credentials: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
output: baseBlock,
|
||||
},
|
||||
};
|
||||
|
||||
export const InputsOnly: Story = {
|
||||
args: {
|
||||
output: {
|
||||
...baseBlock,
|
||||
message: "This block requires inputs. No outputs are defined.",
|
||||
block: {
|
||||
...baseBlock.block,
|
||||
outputs: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const OutputsOnly: Story = {
|
||||
args: {
|
||||
output: {
|
||||
...baseBlock,
|
||||
message: "This block has no required inputs.",
|
||||
block: {
|
||||
...baseBlock.block,
|
||||
inputs: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyFields: Story = {
|
||||
args: {
|
||||
output: {
|
||||
...baseBlock,
|
||||
message: "Block with many input and output fields.",
|
||||
block: {
|
||||
...baseBlock.block,
|
||||
name: "SendEmail",
|
||||
description: "Sends an email via SMTP.",
|
||||
inputs: {
|
||||
type: "object",
|
||||
properties: {
|
||||
to: {
|
||||
title: "To",
|
||||
type: "string",
|
||||
description: "Recipient email address",
|
||||
},
|
||||
subject: {
|
||||
title: "Subject",
|
||||
type: "string",
|
||||
description: "Email subject line",
|
||||
},
|
||||
body: {
|
||||
title: "Body",
|
||||
type: "string",
|
||||
description: "Email body content",
|
||||
},
|
||||
cc: {
|
||||
title: "CC",
|
||||
type: "string",
|
||||
description: "CC recipients (comma-separated)",
|
||||
},
|
||||
bcc: {
|
||||
title: "BCC",
|
||||
type: "string",
|
||||
description: "BCC recipients (comma-separated)",
|
||||
},
|
||||
},
|
||||
required: ["to", "subject", "body"],
|
||||
},
|
||||
outputs: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message_id: {
|
||||
title: "Message ID",
|
||||
type: "string",
|
||||
description: "Unique ID of the sent email",
|
||||
},
|
||||
status: {
|
||||
title: "Status",
|
||||
type: "string",
|
||||
description: "Delivery status",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoFieldDescriptions: Story = {
|
||||
args: {
|
||||
output: {
|
||||
...baseBlock,
|
||||
message: "Fields without descriptions.",
|
||||
block: {
|
||||
...baseBlock.block,
|
||||
name: "SimpleBlock",
|
||||
inputs: {
|
||||
type: "object",
|
||||
properties: {
|
||||
input_a: { title: "Input A", type: "string" },
|
||||
input_b: { title: "Input B", type: "number" },
|
||||
},
|
||||
required: ["input_a"],
|
||||
},
|
||||
outputs: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { title: "Result", type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -134,7 +134,7 @@ export function SetupRequirementsCard({ output }: Props) {
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="w-fit"
|
||||
className="mt-4 w-fit"
|
||||
disabled={!canRun}
|
||||
onClick={handleRun}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { AuthCard } from "@/components/auth/AuthCard";
|
||||
import { ExpiredLinkMessage } from "@/components/auth/ExpiredLinkMessage";
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
@@ -21,18 +22,42 @@ function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [showExpiredMessage, setShowExpiredMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const error = searchParams.get("error");
|
||||
if (error) {
|
||||
toast({
|
||||
title: "Password Reset Failed",
|
||||
description: error,
|
||||
variant: "destructive",
|
||||
});
|
||||
const errorCode = searchParams.get("error_code");
|
||||
const errorDescription = searchParams.get("error_description");
|
||||
|
||||
if (error || errorCode) {
|
||||
// Check if this is an expired/used link error
|
||||
// Avoid broad checks like "invalid" which can match unrelated errors (e.g., PKCE errors)
|
||||
const descLower = errorDescription?.toLowerCase() || "";
|
||||
const isExpiredOrUsed =
|
||||
error === "link_expired" ||
|
||||
errorCode === "otp_expired" ||
|
||||
descLower.includes("expired") ||
|
||||
descLower.includes("already") ||
|
||||
descLower.includes("used");
|
||||
|
||||
if (isExpiredOrUsed) {
|
||||
setShowExpiredMessage(true);
|
||||
} else {
|
||||
// Show toast for other errors
|
||||
const errorMessage =
|
||||
errorDescription || error || "Password reset failed";
|
||||
toast({
|
||||
title: "Password Reset Failed",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all error params from URL
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete("error");
|
||||
newUrl.searchParams.delete("error_code");
|
||||
newUrl.searchParams.delete("error_description");
|
||||
router.replace(newUrl.pathname + newUrl.search);
|
||||
}
|
||||
}, [searchParams, toast, router]);
|
||||
@@ -82,6 +107,10 @@ function ResetPasswordContent() {
|
||||
[sendEmailForm, toast],
|
||||
);
|
||||
|
||||
function handleShowEmailForm() {
|
||||
setShowExpiredMessage(false);
|
||||
}
|
||||
|
||||
const onChangePassword = useCallback(
|
||||
async (data: z.infer<typeof changePasswordFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
@@ -122,6 +151,17 @@ function ResetPasswordContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show expired link message if detected
|
||||
if (showExpiredMessage && !user) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[85vh] w-full flex-col items-center justify-center">
|
||||
<AuthCard title="Reset Password">
|
||||
<ExpiredLinkMessage onRequestNewLink={handleShowEmailForm} />
|
||||
</AuthCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[85vh] w-full flex-col items-center justify-center">
|
||||
<AuthCard title="Reset Password">
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.redirect(
|
||||
`${origin}/reset-password?error=Missing verification code`,
|
||||
`${origin}/reset-password?error=${encodeURIComponent("Missing verification code")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,21 @@ export async function GET(request: NextRequest) {
|
||||
const result = await exchangePasswordResetCode(supabase, code);
|
||||
|
||||
if (!result.success) {
|
||||
// Check for expired or used link errors
|
||||
// Avoid broad checks like "invalid" which can match unrelated errors (e.g., PKCE errors)
|
||||
const errorMessage = result.error?.toLowerCase() || "";
|
||||
const isExpiredOrUsed =
|
||||
errorMessage.includes("expired") ||
|
||||
errorMessage.includes("otp_expired") ||
|
||||
errorMessage.includes("already") ||
|
||||
errorMessage.includes("used");
|
||||
|
||||
const errorParam = isExpiredOrUsed
|
||||
? "link_expired"
|
||||
: encodeURIComponent(result.error || "Password reset failed");
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${origin}/reset-password?error=${encodeURIComponent(result.error || "Password reset failed")}`,
|
||||
`${origin}/reset-password?error=${errorParam}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +48,7 @@ export async function GET(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error("Password reset callback error:", error);
|
||||
return NextResponse.redirect(
|
||||
`${origin}/reset-password?error=Password reset failed`,
|
||||
`${origin}/reset-password?error=${encodeURIComponent("Password reset failed")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const extendedButtonVariants = cva(
|
||||
primary:
|
||||
"bg-zinc-800 border-zinc-800 text-white hover:bg-zinc-900 hover:border-zinc-900 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
|
||||
secondary:
|
||||
"bg-zinc-200 border-zinc-200 text-black hover:bg-zinc-300 hover:border-zinc-300 rounded-full disabled:text-zinc-300 disabled:bg-zinc-50 disabled:border-zinc-50 disabled:opacity-1",
|
||||
"bg-zinc-100 border-zinc-100 text-black hover:bg-zinc-200 hover:border-zinc-200 rounded-full disabled:text-zinc-300 disabled:bg-zinc-50 disabled:border-zinc-50 disabled:opacity-1",
|
||||
destructive:
|
||||
"bg-red-500 border-red-500 text-white hover:bg-red-600 hover:border-red-600 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
|
||||
outline:
|
||||
|
||||
@@ -92,7 +92,7 @@ export function Input({
|
||||
className={cn(
|
||||
baseStyles,
|
||||
errorStyles,
|
||||
"-mb-1 h-auto min-h-[2.875rem] rounded-3xl",
|
||||
"-mb-1 h-auto min-h-[2.875rem] rounded-xl",
|
||||
// Size variants for textarea
|
||||
size === "small" && [
|
||||
"min-h-[2.25rem]", // 36px minimum
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from "../atoms/Button/Button";
|
||||
import { Link } from "../atoms/Link/Link";
|
||||
import { Text } from "../atoms/Text/Text";
|
||||
|
||||
interface Props {
|
||||
onRequestNewLink: () => void;
|
||||
}
|
||||
|
||||
export function ExpiredLinkMessage({ onRequestNewLink }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<Text variant="h3" className="text-center">
|
||||
Your reset password link has expired or has already been used
|
||||
</Text>
|
||||
<Text variant="body-medium" className="text-center text-muted-foreground">
|
||||
Click below to request a new password reset link.
|
||||
</Text>
|
||||
<Button variant="primary" onClick={onRequestNewLink} className="w-full">
|
||||
Request a New Link
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Text variant="small" className="text-muted-foreground">
|
||||
Already have access?
|
||||
</Text>
|
||||
<Link href="/login" variant="secondary">
|
||||
Log in here
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user