mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-09 14:25:25 -05:00
chore: re-arrange
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||
import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput";
|
||||
import { UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { LayoutGroup, motion } from "framer-motion";
|
||||
import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
@@ -8,18 +9,17 @@ import {
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { UIDataTypes, UIMessage, UITools, ToolUIPart } from "ai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks";
|
||||
import { FindAgentsTool } from "../../tools/FindAgents/FindAgents";
|
||||
import { SearchDocsTool } from "../../tools/SearchDocs/SearchDocs";
|
||||
import { RunBlockTool } from "../../tools/RunBlock/RunBlock";
|
||||
import { RunAgentTool } from "../../tools/RunAgent/RunAgent";
|
||||
import { ViewAgentOutputTool } from "../../tools/ViewAgentOutput/ViewAgentOutput";
|
||||
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CreateAgentTool } from "../../tools/CreateAgent/CreateAgent";
|
||||
import { EditAgentTool } from "../../tools/EditAgent/EditAgent";
|
||||
import { FindAgentsTool } from "../../tools/FindAgents/FindAgents";
|
||||
import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks";
|
||||
import { RunAgentTool } from "../../tools/RunAgent/RunAgent";
|
||||
import { RunBlockTool } from "../../tools/RunBlock/RunBlock";
|
||||
import { SearchDocsTool } from "../../tools/SearchDocs/SearchDocs";
|
||||
import { ViewAgentOutputTool } from "../../tools/ViewAgentOutput/ViewAgentOutput";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace media support
|
||||
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { PlusCircleIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getGreetingName,
|
||||
getInputPlaceholder,
|
||||
getQuickActions,
|
||||
} from "@/app/(platform)/copilot/helpers";
|
||||
import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { SpinnerGapIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getGreetingName,
|
||||
getInputPlaceholder,
|
||||
getQuickActions,
|
||||
} from "./helpers";
|
||||
|
||||
interface Props {
|
||||
inputLayoutId: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { User } from "@supabase/supabase-js";
|
||||
|
||||
export function getInputPlaceholder(width?: number) {
|
||||
if (!width) return "What's your role and what eats up most of your day?";
|
||||
|
||||
@@ -9,3 +11,28 @@ export function getInputPlaceholder(width?: number) {
|
||||
}
|
||||
return "What's your role and what eats up most of your day? e.g. 'I'm a recruiter and I hate...'";
|
||||
}
|
||||
|
||||
export function getQuickActions() {
|
||||
return [
|
||||
"I don't know where to start, just ask me stuff",
|
||||
"I do the same thing every week and it's killing me",
|
||||
"Help me find where I'm wasting my time",
|
||||
];
|
||||
}
|
||||
|
||||
export function getGreetingName(user?: User | null) {
|
||||
if (!user) return "there";
|
||||
const metadata = user.user_metadata as Record<string, unknown> | undefined;
|
||||
const fullName = metadata?.full_name;
|
||||
const name = metadata?.name;
|
||||
if (typeof fullName === "string" && fullName.trim()) {
|
||||
return fullName.split(" ")[0];
|
||||
}
|
||||
if (typeof name === "string" && name.trim()) {
|
||||
return name.split(" ")[0];
|
||||
}
|
||||
if (user.email) {
|
||||
return user.email.split("@")[0];
|
||||
}
|
||||
return "there";
|
||||
}
|
||||
|
||||
@@ -56,7 +56,12 @@ export function ToolAccordion({
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className={cn("truncate text-sm font-medium text-gray-800", titleClassName)}>
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm font-medium text-gray-800",
|
||||
titleClassName,
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{description && (
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
|
||||
export function useCopilotSessionId() {
|
||||
const [urlSessionId, setUrlSessionId] = useQueryState(
|
||||
"sessionId",
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
return { urlSessionId, setUrlSessionId };
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ClarificationQuestionsWidget,
|
||||
type ClarifyingQuestion as WidgetClarifyingQuestion,
|
||||
} from "@/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget";
|
||||
import { WarningDiamondIcon } from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
@@ -20,6 +16,10 @@ import {
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { useAsymptoticProgress } from "../../hooks/useAsymptoticProgress";
|
||||
import {
|
||||
ClarificationQuestionsCard,
|
||||
ClarifyingQuestion,
|
||||
} from "./components/ClarificationQuestionsCard";
|
||||
import {
|
||||
AccordionIcon,
|
||||
formatMaybeJson,
|
||||
@@ -86,7 +86,9 @@ function getAccordionMeta(output: CreateAgentToolOutput): {
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: <WarningDiamondIcon size={32} weight="light" className="text-red-500" />,
|
||||
icon: (
|
||||
<WarningDiamondIcon size={32} weight="light" className="text-red-500" />
|
||||
),
|
||||
title: "Error",
|
||||
titleClassName: "text-red-500",
|
||||
};
|
||||
@@ -195,9 +197,9 @@ export function CreateAgentTool({ part }: Props) {
|
||||
)}
|
||||
|
||||
{isClarificationNeededOutput(output) && (
|
||||
<ClarificationQuestionsWidget
|
||||
<ClarificationQuestionsCard
|
||||
questions={(output.questions ?? []).map((q) => {
|
||||
const item: WidgetClarifyingQuestion = {
|
||||
const item: ClarifyingQuestion = {
|
||||
question: q.question,
|
||||
keyword: q.keyword,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
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 { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface ClarifyingQuestion {
|
||||
question: string;
|
||||
keyword: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
questions: ClarifyingQuestion[];
|
||||
message: string;
|
||||
sessionId?: string;
|
||||
onSubmitAnswers: (answers: Record<string, string>) => void;
|
||||
onCancel?: () => void;
|
||||
isAnswered?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ClarificationQuestionsCard({
|
||||
questions,
|
||||
message,
|
||||
sessionId,
|
||||
onSubmitAnswers,
|
||||
onCancel,
|
||||
isAnswered = false,
|
||||
className,
|
||||
}: Props) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const lastSessionIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const storageKey = getStorageKey(sessionId);
|
||||
if (!storageKey) {
|
||||
setAnswers({});
|
||||
setIsSubmitted(false);
|
||||
lastSessionIdRef.current = sessionId;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as Record<string, string>;
|
||||
setAnswers(parsed);
|
||||
} else {
|
||||
setAnswers({});
|
||||
}
|
||||
setIsSubmitted(false);
|
||||
} catch {
|
||||
setAnswers({});
|
||||
setIsSubmitted(false);
|
||||
}
|
||||
lastSessionIdRef.current = sessionId;
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSessionIdRef.current !== sessionId) {
|
||||
return;
|
||||
}
|
||||
const storageKey = getStorageKey(sessionId);
|
||||
if (!storageKey) return;
|
||||
|
||||
const hasAnswers = Object.values(answers).some((v) => v.trim());
|
||||
try {
|
||||
if (hasAnswers) {
|
||||
localStorage.setItem(storageKey, JSON.stringify(answers));
|
||||
} else {
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch {}
|
||||
}, [answers, sessionId]);
|
||||
|
||||
function handleAnswerChange(keyword: string, value: string) {
|
||||
setAnswers((prev) => ({ ...prev, [keyword]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
|
||||
if (!allAnswered) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitted(true);
|
||||
onSubmitAnswers(answers);
|
||||
|
||||
const storageKey = getStorageKey(sessionId);
|
||||
try {
|
||||
if (storageKey) {
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
|
||||
|
||||
if (isAnswered || isSubmitted) {
|
||||
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-green-500">
|
||||
<CheckCircleIcon className="h-4 w-4 text-white" weight="bold" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Card className="p-4">
|
||||
<Text variant="h4" className="mb-1 text-slate-900">
|
||||
Answers submitted
|
||||
</Text>
|
||||
<Text variant="small" className="text-slate-600">
|
||||
Processing your responses...
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<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">
|
||||
<div>
|
||||
<Text variant="h4" className="mb-1 text-slate-900">
|
||||
I need more information
|
||||
</Text>
|
||||
<Text variant="small" className="text-slate-600">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{questions.map((q, index) => {
|
||||
const isAnswered = !!answers[q.keyword]?.trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${q.keyword}-${index}`}
|
||||
className={cn(
|
||||
"relative rounded-lg border p-3",
|
||||
isAnswered
|
||||
? "border-green-500 bg-green-50/50"
|
||||
: "border-slate-200 bg-white/50",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
{isAnswered ? (
|
||||
<CheckCircleIcon
|
||||
size={16}
|
||||
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">
|
||||
{index + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Text
|
||||
variant="small"
|
||||
className="mb-2 font-semibold text-slate-900"
|
||||
>
|
||||
{q.question}
|
||||
</Text>
|
||||
{q.example && (
|
||||
<Text
|
||||
variant="small"
|
||||
className="mb-2 italic text-slate-500"
|
||||
>
|
||||
Example: {q.example}
|
||||
</Text>
|
||||
)}
|
||||
<Input
|
||||
type="textarea"
|
||||
id={`clarification-${q.keyword}-${index}`}
|
||||
label={q.question}
|
||||
hideLabel
|
||||
placeholder="Your answer..."
|
||||
rows={2}
|
||||
value={answers[q.keyword] || ""}
|
||||
onChange={(e) =>
|
||||
handleAnswerChange(q.keyword, e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allAnswered}
|
||||
className="flex-1"
|
||||
variant="primary"
|
||||
>
|
||||
Submit Answers
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button onClick={onCancel} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStorageKey(sessionId?: string): string | null {
|
||||
if (!sessionId) return null;
|
||||
return `clarification_answers_${sessionId}`;
|
||||
}
|
||||
@@ -156,7 +156,9 @@ export function ToolIcon({
|
||||
isError?: boolean;
|
||||
}) {
|
||||
if (isError) {
|
||||
return <WarningDiamondIcon size={14} weight="regular" className="text-red-500" />;
|
||||
return (
|
||||
<WarningDiamondIcon size={14} weight="regular" className="text-red-500" />
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <OrbitLoader size={24} />;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { WarningDiamondIcon } from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import { ProgressBar } from "../../components/ProgressBar/ProgressBar";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
ContentCardDescription,
|
||||
ContentCodeBlock,
|
||||
@@ -14,13 +14,14 @@ import {
|
||||
ContentLink,
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { useAsymptoticProgress } from "../../hooks/useAsymptoticProgress";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import {
|
||||
ClarificationQuestionsWidget,
|
||||
type ClarifyingQuestion as WidgetClarifyingQuestion,
|
||||
} from "@/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget";
|
||||
ClarificationQuestionsCard,
|
||||
ClarifyingQuestion,
|
||||
} from "../CreateAgent/components/ClarificationQuestionsCard";
|
||||
import {
|
||||
AccordionIcon,
|
||||
formatMaybeJson,
|
||||
getAnimationText,
|
||||
getEditAgentToolOutput,
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
isOperationInProgressOutput,
|
||||
isOperationPendingOutput,
|
||||
isOperationStartedOutput,
|
||||
AccordionIcon,
|
||||
ToolIcon,
|
||||
truncateText,
|
||||
type EditAgentToolOutput,
|
||||
@@ -83,7 +83,9 @@ function getAccordionMeta(output: EditAgentToolOutput): {
|
||||
return { icon: <OrbitLoader size={32} />, title: "Editing agent" };
|
||||
}
|
||||
return {
|
||||
icon: <WarningDiamondIcon size={32} weight="light" className="text-red-500" />,
|
||||
icon: (
|
||||
<WarningDiamondIcon size={32} weight="light" className="text-red-500" />
|
||||
),
|
||||
title: "Error",
|
||||
titleClassName: "text-red-500",
|
||||
};
|
||||
@@ -192,9 +194,9 @@ export function EditAgentTool({ part }: Props) {
|
||||
)}
|
||||
|
||||
{isClarificationNeededOutput(output) && (
|
||||
<ClarificationQuestionsWidget
|
||||
<ClarificationQuestionsCard
|
||||
questions={(output.questions ?? []).map((q) => {
|
||||
const item: WidgetClarifyingQuestion = {
|
||||
const item: ClarifyingQuestion = {
|
||||
question: q.question,
|
||||
keyword: q.keyword,
|
||||
};
|
||||
|
||||
@@ -156,12 +156,16 @@ export function ToolIcon({
|
||||
isError?: boolean;
|
||||
}) {
|
||||
if (isError) {
|
||||
return <WarningDiamondIcon size={14} weight="regular" className="text-red-500" />;
|
||||
return (
|
||||
<WarningDiamondIcon size={14} weight="regular" className="text-red-500" />
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <OrbitLoader size={24} />;
|
||||
}
|
||||
return <PencilLineIcon size={14} weight="regular" className="text-neutral-400" />;
|
||||
return (
|
||||
<PencilLineIcon size={14} weight="regular" className="text-neutral-400" />
|
||||
);
|
||||
}
|
||||
|
||||
export function AccordionIcon() {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { ToolUIPart } from "ai";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
ContentBadge,
|
||||
ContentCard,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
ContentGrid,
|
||||
ContentLink,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
AccordionIcon,
|
||||
getAgentHref,
|
||||
@@ -59,7 +59,7 @@ export function FindAgentsTool({ part }: Props) {
|
||||
(typeof agentsFoundOutput.count !== "number" ||
|
||||
agentsFoundOutput.count > 0);
|
||||
const totalCount = agentsFoundOutput ? agentsFoundOutput.count : 0;
|
||||
const { label: sourceLabel, source } = getSourceLabelFromToolType(part.type);
|
||||
const { source } = getSourceLabelFromToolType(part.type);
|
||||
const scopeText =
|
||||
source === "library"
|
||||
? "in your library"
|
||||
|
||||
@@ -11,7 +11,12 @@ import type { BlockListResponse } from "@/app/api/__generated__/models/blockList
|
||||
import type { BlockInfoSummary } from "@/app/api/__generated__/models/blockInfoSummary";
|
||||
import { ToolUIPart } from "ai";
|
||||
import { HorizontalScroll } from "@/app/(platform)/build/components/NewControlPanel/NewBlockMenu/HorizontalScroll";
|
||||
import { AccordionIcon, getAnimationText, parseOutput, ToolIcon } from "./helpers";
|
||||
import {
|
||||
AccordionIcon,
|
||||
getAnimationText,
|
||||
parseOutput,
|
||||
ToolIcon,
|
||||
} from "./helpers";
|
||||
|
||||
export interface FindBlockInput {
|
||||
query: string;
|
||||
|
||||
@@ -73,9 +73,7 @@ export function AgentDetailsCard({ output }: Props) {
|
||||
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>
|
||||
<Text variant="body-medium">Enter your inputs</Text>
|
||||
<FormRenderer
|
||||
jsonSchema={buildInputSchema(output.agent.inputs)!}
|
||||
handleChange={(v) => setInputValues(v.formData ?? {})}
|
||||
|
||||
@@ -77,7 +77,9 @@ export function SetupRequirementsCard({ output }: Props) {
|
||||
|
||||
{expectedInputs.length > 0 && (
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<ContentCardTitle className="text-xs">Expected inputs</ContentCardTitle>
|
||||
<ContentCardTitle className="text-xs">
|
||||
Expected inputs
|
||||
</ContentCardTitle>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{expectedInputs.map((input) => (
|
||||
<div key={input.name} className="rounded-xl border p-2">
|
||||
|
||||
@@ -166,7 +166,9 @@ export function ToolIcon({
|
||||
isError?: boolean;
|
||||
}) {
|
||||
if (isError) {
|
||||
return <WarningDiamondIcon size={14} weight="regular" className="text-red-500" />;
|
||||
return (
|
||||
<WarningDiamondIcon size={14} weight="regular" className="text-red-500" />
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <PulseLoader size={40} className="text-neutral-700" />;
|
||||
@@ -237,7 +239,9 @@ export function getAccordionMeta(output: RunAgentToolOutput): {
|
||||
}
|
||||
|
||||
return {
|
||||
icon: <WarningDiamondIcon size={28} weight="light" className="text-red-500" />,
|
||||
icon: (
|
||||
<WarningDiamondIcon size={28} weight="light" className="text-red-500" />
|
||||
),
|
||||
title: "Error",
|
||||
titleClassName: "text-red-500",
|
||||
};
|
||||
|
||||
@@ -54,7 +54,10 @@ function resolveForRenderer(value: unknown): {
|
||||
|
||||
function RenderOutputValue({ value }: { value: unknown }) {
|
||||
const resolved = resolveForRenderer(value);
|
||||
const renderer = globalRegistry.getRenderer(resolved.value, resolved.metadata);
|
||||
const renderer = globalRegistry.getRenderer(
|
||||
resolved.value,
|
||||
resolved.metadata,
|
||||
);
|
||||
|
||||
if (renderer) {
|
||||
return (
|
||||
|
||||
@@ -115,7 +115,9 @@ export function ToolIcon({
|
||||
isError?: boolean;
|
||||
}) {
|
||||
if (isError) {
|
||||
return <WarningDiamondIcon size={14} weight="regular" className="text-red-500" />;
|
||||
return (
|
||||
<WarningDiamondIcon size={14} weight="regular" className="text-red-500" />
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <PulseLoader size={40} className="text-neutral-700" />;
|
||||
@@ -174,7 +176,9 @@ export function getAccordionMeta(output: RunBlockToolOutput): {
|
||||
}
|
||||
|
||||
return {
|
||||
icon: <WarningDiamondIcon size={32} weight="light" className="text-red-500" />,
|
||||
icon: (
|
||||
<WarningDiamondIcon size={32} weight="light" className="text-red-500" />
|
||||
),
|
||||
title: "Error",
|
||||
titleClassName: "text-red-500",
|
||||
};
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCopilotSessionId } from "@/app/(platform)/copilot/useCopilotSessionId";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
|
||||
import { useChat } from "./useChat";
|
||||
|
||||
export interface ChatProps {
|
||||
className?: string;
|
||||
initialPrompt?: string;
|
||||
onSessionNotFound?: () => void;
|
||||
onStreamingChange?: (isStreaming: boolean) => void;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
className,
|
||||
initialPrompt,
|
||||
onSessionNotFound,
|
||||
onStreamingChange,
|
||||
}: ChatProps) {
|
||||
const { urlSessionId } = useCopilotSessionId();
|
||||
const hasHandledNotFoundRef = useRef(false);
|
||||
const {
|
||||
session,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
isSessionNotFound,
|
||||
sessionId,
|
||||
createSession,
|
||||
showLoader,
|
||||
startPollingForOperation,
|
||||
} = useChat({ urlSessionId });
|
||||
|
||||
// Extract active stream info for reconnection
|
||||
const activeStream = (
|
||||
session as {
|
||||
active_stream?: {
|
||||
task_id: string;
|
||||
last_message_id: string;
|
||||
operation_id: string;
|
||||
tool_name: string;
|
||||
};
|
||||
}
|
||||
)?.active_stream;
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSessionNotFound) return;
|
||||
if (!urlSessionId) return;
|
||||
if (!isSessionNotFound || isLoading || isCreating) return;
|
||||
if (hasHandledNotFoundRef.current) return;
|
||||
hasHandledNotFoundRef.current = true;
|
||||
onSessionNotFound();
|
||||
}, [
|
||||
onSessionNotFound,
|
||||
urlSessionId,
|
||||
isSessionNotFound,
|
||||
isLoading,
|
||||
isCreating,
|
||||
]);
|
||||
|
||||
const shouldShowLoader = showLoader && (isLoading || isCreating);
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Main Content */}
|
||||
<main className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[#f8f8f9]">
|
||||
{/* Loading State */}
|
||||
{shouldShowLoader && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<LoadingSpinner size="large" className="text-neutral-400" />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chat...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<ChatErrorState error={error} onRetry={createSession} />
|
||||
)}
|
||||
|
||||
{/* Session Content */}
|
||||
{sessionId && !isLoading && !error && (
|
||||
<ChatContainer
|
||||
sessionId={sessionId}
|
||||
initialMessages={messages}
|
||||
initialPrompt={initialPrompt}
|
||||
className="flex-1"
|
||||
onStreamingChange={onStreamingChange}
|
||||
onOperationStarted={startPollingForOperation}
|
||||
activeStream={
|
||||
activeStream
|
||||
? {
|
||||
taskId: activeStream.task_id,
|
||||
lastMessageId: activeStream.last_message_id,
|
||||
operationId: activeStream.operation_id,
|
||||
toolName: activeStream.tool_name,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
# SSE Reconnection Contract for Long-Running Operations
|
||||
|
||||
This document describes the client-side contract for handling SSE (Server-Sent Events) disconnections and reconnecting to long-running background tasks.
|
||||
|
||||
## Overview
|
||||
|
||||
When a user triggers a long-running operation (like agent generation), the backend:
|
||||
|
||||
1. Spawns a background task that survives SSE disconnections
|
||||
2. Returns an `operation_started` response with a `task_id`
|
||||
3. Stores stream messages in Redis Streams for replay
|
||||
|
||||
Clients can reconnect to the task stream at any time to receive missed messages.
|
||||
|
||||
## Client-Side Flow
|
||||
|
||||
### 1. Receiving Operation Started
|
||||
|
||||
When you receive an `operation_started` tool response:
|
||||
|
||||
```typescript
|
||||
// The response includes a task_id for reconnection
|
||||
{
|
||||
type: "operation_started",
|
||||
tool_name: "generate_agent",
|
||||
operation_id: "uuid-...",
|
||||
task_id: "task-uuid-...", // <-- Store this for reconnection
|
||||
message: "Operation started. You can close this tab."
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Storing Task Info
|
||||
|
||||
Use the chat store to track the active task:
|
||||
|
||||
```typescript
|
||||
import { useChatStore } from "./chat-store";
|
||||
|
||||
// When operation_started is received:
|
||||
useChatStore.getState().setActiveTask(sessionId, {
|
||||
taskId: response.task_id,
|
||||
operationId: response.operation_id,
|
||||
toolName: response.tool_name,
|
||||
lastMessageId: "0",
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Reconnecting to a Task
|
||||
|
||||
To reconnect (e.g., after page refresh or tab reopen):
|
||||
|
||||
```typescript
|
||||
const { reconnectToTask, getActiveTask } = useChatStore.getState();
|
||||
|
||||
// Check if there's an active task for this session
|
||||
const activeTask = getActiveTask(sessionId);
|
||||
|
||||
if (activeTask) {
|
||||
// Reconnect to the task stream
|
||||
await reconnectToTask(
|
||||
sessionId,
|
||||
activeTask.taskId,
|
||||
activeTask.lastMessageId, // Resume from last position
|
||||
(chunk) => {
|
||||
// Handle incoming chunks
|
||||
console.log("Received chunk:", chunk);
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Tracking Message Position
|
||||
|
||||
To enable precise replay, update the last message ID as chunks arrive:
|
||||
|
||||
```typescript
|
||||
const { updateTaskLastMessageId } = useChatStore.getState();
|
||||
|
||||
function handleChunk(chunk: StreamChunk) {
|
||||
// If chunk has an index/id, track it
|
||||
if (chunk.idx !== undefined) {
|
||||
updateTaskLastMessageId(sessionId, String(chunk.idx));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Task Stream Reconnection
|
||||
|
||||
```
|
||||
GET /api/chat/tasks/{taskId}/stream?last_message_id={idx}
|
||||
```
|
||||
|
||||
- `taskId`: The task ID from `operation_started`
|
||||
- `last_message_id`: Last received message index (default: "0" for full replay)
|
||||
|
||||
Returns: SSE stream of missed messages + live updates
|
||||
|
||||
## Chunk Types
|
||||
|
||||
The reconnected stream follows the same Vercel AI SDK protocol:
|
||||
|
||||
| Type | Description |
|
||||
| ----------------------- | ----------------------- |
|
||||
| `start` | Message lifecycle start |
|
||||
| `text-delta` | Streaming text content |
|
||||
| `text-end` | Text block completed |
|
||||
| `tool-output-available` | Tool result available |
|
||||
| `finish` | Stream completed |
|
||||
| `error` | Error occurred |
|
||||
|
||||
## Error Handling
|
||||
|
||||
If reconnection fails:
|
||||
|
||||
1. Check if task still exists (may have expired - default TTL: 1 hour)
|
||||
2. Fall back to polling the session for final state
|
||||
3. Show appropriate UI message to user
|
||||
|
||||
## Persistence Considerations
|
||||
|
||||
For robust reconnection across browser restarts:
|
||||
|
||||
```typescript
|
||||
// Store in localStorage/sessionStorage
|
||||
const ACTIVE_TASKS_KEY = "chat_active_tasks";
|
||||
|
||||
function persistActiveTask(sessionId: string, task: ActiveTaskInfo) {
|
||||
const tasks = JSON.parse(localStorage.getItem(ACTIVE_TASKS_KEY) || "{}");
|
||||
tasks[sessionId] = task;
|
||||
localStorage.setItem(ACTIVE_TASKS_KEY, JSON.stringify(tasks));
|
||||
}
|
||||
|
||||
function loadPersistedTasks(): Record<string, ActiveTaskInfo> {
|
||||
return JSON.parse(localStorage.getItem(ACTIVE_TASKS_KEY) || "{}");
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Configuration
|
||||
|
||||
The following backend settings affect reconnection behavior:
|
||||
|
||||
| Setting | Default | Description |
|
||||
| ------------------- | ------- | ---------------------------------- |
|
||||
| `stream_ttl` | 3600s | How long streams are kept in Redis |
|
||||
| `stream_max_length` | 1000 | Max messages per stream |
|
||||
|
||||
## Testing
|
||||
|
||||
To test reconnection locally:
|
||||
|
||||
1. Start a long-running operation (e.g., agent generation)
|
||||
2. Note the `task_id` from the `operation_started` response
|
||||
3. Close the browser tab
|
||||
4. Reopen and call `reconnectToTask` with the saved `task_id`
|
||||
5. Verify that missed messages are replayed
|
||||
|
||||
See the main README for full local development setup.
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Constants for the chat system.
|
||||
*
|
||||
* Centralizes magic strings and values used across chat components.
|
||||
*/
|
||||
|
||||
// LocalStorage keys
|
||||
export const STORAGE_KEY_ACTIVE_TASKS = "chat_active_tasks";
|
||||
|
||||
// Redis Stream IDs
|
||||
export const INITIAL_MESSAGE_ID = "0";
|
||||
export const INITIAL_STREAM_ID = "0-0";
|
||||
|
||||
// TTL values (in milliseconds)
|
||||
export const COMPLETED_STREAM_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
export const ACTIVE_TASK_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
@@ -1,501 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
ACTIVE_TASK_TTL_MS,
|
||||
COMPLETED_STREAM_TTL_MS,
|
||||
INITIAL_STREAM_ID,
|
||||
STORAGE_KEY_ACTIVE_TASKS,
|
||||
} from "./chat-constants";
|
||||
import type {
|
||||
ActiveStream,
|
||||
StreamChunk,
|
||||
StreamCompleteCallback,
|
||||
StreamResult,
|
||||
StreamStatus,
|
||||
} from "./chat-types";
|
||||
import { executeStream, executeTaskReconnect } from "./stream-executor";
|
||||
|
||||
export interface ActiveTaskInfo {
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
lastMessageId: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/** Load active tasks from localStorage */
|
||||
function loadPersistedTasks(): Map<string, ActiveTaskInfo> {
|
||||
if (typeof window === "undefined") return new Map();
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_ACTIVE_TASKS);
|
||||
if (!stored) return new Map();
|
||||
const parsed = JSON.parse(stored) as Record<string, ActiveTaskInfo>;
|
||||
const now = Date.now();
|
||||
const tasks = new Map<string, ActiveTaskInfo>();
|
||||
// Filter out expired tasks
|
||||
for (const [sessionId, task] of Object.entries(parsed)) {
|
||||
if (now - task.startedAt < ACTIVE_TASK_TTL_MS) {
|
||||
tasks.set(sessionId, task);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/** Save active tasks to localStorage */
|
||||
function persistTasks(tasks: Map<string, ActiveTaskInfo>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const obj: Record<string, ActiveTaskInfo> = {};
|
||||
for (const [sessionId, task] of tasks) {
|
||||
obj[sessionId] = task;
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY_ACTIVE_TASKS, JSON.stringify(obj));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatStoreState {
|
||||
activeStreams: Map<string, ActiveStream>;
|
||||
completedStreams: Map<string, StreamResult>;
|
||||
activeSessions: Set<string>;
|
||||
streamCompleteCallbacks: Set<StreamCompleteCallback>;
|
||||
/** Active tasks for SSE reconnection - keyed by sessionId */
|
||||
activeTasks: Map<string, ActiveTaskInfo>;
|
||||
}
|
||||
|
||||
interface ChatStoreActions {
|
||||
startStream: (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
isUserMessage: boolean,
|
||||
context?: { url: string; content: string },
|
||||
onChunk?: (chunk: StreamChunk) => void,
|
||||
) => Promise<void>;
|
||||
stopStream: (sessionId: string) => void;
|
||||
subscribeToStream: (
|
||||
sessionId: string,
|
||||
onChunk: (chunk: StreamChunk) => void,
|
||||
skipReplay?: boolean,
|
||||
) => () => void;
|
||||
getStreamStatus: (sessionId: string) => StreamStatus;
|
||||
getCompletedStream: (sessionId: string) => StreamResult | undefined;
|
||||
clearCompletedStream: (sessionId: string) => void;
|
||||
isStreaming: (sessionId: string) => boolean;
|
||||
registerActiveSession: (sessionId: string) => void;
|
||||
unregisterActiveSession: (sessionId: string) => void;
|
||||
isSessionActive: (sessionId: string) => boolean;
|
||||
onStreamComplete: (callback: StreamCompleteCallback) => () => void;
|
||||
/** Track active task for SSE reconnection */
|
||||
setActiveTask: (
|
||||
sessionId: string,
|
||||
taskInfo: Omit<ActiveTaskInfo, "sessionId" | "startedAt">,
|
||||
) => void;
|
||||
/** Get active task for a session */
|
||||
getActiveTask: (sessionId: string) => ActiveTaskInfo | undefined;
|
||||
/** Clear active task when operation completes */
|
||||
clearActiveTask: (sessionId: string) => void;
|
||||
/** Reconnect to an existing task stream */
|
||||
reconnectToTask: (
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
lastMessageId?: string,
|
||||
onChunk?: (chunk: StreamChunk) => void,
|
||||
) => Promise<void>;
|
||||
/** Update last message ID for a task (for tracking replay position) */
|
||||
updateTaskLastMessageId: (sessionId: string, lastMessageId: string) => void;
|
||||
}
|
||||
|
||||
type ChatStore = ChatStoreState & ChatStoreActions;
|
||||
|
||||
function notifyStreamComplete(
|
||||
callbacks: Set<StreamCompleteCallback>,
|
||||
sessionId: string,
|
||||
) {
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(sessionId);
|
||||
} catch (err) {
|
||||
console.warn("[ChatStore] Stream complete callback error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupExpiredStreams(
|
||||
completedStreams: Map<string, StreamResult>,
|
||||
): Map<string, StreamResult> {
|
||||
const now = Date.now();
|
||||
const cleaned = new Map(completedStreams);
|
||||
for (const [sessionId, result] of cleaned) {
|
||||
if (now - result.completedAt > COMPLETED_STREAM_TTL_MS) {
|
||||
cleaned.delete(sessionId);
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a stream by moving it from activeStreams to completedStreams.
|
||||
* Also handles cleanup and notifications.
|
||||
*/
|
||||
function finalizeStream(
|
||||
sessionId: string,
|
||||
stream: ActiveStream,
|
||||
onChunk: ((chunk: StreamChunk) => void) | undefined,
|
||||
get: () => ChatStoreState & ChatStoreActions,
|
||||
set: (state: Partial<ChatStoreState>) => void,
|
||||
): void {
|
||||
if (onChunk) stream.onChunkCallbacks.delete(onChunk);
|
||||
|
||||
if (stream.status !== "streaming") {
|
||||
const currentState = get();
|
||||
const finalActiveStreams = new Map(currentState.activeStreams);
|
||||
let finalCompletedStreams = new Map(currentState.completedStreams);
|
||||
|
||||
const storedStream = finalActiveStreams.get(sessionId);
|
||||
if (storedStream === stream) {
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: stream.status,
|
||||
chunks: stream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: stream.error,
|
||||
};
|
||||
finalCompletedStreams.set(sessionId, result);
|
||||
finalActiveStreams.delete(sessionId);
|
||||
finalCompletedStreams = cleanupExpiredStreams(finalCompletedStreams);
|
||||
set({
|
||||
activeStreams: finalActiveStreams,
|
||||
completedStreams: finalCompletedStreams,
|
||||
});
|
||||
|
||||
if (stream.status === "completed" || stream.status === "error") {
|
||||
notifyStreamComplete(currentState.streamCompleteCallbacks, sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up an existing stream for a session and move it to completed streams.
|
||||
* Returns updated maps for both active and completed streams.
|
||||
*/
|
||||
function cleanupExistingStream(
|
||||
sessionId: string,
|
||||
activeStreams: Map<string, ActiveStream>,
|
||||
completedStreams: Map<string, StreamResult>,
|
||||
callbacks: Set<StreamCompleteCallback>,
|
||||
): {
|
||||
activeStreams: Map<string, ActiveStream>;
|
||||
completedStreams: Map<string, StreamResult>;
|
||||
} {
|
||||
const newActiveStreams = new Map(activeStreams);
|
||||
let newCompletedStreams = new Map(completedStreams);
|
||||
|
||||
const existingStream = newActiveStreams.get(sessionId);
|
||||
if (existingStream) {
|
||||
existingStream.abortController.abort();
|
||||
const normalizedStatus =
|
||||
existingStream.status === "streaming"
|
||||
? "completed"
|
||||
: existingStream.status;
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: normalizedStatus,
|
||||
chunks: existingStream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: existingStream.error,
|
||||
};
|
||||
newCompletedStreams.set(sessionId, result);
|
||||
newActiveStreams.delete(sessionId);
|
||||
newCompletedStreams = cleanupExpiredStreams(newCompletedStreams);
|
||||
if (normalizedStatus === "completed" || normalizedStatus === "error") {
|
||||
notifyStreamComplete(callbacks, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new active stream with initial state.
|
||||
*/
|
||||
function createActiveStream(
|
||||
sessionId: string,
|
||||
onChunk?: (chunk: StreamChunk) => void,
|
||||
): ActiveStream {
|
||||
const abortController = new AbortController();
|
||||
const initialCallbacks = new Set<(chunk: StreamChunk) => void>();
|
||||
if (onChunk) initialCallbacks.add(onChunk);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
abortController,
|
||||
status: "streaming",
|
||||
startedAt: Date.now(),
|
||||
chunks: [],
|
||||
onChunkCallbacks: initialCallbacks,
|
||||
};
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
activeStreams: new Map(),
|
||||
completedStreams: new Map(),
|
||||
activeSessions: new Set(),
|
||||
streamCompleteCallbacks: new Set(),
|
||||
activeTasks: loadPersistedTasks(),
|
||||
|
||||
startStream: async function startStream(
|
||||
sessionId,
|
||||
message,
|
||||
isUserMessage,
|
||||
context,
|
||||
onChunk,
|
||||
) {
|
||||
const state = get();
|
||||
const callbacks = state.streamCompleteCallbacks;
|
||||
|
||||
// Clean up any existing stream for this session
|
||||
const {
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
} = cleanupExistingStream(
|
||||
sessionId,
|
||||
state.activeStreams,
|
||||
state.completedStreams,
|
||||
callbacks,
|
||||
);
|
||||
|
||||
// Create new stream
|
||||
const stream = createActiveStream(sessionId, onChunk);
|
||||
newActiveStreams.set(sessionId, stream);
|
||||
set({
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
});
|
||||
|
||||
try {
|
||||
await executeStream(stream, message, isUserMessage, context);
|
||||
} finally {
|
||||
finalizeStream(sessionId, stream, onChunk, get, set);
|
||||
}
|
||||
},
|
||||
|
||||
stopStream: function stopStream(sessionId) {
|
||||
const state = get();
|
||||
const stream = state.activeStreams.get(sessionId);
|
||||
if (!stream) return;
|
||||
|
||||
stream.abortController.abort();
|
||||
stream.status = "completed";
|
||||
|
||||
const newActiveStreams = new Map(state.activeStreams);
|
||||
let newCompletedStreams = new Map(state.completedStreams);
|
||||
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: stream.status,
|
||||
chunks: stream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: stream.error,
|
||||
};
|
||||
newCompletedStreams.set(sessionId, result);
|
||||
newActiveStreams.delete(sessionId);
|
||||
newCompletedStreams = cleanupExpiredStreams(newCompletedStreams);
|
||||
|
||||
set({
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
});
|
||||
|
||||
notifyStreamComplete(state.streamCompleteCallbacks, sessionId);
|
||||
},
|
||||
|
||||
subscribeToStream: function subscribeToStream(
|
||||
sessionId,
|
||||
onChunk,
|
||||
skipReplay = false,
|
||||
) {
|
||||
const state = get();
|
||||
const stream = state.activeStreams.get(sessionId);
|
||||
|
||||
if (stream) {
|
||||
if (!skipReplay) {
|
||||
for (const chunk of stream.chunks) {
|
||||
onChunk(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
stream.onChunkCallbacks.add(onChunk);
|
||||
|
||||
return function unsubscribe() {
|
||||
stream.onChunkCallbacks.delete(onChunk);
|
||||
};
|
||||
}
|
||||
|
||||
return function noop() {};
|
||||
},
|
||||
|
||||
getStreamStatus: function getStreamStatus(sessionId) {
|
||||
const { activeStreams, completedStreams } = get();
|
||||
|
||||
const active = activeStreams.get(sessionId);
|
||||
if (active) return active.status;
|
||||
|
||||
const completed = completedStreams.get(sessionId);
|
||||
if (completed) return completed.status;
|
||||
|
||||
return "idle";
|
||||
},
|
||||
|
||||
getCompletedStream: function getCompletedStream(sessionId) {
|
||||
return get().completedStreams.get(sessionId);
|
||||
},
|
||||
|
||||
clearCompletedStream: function clearCompletedStream(sessionId) {
|
||||
const state = get();
|
||||
if (!state.completedStreams.has(sessionId)) return;
|
||||
|
||||
const newCompletedStreams = new Map(state.completedStreams);
|
||||
newCompletedStreams.delete(sessionId);
|
||||
set({ completedStreams: newCompletedStreams });
|
||||
},
|
||||
|
||||
isStreaming: function isStreaming(sessionId) {
|
||||
const stream = get().activeStreams.get(sessionId);
|
||||
return stream?.status === "streaming";
|
||||
},
|
||||
|
||||
registerActiveSession: function registerActiveSession(sessionId) {
|
||||
const state = get();
|
||||
if (state.activeSessions.has(sessionId)) return;
|
||||
|
||||
const newActiveSessions = new Set(state.activeSessions);
|
||||
newActiveSessions.add(sessionId);
|
||||
set({ activeSessions: newActiveSessions });
|
||||
},
|
||||
|
||||
unregisterActiveSession: function unregisterActiveSession(sessionId) {
|
||||
const state = get();
|
||||
if (!state.activeSessions.has(sessionId)) return;
|
||||
|
||||
const newActiveSessions = new Set(state.activeSessions);
|
||||
newActiveSessions.delete(sessionId);
|
||||
set({ activeSessions: newActiveSessions });
|
||||
},
|
||||
|
||||
isSessionActive: function isSessionActive(sessionId) {
|
||||
return get().activeSessions.has(sessionId);
|
||||
},
|
||||
|
||||
onStreamComplete: function onStreamComplete(callback) {
|
||||
const state = get();
|
||||
const newCallbacks = new Set(state.streamCompleteCallbacks);
|
||||
newCallbacks.add(callback);
|
||||
set({ streamCompleteCallbacks: newCallbacks });
|
||||
|
||||
return function unsubscribe() {
|
||||
const currentState = get();
|
||||
const cleanedCallbacks = new Set(currentState.streamCompleteCallbacks);
|
||||
cleanedCallbacks.delete(callback);
|
||||
set({ streamCompleteCallbacks: cleanedCallbacks });
|
||||
};
|
||||
},
|
||||
|
||||
setActiveTask: function setActiveTask(sessionId, taskInfo) {
|
||||
const state = get();
|
||||
const newActiveTasks = new Map(state.activeTasks);
|
||||
newActiveTasks.set(sessionId, {
|
||||
...taskInfo,
|
||||
sessionId,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
set({ activeTasks: newActiveTasks });
|
||||
persistTasks(newActiveTasks);
|
||||
},
|
||||
|
||||
getActiveTask: function getActiveTask(sessionId) {
|
||||
return get().activeTasks.get(sessionId);
|
||||
},
|
||||
|
||||
clearActiveTask: function clearActiveTask(sessionId) {
|
||||
const state = get();
|
||||
if (!state.activeTasks.has(sessionId)) return;
|
||||
|
||||
const newActiveTasks = new Map(state.activeTasks);
|
||||
newActiveTasks.delete(sessionId);
|
||||
set({ activeTasks: newActiveTasks });
|
||||
persistTasks(newActiveTasks);
|
||||
},
|
||||
|
||||
reconnectToTask: async function reconnectToTask(
|
||||
sessionId,
|
||||
taskId,
|
||||
lastMessageId = INITIAL_STREAM_ID,
|
||||
onChunk,
|
||||
) {
|
||||
const state = get();
|
||||
const callbacks = state.streamCompleteCallbacks;
|
||||
|
||||
// Clean up any existing stream for this session
|
||||
const {
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
} = cleanupExistingStream(
|
||||
sessionId,
|
||||
state.activeStreams,
|
||||
state.completedStreams,
|
||||
callbacks,
|
||||
);
|
||||
|
||||
// Create new stream for reconnection
|
||||
const stream = createActiveStream(sessionId, onChunk);
|
||||
newActiveStreams.set(sessionId, stream);
|
||||
set({
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
});
|
||||
|
||||
try {
|
||||
await executeTaskReconnect(stream, taskId, lastMessageId);
|
||||
} finally {
|
||||
finalizeStream(sessionId, stream, onChunk, get, set);
|
||||
|
||||
// Clear active task on completion
|
||||
if (stream.status === "completed" || stream.status === "error") {
|
||||
const taskState = get();
|
||||
if (taskState.activeTasks.has(sessionId)) {
|
||||
const newActiveTasks = new Map(taskState.activeTasks);
|
||||
newActiveTasks.delete(sessionId);
|
||||
set({ activeTasks: newActiveTasks });
|
||||
persistTasks(newActiveTasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updateTaskLastMessageId: function updateTaskLastMessageId(
|
||||
sessionId,
|
||||
lastMessageId,
|
||||
) {
|
||||
const state = get();
|
||||
const task = state.activeTasks.get(sessionId);
|
||||
if (!task) return;
|
||||
|
||||
const newActiveTasks = new Map(state.activeTasks);
|
||||
newActiveTasks.set(sessionId, {
|
||||
...task,
|
||||
lastMessageId,
|
||||
});
|
||||
set({ activeTasks: newActiveTasks });
|
||||
persistTasks(newActiveTasks);
|
||||
},
|
||||
}));
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { ToolArguments, ToolResult } from "@/types/chat";
|
||||
|
||||
export type StreamStatus = "idle" | "streaming" | "completed" | "error";
|
||||
|
||||
export interface StreamChunk {
|
||||
type:
|
||||
| "stream_start"
|
||||
| "text_chunk"
|
||||
| "text_ended"
|
||||
| "tool_call"
|
||||
| "tool_call_start"
|
||||
| "tool_response"
|
||||
| "login_needed"
|
||||
| "need_login"
|
||||
| "credentials_needed"
|
||||
| "error"
|
||||
| "usage"
|
||||
| "stream_end";
|
||||
taskId?: string;
|
||||
timestamp?: string;
|
||||
content?: string;
|
||||
message?: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
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 type VercelStreamChunk =
|
||||
| { type: "start"; messageId: string; taskId?: string }
|
||||
| { type: "finish" }
|
||||
| { type: "text-start"; id: string }
|
||||
| { type: "text-delta"; id: string; delta: string }
|
||||
| { type: "text-end"; id: string }
|
||||
| { type: "tool-input-start"; toolCallId: string; toolName: string }
|
||||
| {
|
||||
type: "tool-input-available";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "tool-output-available";
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
output: unknown;
|
||||
success?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "usage";
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
errorText: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface ActiveStream {
|
||||
sessionId: string;
|
||||
abortController: AbortController;
|
||||
status: StreamStatus;
|
||||
startedAt: number;
|
||||
chunks: StreamChunk[];
|
||||
error?: Error;
|
||||
onChunkCallbacks: Set<(chunk: StreamChunk) => void>;
|
||||
}
|
||||
|
||||
export interface StreamResult {
|
||||
sessionId: string;
|
||||
status: StreamStatus;
|
||||
chunks: StreamChunk[];
|
||||
completedAt: number;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export type StreamCompleteCallback = (sessionId: string) => void;
|
||||
|
||||
// Type guards for message types
|
||||
|
||||
/**
|
||||
* Check if a message has a toolId property.
|
||||
*/
|
||||
export function hasToolId<T extends { type: string }>(
|
||||
msg: T,
|
||||
): msg is T & { toolId: string } {
|
||||
return (
|
||||
"toolId" in msg &&
|
||||
typeof (msg as Record<string, unknown>).toolId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has an operationId property.
|
||||
*/
|
||||
export function hasOperationId<T extends { type: string }>(
|
||||
msg: T,
|
||||
): msg is T & { operationId: string } {
|
||||
return (
|
||||
"operationId" in msg &&
|
||||
typeof (msg as Record<string, unknown>).operationId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has a toolCallId property.
|
||||
*/
|
||||
export function hasToolCallId<T extends { type: string }>(
|
||||
msg: T,
|
||||
): msg is T & { toolCallId: string } {
|
||||
return (
|
||||
"toolCallId" in msg &&
|
||||
typeof (msg as Record<string, unknown>).toolCallId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is an operation message type.
|
||||
*/
|
||||
export function isOperationMessage<T extends { type: string }>(
|
||||
msg: T,
|
||||
): msg is T & {
|
||||
type: "operation_started" | "operation_pending" | "operation_in_progress";
|
||||
} {
|
||||
return (
|
||||
msg.type === "operation_started" ||
|
||||
msg.type === "operation_pending" ||
|
||||
msg.type === "operation_in_progress"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tool ID from a message if available.
|
||||
* Checks toolId, operationId, and toolCallId properties.
|
||||
*/
|
||||
export function getToolIdFromMessage<T extends { type: string }>(
|
||||
msg: T,
|
||||
): string | undefined {
|
||||
const record = msg as Record<string, unknown>;
|
||||
if (typeof record.toolId === "string") return record.toolId;
|
||||
if (typeof record.operationId === "string") return record.operationId;
|
||||
if (typeof record.toolCallId === "string") return record.toolCallId;
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface AIChatBubbleProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AIChatBubble({ children, className }: AIChatBubbleProps) {
|
||||
return (
|
||||
<div className={cn("text-left text-[1rem] leading-relaxed", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Card } from "@/components/atoms/Card/Card";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowRight, List, Robot } from "@phosphor-icons/react";
|
||||
import Image from "next/image";
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version?: number;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface AgentCarouselMessageProps {
|
||||
agents: Agent[];
|
||||
totalCount?: number;
|
||||
onSelectAgent?: (agentId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentCarouselMessage({
|
||||
agents,
|
||||
totalCount,
|
||||
onSelectAgent,
|
||||
className,
|
||||
}: AgentCarouselMessageProps) {
|
||||
const displayCount = totalCount ?? agents.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 my-2 flex flex-col gap-4 rounded-lg border border-purple-200 bg-purple-50 p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500">
|
||||
<List size={24} weight="bold" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="h3" className="text-purple-900">
|
||||
Found {displayCount} {displayCount === 1 ? "Agent" : "Agents"}
|
||||
</Text>
|
||||
<Text variant="small" className="text-purple-700">
|
||||
Select an agent to view details or run it
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Cards */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{agents.map((agent) => (
|
||||
<Card
|
||||
key={agent.id}
|
||||
className="border border-purple-200 bg-white p-4"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative h-10 w-10 flex-shrink-0 overflow-hidden rounded-lg bg-purple-100">
|
||||
{agent.image_url ? (
|
||||
<Image
|
||||
src={agent.image_url}
|
||||
alt={`${agent.name} preview image`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Robot
|
||||
size={20}
|
||||
weight="bold"
|
||||
className="text-purple-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<Text
|
||||
variant="body"
|
||||
className="font-semibold text-purple-900"
|
||||
>
|
||||
{agent.name}
|
||||
</Text>
|
||||
{agent.version && (
|
||||
<Text variant="small" className="text-purple-600">
|
||||
v{agent.version}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Text variant="small" className="line-clamp-2 text-purple-700">
|
||||
{agent.description}
|
||||
</Text>
|
||||
{onSelectAgent && (
|
||||
<Button
|
||||
onClick={() => onSelectAgent(agent.id)}
|
||||
variant="ghost"
|
||||
className="mt-2 flex items-center gap-1 p-0 text-sm text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
View details
|
||||
<ArrowRight size={16} weight="bold" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalCount && totalCount > agents.length && (
|
||||
<Text variant="small" className="text-center text-purple-600">
|
||||
Showing {agents.length} of {totalCount} results
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +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/CredentialsInput/CredentialsInput";
|
||||
import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs";
|
||||
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import {
|
||||
BlockIOCredentialsSubSchema,
|
||||
BlockIOSubSchema,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { cn, isEmpty } from "@/lib/utils";
|
||||
import { PlayIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { useAgentInputsSetup } from "./useAgentInputsSetup";
|
||||
|
||||
type LibraryAgentInputSchemaProperties = LibraryAgent["input_schema"] extends {
|
||||
properties: infer P;
|
||||
}
|
||||
? P extends Record<string, BlockIOSubSchema>
|
||||
? P
|
||||
: Record<string, BlockIOSubSchema>
|
||||
: Record<string, BlockIOSubSchema>;
|
||||
|
||||
type LibraryAgentCredentialsInputSchemaProperties =
|
||||
LibraryAgent["credentials_input_schema"] extends {
|
||||
properties: infer P;
|
||||
}
|
||||
? P extends Record<string, BlockIOCredentialsSubSchema>
|
||||
? P
|
||||
: Record<string, BlockIOCredentialsSubSchema>
|
||||
: Record<string, BlockIOCredentialsSubSchema>;
|
||||
|
||||
interface Props {
|
||||
agentName?: string;
|
||||
inputSchema: LibraryAgentInputSchemaProperties | Record<string, any>;
|
||||
credentialsSchema?:
|
||||
| LibraryAgentCredentialsInputSchemaProperties
|
||||
| Record<string, any>;
|
||||
message: string;
|
||||
requiredFields?: string[];
|
||||
onRun: (
|
||||
inputs: Record<string, any>,
|
||||
credentials: Record<string, any>,
|
||||
) => void;
|
||||
onCancel?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentInputsSetup({
|
||||
agentName,
|
||||
inputSchema,
|
||||
credentialsSchema,
|
||||
message,
|
||||
requiredFields,
|
||||
onRun,
|
||||
onCancel,
|
||||
className,
|
||||
}: Props) {
|
||||
const { inputValues, setInputValue, credentialsValues, setCredentialsValue } =
|
||||
useAgentInputsSetup();
|
||||
|
||||
const inputSchemaObj = useMemo(() => {
|
||||
if (!inputSchema) return { properties: {}, required: [] };
|
||||
if ("properties" in inputSchema && "type" in inputSchema) {
|
||||
return inputSchema as {
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
return { properties: inputSchema as Record<string, any>, required: [] };
|
||||
}, [inputSchema]);
|
||||
|
||||
const credentialsSchemaObj = useMemo(() => {
|
||||
if (!credentialsSchema) return { properties: {}, required: [] };
|
||||
if ("properties" in credentialsSchema && "type" in credentialsSchema) {
|
||||
return credentialsSchema as {
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
return {
|
||||
properties: credentialsSchema as Record<string, any>,
|
||||
required: [],
|
||||
};
|
||||
}, [credentialsSchema]);
|
||||
|
||||
const agentInputFields = useMemo(() => {
|
||||
const properties = inputSchemaObj.properties || {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties).filter(
|
||||
([_, subSchema]: [string, any]) => !subSchema.hidden,
|
||||
),
|
||||
);
|
||||
}, [inputSchemaObj]);
|
||||
|
||||
const agentCredentialsInputFields = useMemo(() => {
|
||||
return credentialsSchemaObj.properties || {};
|
||||
}, [credentialsSchemaObj]);
|
||||
|
||||
const inputFields = Object.entries(agentInputFields);
|
||||
const credentialFields = Object.entries(agentCredentialsInputFields);
|
||||
|
||||
const defaultsFromSchema = useMemo(() => {
|
||||
const defaults: Record<string, any> = {};
|
||||
Object.entries(agentInputFields).forEach(([key, schema]) => {
|
||||
if ("default" in schema && schema.default !== undefined) {
|
||||
defaults[key] = schema.default;
|
||||
}
|
||||
});
|
||||
return defaults;
|
||||
}, [agentInputFields]);
|
||||
|
||||
const defaultsFromCredentialsSchema = useMemo(() => {
|
||||
const defaults: Record<string, any> = {};
|
||||
Object.entries(agentCredentialsInputFields).forEach(([key, schema]) => {
|
||||
if ("default" in schema && schema.default !== undefined) {
|
||||
defaults[key] = schema.default;
|
||||
}
|
||||
});
|
||||
return defaults;
|
||||
}, [agentCredentialsInputFields]);
|
||||
|
||||
const mergedInputValues = useMemo(() => {
|
||||
return { ...defaultsFromSchema, ...inputValues };
|
||||
}, [defaultsFromSchema, inputValues]);
|
||||
|
||||
const mergedCredentialsValues = useMemo(() => {
|
||||
return { ...defaultsFromCredentialsSchema, ...credentialsValues };
|
||||
}, [defaultsFromCredentialsSchema, credentialsValues]);
|
||||
|
||||
const allRequiredInputsAreSet = useMemo(() => {
|
||||
const requiredInputs = new Set(
|
||||
requiredFields || (inputSchemaObj.required as string[]) || [],
|
||||
);
|
||||
const nonEmptyInputs = new Set(
|
||||
Object.keys(mergedInputValues).filter(
|
||||
(k) => !isEmpty(mergedInputValues[k]),
|
||||
),
|
||||
);
|
||||
const missing = [...requiredInputs].filter(
|
||||
(input) => !nonEmptyInputs.has(input),
|
||||
);
|
||||
return missing.length === 0;
|
||||
}, [inputSchemaObj.required, mergedInputValues, requiredFields]);
|
||||
|
||||
const allCredentialsAreSet = useMemo(() => {
|
||||
const requiredCredentials = new Set(
|
||||
(credentialsSchemaObj.required as string[]) || [],
|
||||
);
|
||||
if (requiredCredentials.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const missing = [...requiredCredentials].filter((key) => {
|
||||
const cred = mergedCredentialsValues[key];
|
||||
return !cred || !cred.id;
|
||||
});
|
||||
return missing.length === 0;
|
||||
}, [credentialsSchemaObj.required, mergedCredentialsValues]);
|
||||
|
||||
const canRun = allRequiredInputsAreSet && allCredentialsAreSet;
|
||||
|
||||
function handleRun() {
|
||||
if (canRun) {
|
||||
onRun(mergedInputValues, mergedCredentialsValues);
|
||||
}
|
||||
}
|
||||
|
||||
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, inputSubSchema]) => (
|
||||
<RunAgentInputs
|
||||
key={key}
|
||||
schema={inputSubSchema}
|
||||
value={inputValues[key] ?? inputSubSchema.default}
|
||||
placeholder={inputSubSchema.description}
|
||||
onChange={(value) => setInputValue(key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialFields.length > 0 && (
|
||||
<div className="mb-4 space-y-4">
|
||||
{credentialFields.map(([key, schema]) => {
|
||||
const requiredCredentials = new Set(
|
||||
(credentialsSchemaObj.required as string[]) || [],
|
||||
);
|
||||
return (
|
||||
<CredentialsInput
|
||||
key={key}
|
||||
schema={schema}
|
||||
selectedCredentials={credentialsValues[key]}
|
||||
onSelectCredentials={(value) =>
|
||||
setCredentialsValue(key, value)
|
||||
}
|
||||
siblingInputs={mergedInputValues}
|
||||
isOptional={!requiredCredentials.has(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={handleRun}
|
||||
disabled={!canRun}
|
||||
>
|
||||
<PlayIcon className="mr-2 h-4 w-4" weight="bold" />
|
||||
Run Agent
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button variant="outline" size="small" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import { useState } from "react";
|
||||
|
||||
export function useAgentInputsSetup() {
|
||||
const [inputValues, setInputValues] = useState<Record<string, any>>({});
|
||||
const [credentialsValues, setCredentialsValues] = useState<
|
||||
Record<string, CredentialsMetaInput>
|
||||
>({});
|
||||
|
||||
function setInputValue(key: string, value: any) {
|
||||
setInputValues((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
function setCredentialsValue(key: string, value?: CredentialsMetaInput) {
|
||||
if (value) {
|
||||
setCredentialsValues((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
} else {
|
||||
setCredentialsValues((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inputValues,
|
||||
setInputValue,
|
||||
credentialsValues,
|
||||
setCredentialsValue,
|
||||
};
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ShieldIcon, SignInIcon, UserPlusIcon } from "@phosphor-icons/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export interface AuthPromptWidgetProps {
|
||||
message: string;
|
||||
sessionId: string;
|
||||
agentInfo?: {
|
||||
graph_id: string;
|
||||
name: string;
|
||||
trigger_type: string;
|
||||
};
|
||||
returnUrl?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AuthPromptWidget({
|
||||
message,
|
||||
sessionId,
|
||||
agentInfo,
|
||||
returnUrl = "/copilot/chat",
|
||||
className,
|
||||
}: AuthPromptWidgetProps) {
|
||||
const router = useRouter();
|
||||
|
||||
function handleSignIn() {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("pending_chat_session", sessionId);
|
||||
if (agentInfo) {
|
||||
localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo));
|
||||
}
|
||||
}
|
||||
const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`;
|
||||
const encodedReturnUrl = encodeURIComponent(returnUrlWithSession);
|
||||
router.push(`/login?returnUrl=${encodedReturnUrl}`);
|
||||
}
|
||||
|
||||
function handleSignUp() {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("pending_chat_session", sessionId);
|
||||
if (agentInfo) {
|
||||
localStorage.setItem("pending_agent_setup", JSON.stringify(agentInfo));
|
||||
}
|
||||
}
|
||||
const returnUrlWithSession = `${returnUrl}?session_id=${sessionId}`;
|
||||
const encodedReturnUrl = encodeURIComponent(returnUrlWithSession);
|
||||
router.push(`/signup?returnUrl=${encodedReturnUrl}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"my-4 overflow-hidden rounded-lg border border-violet-200",
|
||||
"bg-gradient-to-br from-violet-50 to-purple-50",
|
||||
"duration-500 animate-in fade-in-50 slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="px-6 py-5">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-600">
|
||||
<ShieldIcon size={20} weight="fill" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">
|
||||
Authentication Required
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Sign in to set up and manage agents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-md bg-white/50 p-4">
|
||||
<p className="text-sm text-neutral-700">{message}</p>
|
||||
{agentInfo && (
|
||||
<div className="mt-3 text-xs text-neutral-600">
|
||||
<p>
|
||||
Ready to set up:{" "}
|
||||
<span className="font-medium">{agentInfo.name}</span>
|
||||
</p>
|
||||
<p>
|
||||
Type:{" "}
|
||||
<span className="font-medium">{agentInfo.trigger_type}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleSignIn}
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="flex-1"
|
||||
>
|
||||
<SignInIcon size={16} weight="bold" className="mr-2" />
|
||||
Sign In
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSignUp}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="flex-1"
|
||||
>
|
||||
<UserPlusIcon size={16} weight="bold" className="mr-2" />
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-xs text-neutral-500">
|
||||
Your chat session will be preserved after signing in
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GlobeHemisphereEastIcon } from "@phosphor-icons/react";
|
||||
import { useEffect } from "react";
|
||||
import { ChatInput } from "../ChatInput/ChatInput";
|
||||
import { MessageList } from "../MessageList/MessageList";
|
||||
import { useChatContainer } from "./useChatContainer";
|
||||
|
||||
export interface ChatContainerProps {
|
||||
sessionId: string | null;
|
||||
initialMessages: SessionDetailResponse["messages"];
|
||||
initialPrompt?: string;
|
||||
className?: string;
|
||||
onStreamingChange?: (isStreaming: boolean) => void;
|
||||
onOperationStarted?: () => void;
|
||||
/** Active stream info from the server for reconnection */
|
||||
activeStream?: {
|
||||
taskId: string;
|
||||
lastMessageId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
sessionId,
|
||||
initialMessages,
|
||||
initialPrompt,
|
||||
className,
|
||||
onStreamingChange,
|
||||
onOperationStarted,
|
||||
activeStream,
|
||||
}: ChatContainerProps) {
|
||||
const {
|
||||
messages,
|
||||
streamingChunks,
|
||||
isStreaming,
|
||||
stopStreaming,
|
||||
isRegionBlockedModalOpen,
|
||||
sendMessageWithContext,
|
||||
handleRegionModalOpenChange,
|
||||
handleRegionModalClose,
|
||||
} = useChatContainer({
|
||||
sessionId,
|
||||
initialMessages,
|
||||
initialPrompt,
|
||||
onOperationStarted,
|
||||
activeStream,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onStreamingChange?.(isStreaming);
|
||||
}, [isStreaming, onStreamingChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col bg-[#f8f8f9]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Dialog
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeHemisphereEastIcon className="size-6" />
|
||||
<Text
|
||||
variant="body"
|
||||
className="text-md font-poppins leading-none md:text-lg"
|
||||
>
|
||||
Service unavailable
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
controlled={{
|
||||
isOpen: isRegionBlockedModalOpen,
|
||||
set: handleRegionModalOpenChange,
|
||||
}}
|
||||
onClose={handleRegionModalClose}
|
||||
styling={{ maxWidth: 550, width: "100%", minWidth: "auto" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-8">
|
||||
<Text variant="body">
|
||||
The Autogpt AI model is not available in your region or your
|
||||
connection is blocking it. Please try again with a different
|
||||
connection.
|
||||
</Text>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleRegionModalClose}
|
||||
className="w-full"
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
{/* Messages - Scrollable */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-full flex-col justify-end">
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingChunks={streamingChunks}
|
||||
isStreaming={isStreaming}
|
||||
onSendMessage={sendMessageWithContext}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input - Fixed at bottom */}
|
||||
<div className="relative px-3 pb-6 pt-2">
|
||||
<div className="pointer-events-none absolute top-[-18px] z-10 h-6 w-full bg-gradient-to-b from-transparent to-[#f8f8f9]" />
|
||||
<ChatInput
|
||||
onSend={sendMessageWithContext}
|
||||
disabled={isStreaming || !sessionId}
|
||||
isStreaming={isStreaming}
|
||||
onStop={stopStreaming}
|
||||
placeholder="What else can I help with?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { toast } from "sonner";
|
||||
import type { StreamChunk } from "../../chat-types";
|
||||
import type { HandlerDependencies } from "./handlers";
|
||||
import {
|
||||
getErrorDisplayMessage,
|
||||
handleError,
|
||||
handleLoginNeeded,
|
||||
handleStreamEnd,
|
||||
handleTextChunk,
|
||||
handleTextEnded,
|
||||
handleToolCallStart,
|
||||
handleToolResponse,
|
||||
isRegionBlockedError,
|
||||
} from "./handlers";
|
||||
|
||||
export function createStreamEventDispatcher(
|
||||
deps: HandlerDependencies,
|
||||
): (chunk: StreamChunk) => void {
|
||||
return function dispatchStreamEvent(chunk: StreamChunk): void {
|
||||
if (
|
||||
chunk.type === "text_chunk" ||
|
||||
chunk.type === "tool_call_start" ||
|
||||
chunk.type === "tool_response" ||
|
||||
chunk.type === "login_needed" ||
|
||||
chunk.type === "need_login" ||
|
||||
chunk.type === "error"
|
||||
) {
|
||||
deps.hasResponseRef.current = true;
|
||||
}
|
||||
|
||||
switch (chunk.type) {
|
||||
case "stream_start":
|
||||
// Store task ID for SSE reconnection
|
||||
if (chunk.taskId && deps.onActiveTaskStarted) {
|
||||
deps.onActiveTaskStarted({
|
||||
taskId: chunk.taskId,
|
||||
operationId: chunk.taskId,
|
||||
toolName: "chat",
|
||||
toolCallId: "chat_stream",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
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":
|
||||
// Note: "finish" type from backend gets normalized to "stream_end" by normalizeStreamChunk
|
||||
handleStreamEnd(chunk, deps);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
const isRegionBlocked = isRegionBlockedError(chunk);
|
||||
handleError(chunk, deps);
|
||||
// Show toast at dispatcher level to avoid circular dependencies
|
||||
if (!isRegionBlocked) {
|
||||
toast.error("Chat Error", {
|
||||
description: getErrorDisplayMessage(chunk),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "usage":
|
||||
// TODO: Handle usage for display
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("Unknown stream chunk type:", chunk);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { StreamChunk } from "../../useChatStream";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import {
|
||||
extractCredentialsNeeded,
|
||||
extractInputsNeeded,
|
||||
parseToolResponse,
|
||||
} from "./helpers";
|
||||
|
||||
function isToolCallMessage(
|
||||
message: ChatMessageData,
|
||||
): message is Extract<ChatMessageData, { type: "tool_call" }> {
|
||||
return message.type === "tool_call";
|
||||
}
|
||||
|
||||
export interface HandlerDependencies {
|
||||
setHasTextChunks: Dispatch<SetStateAction<boolean>>;
|
||||
setStreamingChunks: Dispatch<SetStateAction<string[]>>;
|
||||
streamingChunksRef: MutableRefObject<string[]>;
|
||||
hasResponseRef: MutableRefObject<boolean>;
|
||||
textFinalizedRef: MutableRefObject<boolean>;
|
||||
streamEndedRef: MutableRefObject<boolean>;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
|
||||
setIsStreamingInitiated: Dispatch<SetStateAction<boolean>>;
|
||||
setIsRegionBlockedModalOpen: Dispatch<SetStateAction<boolean>>;
|
||||
sessionId: string;
|
||||
onOperationStarted?: () => void;
|
||||
onActiveTaskStarted?: (taskInfo: {
|
||||
taskId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function isRegionBlockedError(chunk: StreamChunk): boolean {
|
||||
if (chunk.code === "MODEL_NOT_AVAILABLE_REGION") return true;
|
||||
const message = chunk.message || chunk.content;
|
||||
if (typeof message !== "string") return false;
|
||||
return message.toLowerCase().includes("not available in your region");
|
||||
}
|
||||
|
||||
export function getUserFriendlyErrorMessage(
|
||||
code: string | undefined,
|
||||
): string | undefined {
|
||||
switch (code) {
|
||||
case "TASK_EXPIRED":
|
||||
return "This operation has expired. Please try again.";
|
||||
case "TASK_NOT_FOUND":
|
||||
return "Could not find the requested operation.";
|
||||
case "ACCESS_DENIED":
|
||||
return "You do not have access to this operation.";
|
||||
case "QUEUE_OVERFLOW":
|
||||
return "Connection was interrupted. Please refresh to continue.";
|
||||
case "MODEL_NOT_AVAILABLE_REGION":
|
||||
return "This model is not available in your region.";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
if (deps.textFinalizedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completedText = deps.streamingChunksRef.current.join("");
|
||||
if (completedText.trim()) {
|
||||
deps.textFinalizedRef.current = true;
|
||||
|
||||
deps.setMessages((prev) => {
|
||||
const exists = prev.some(
|
||||
(msg) =>
|
||||
msg.type === "message" &&
|
||||
msg.role === "assistant" &&
|
||||
msg.content === completedText,
|
||||
);
|
||||
if (exists) return prev;
|
||||
|
||||
const assistantMessage: ChatMessageData = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: completedText,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
return [...prev, assistantMessage];
|
||||
});
|
||||
}
|
||||
deps.setStreamingChunks([]);
|
||||
deps.streamingChunksRef.current = [];
|
||||
deps.setHasTextChunks(false);
|
||||
}
|
||||
|
||||
export function handleToolCallStart(
|
||||
chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
// Use deterministic fallback instead of Date.now() to ensure same ID on replay
|
||||
const toolId =
|
||||
chunk.tool_id ||
|
||||
`tool-${deps.sessionId}-${chunk.idx ?? "unknown"}-${chunk.tool_name || "unknown"}`;
|
||||
|
||||
const toolCallMessage: Extract<ChatMessageData, { type: "tool_call" }> = {
|
||||
type: "tool_call",
|
||||
toolId,
|
||||
toolName: chunk.tool_name || "Executing",
|
||||
arguments: chunk.arguments || {},
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
function updateToolCallMessages(prev: ChatMessageData[]) {
|
||||
const existingIndex = prev.findIndex(function findToolCallIndex(msg) {
|
||||
return isToolCallMessage(msg) && msg.toolId === toolCallMessage.toolId;
|
||||
});
|
||||
if (existingIndex === -1) {
|
||||
return [...prev, toolCallMessage];
|
||||
}
|
||||
const nextMessages = [...prev];
|
||||
const existing = nextMessages[existingIndex];
|
||||
if (!isToolCallMessage(existing)) return prev;
|
||||
const nextArguments =
|
||||
toolCallMessage.arguments &&
|
||||
Object.keys(toolCallMessage.arguments).length > 0
|
||||
? toolCallMessage.arguments
|
||||
: existing.arguments;
|
||||
nextMessages[existingIndex] = {
|
||||
...existing,
|
||||
toolName: toolCallMessage.toolName || existing.toolName,
|
||||
arguments: nextArguments,
|
||||
timestamp: toolCallMessage.timestamp,
|
||||
};
|
||||
return nextMessages;
|
||||
}
|
||||
|
||||
deps.setMessages(updateToolCallMessages);
|
||||
}
|
||||
|
||||
const TOOL_RESPONSE_TYPES = new Set([
|
||||
"tool_response",
|
||||
"operation_started",
|
||||
"operation_pending",
|
||||
"operation_in_progress",
|
||||
"execution_started",
|
||||
"agent_carousel",
|
||||
"clarification_needed",
|
||||
]);
|
||||
|
||||
function hasResponseForTool(
|
||||
messages: ChatMessageData[],
|
||||
toolId: string,
|
||||
): boolean {
|
||||
return messages.some((msg) => {
|
||||
if (!TOOL_RESPONSE_TYPES.has(msg.type)) return false;
|
||||
const msgToolId =
|
||||
(msg as { toolId?: string }).toolId ||
|
||||
(msg as { toolCallId?: string }).toolCallId;
|
||||
return msgToolId === toolId;
|
||||
});
|
||||
}
|
||||
|
||||
export function handleToolResponse(
|
||||
chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
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) => {
|
||||
// Check for duplicate inputs_needed message
|
||||
const exists = prev.some((msg) => msg.type === "inputs_needed");
|
||||
if (exists) return prev;
|
||||
return [...prev, inputsMessage];
|
||||
});
|
||||
}
|
||||
const credentialsMessage = extractCredentialsNeeded(
|
||||
parsedResult,
|
||||
chunk.tool_name,
|
||||
);
|
||||
if (credentialsMessage) {
|
||||
deps.setMessages((prev) => {
|
||||
// Check for duplicate credentials_needed message
|
||||
const exists = prev.some((msg) => msg.type === "credentials_needed");
|
||||
if (exists) return prev;
|
||||
return [...prev, credentialsMessage];
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (responseMessage.type === "operation_started") {
|
||||
deps.onOperationStarted?.();
|
||||
const taskId = (responseMessage as { taskId?: string }).taskId;
|
||||
if (taskId && deps.onActiveTaskStarted) {
|
||||
deps.onActiveTaskStarted({
|
||||
taskId,
|
||||
operationId:
|
||||
(responseMessage as { operationId?: string }).operationId || "",
|
||||
toolName: (responseMessage as { toolName?: string }).toolName || "",
|
||||
toolCallId: (responseMessage as { toolId?: string }).toolId || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deps.setMessages((prev) => {
|
||||
const toolCallIndex = prev.findIndex(
|
||||
(msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
|
||||
);
|
||||
if (hasResponseForTool(prev, chunk.tool_id!)) {
|
||||
return prev;
|
||||
}
|
||||
if (toolCallIndex !== -1) {
|
||||
const newMessages = [...prev];
|
||||
newMessages.splice(toolCallIndex + 1, 0, responseMessage);
|
||||
return newMessages;
|
||||
}
|
||||
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) => {
|
||||
// Check for duplicate login_needed message
|
||||
const exists = prev.some((msg) => msg.type === "login_needed");
|
||||
if (exists) return prev;
|
||||
return [...prev, loginNeededMessage];
|
||||
});
|
||||
}
|
||||
|
||||
export function handleStreamEnd(
|
||||
_chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
if (deps.streamEndedRef.current) {
|
||||
return;
|
||||
}
|
||||
deps.streamEndedRef.current = true;
|
||||
|
||||
const completedContent = deps.streamingChunksRef.current.join("");
|
||||
if (!completedContent.trim() && !deps.hasResponseRef.current) {
|
||||
deps.setMessages((prev) => {
|
||||
const exists = prev.some(
|
||||
(msg) =>
|
||||
msg.type === "message" &&
|
||||
msg.role === "assistant" &&
|
||||
msg.content === "No response received. Please try again.",
|
||||
);
|
||||
if (exists) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "No response received. Please try again.",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
if (completedContent.trim() && !deps.textFinalizedRef.current) {
|
||||
deps.textFinalizedRef.current = true;
|
||||
|
||||
deps.setMessages((prev) => {
|
||||
const exists = prev.some(
|
||||
(msg) =>
|
||||
msg.type === "message" &&
|
||||
msg.role === "assistant" &&
|
||||
msg.content === completedContent,
|
||||
);
|
||||
if (exists) return prev;
|
||||
|
||||
const assistantMessage: ChatMessageData = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: completedContent,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
return [...prev, assistantMessage];
|
||||
});
|
||||
}
|
||||
deps.setStreamingChunks([]);
|
||||
deps.streamingChunksRef.current = [];
|
||||
deps.setHasTextChunks(false);
|
||||
deps.setIsStreamingInitiated(false);
|
||||
}
|
||||
|
||||
export function handleError(chunk: StreamChunk, deps: HandlerDependencies) {
|
||||
if (isRegionBlockedError(chunk)) {
|
||||
deps.setIsRegionBlockedModalOpen(true);
|
||||
}
|
||||
deps.setIsStreamingInitiated(false);
|
||||
deps.setHasTextChunks(false);
|
||||
deps.setStreamingChunks([]);
|
||||
deps.streamingChunksRef.current = [];
|
||||
deps.textFinalizedRef.current = false;
|
||||
deps.streamEndedRef.current = true;
|
||||
}
|
||||
|
||||
export function getErrorDisplayMessage(chunk: StreamChunk): string {
|
||||
const friendlyMessage = getUserFriendlyErrorMessage(chunk.code);
|
||||
if (friendlyMessage) {
|
||||
return friendlyMessage;
|
||||
}
|
||||
return chunk.message || chunk.content || "An error occurred";
|
||||
}
|
||||
@@ -1,607 +0,0 @@
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import { SessionKey, sessionStorage } from "@/services/storage/session-storage";
|
||||
import type { ToolResult } from "@/types/chat";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
|
||||
export function processInitialMessages(
|
||||
initialMessages: SessionDetailResponse["messages"],
|
||||
): ChatMessageData[] {
|
||||
const processedMessages: ChatMessageData[] = [];
|
||||
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;
|
||||
|
||||
if (role === "user") {
|
||||
content = removePageContext(content);
|
||||
if (!content.trim()) continue;
|
||||
processedMessages.push({
|
||||
type: "message",
|
||||
role: "user",
|
||||
content,
|
||||
timestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
content = content
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.replace(/<internal_reasoning>[\s\S]*?<\/internal_reasoning>/gi, "")
|
||||
.trim();
|
||||
|
||||
if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) {
|
||||
for (const toolCall of toolCalls) {
|
||||
const toolName = toolCall.function.name;
|
||||
const toolId = toolCall.id;
|
||||
toolCallMap.set(toolId, toolName);
|
||||
|
||||
try {
|
||||
const args = JSON.parse(toolCall.function.arguments || "{}");
|
||||
processedMessages.push({
|
||||
type: "tool_call",
|
||||
toolId,
|
||||
toolName,
|
||||
arguments: args,
|
||||
timestamp,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse tool call arguments:", err);
|
||||
processedMessages.push({
|
||||
type: "tool_call",
|
||||
toolId,
|
||||
toolName,
|
||||
arguments: {},
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (content.trim()) {
|
||||
processedMessages.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
} else if (content.trim()) {
|
||||
processedMessages.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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) {
|
||||
processedMessages.push(toolResponse);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.trim()) {
|
||||
processedMessages.push({
|
||||
type: "message",
|
||||
role: role as "user" | "assistant" | "system",
|
||||
content,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return processedMessages;
|
||||
}
|
||||
|
||||
export function hasSentInitialPrompt(sessionId: string): boolean {
|
||||
try {
|
||||
const sent = JSON.parse(
|
||||
sessionStorage.get(SessionKey.CHAT_SENT_INITIAL_PROMPTS) || "{}",
|
||||
);
|
||||
return sent[sessionId] === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markInitialPromptSent(sessionId: string): void {
|
||||
try {
|
||||
const sent = JSON.parse(
|
||||
sessionStorage.get(SessionKey.CHAT_SENT_INITIAL_PROMPTS) || "{}",
|
||||
);
|
||||
sent[sessionId] = true;
|
||||
sessionStorage.set(
|
||||
SessionKey.CHAT_SENT_INITIAL_PROMPTS,
|
||||
JSON.stringify(sent),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function removePageContext(content: string): string {
|
||||
// Remove "Page URL: ..." pattern at start of line (case insensitive, handles various formats)
|
||||
let cleaned = content.replace(/^\s*Page URL:\s*[^\n\r]*/gim, "");
|
||||
|
||||
// Find "User Message:" marker at start of line to preserve the actual user message
|
||||
const userMessageMatch = cleaned.match(/^\s*User Message:\s*([\s\S]*)$/im);
|
||||
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 at start of line
|
||||
cleaned = cleaned.replace(/^\s*Page Content:[\s\S]*$/gim, "");
|
||||
}
|
||||
|
||||
// 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",
|
||||
toolId,
|
||||
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",
|
||||
toolId,
|
||||
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 === "clarification_needed") {
|
||||
return {
|
||||
type: "clarification_needed",
|
||||
toolName,
|
||||
questions:
|
||||
(parsedResult.questions as Array<{
|
||||
question: string;
|
||||
keyword: string;
|
||||
example?: string;
|
||||
}>) || [],
|
||||
message:
|
||||
(parsedResult.message as string) ||
|
||||
"I need more information to proceed.",
|
||||
sessionId: (parsedResult.session_id as string) || "",
|
||||
timestamp: timestamp || new Date(),
|
||||
};
|
||||
}
|
||||
if (responseType === "operation_started") {
|
||||
return {
|
||||
type: "operation_started",
|
||||
toolName: (parsedResult.tool_name as string) || toolName,
|
||||
toolId,
|
||||
operationId: (parsedResult.operation_id as string) || "",
|
||||
taskId: (parsedResult.task_id as string) || undefined, // For SSE reconnection
|
||||
message:
|
||||
(parsedResult.message as string) ||
|
||||
"Operation started. You can close this tab.",
|
||||
timestamp: timestamp || new Date(),
|
||||
};
|
||||
}
|
||||
if (responseType === "operation_pending") {
|
||||
return {
|
||||
type: "operation_pending",
|
||||
toolName: (parsedResult.tool_name as string) || toolName,
|
||||
toolId,
|
||||
operationId: (parsedResult.operation_id as string) || "",
|
||||
message:
|
||||
(parsedResult.message as string) ||
|
||||
"Operation in progress. Please wait...",
|
||||
timestamp: timestamp || new Date(),
|
||||
};
|
||||
}
|
||||
if (responseType === "operation_in_progress") {
|
||||
return {
|
||||
type: "operation_in_progress",
|
||||
toolName: (parsedResult.tool_name as string) || toolName,
|
||||
toolCallId: (parsedResult.tool_call_id as string) || toolId,
|
||||
message:
|
||||
(parsedResult.message as string) ||
|
||||
"Operation already in progress. Please wait...",
|
||||
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;
|
||||
}
|
||||
if (responseType === "understanding_updated") {
|
||||
return {
|
||||
type: "tool_response",
|
||||
toolId,
|
||||
toolName,
|
||||
result: (parsedResult || result) as ToolResult,
|
||||
success: true,
|
||||
timestamp: timestamp || new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "tool_response",
|
||||
toolId,
|
||||
toolName,
|
||||
result: parsedResult ? (parsedResult as ToolResult) : 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) => {
|
||||
// Normalize to array at boundary - prefer 'types' array, fall back to single 'type'
|
||||
const typesArray = credInfo.types as
|
||||
| Array<"api_key" | "oauth2" | "user_password" | "host_scoped">
|
||||
| undefined;
|
||||
const singleType =
|
||||
(credInfo.type as
|
||||
| "api_key"
|
||||
| "oauth2"
|
||||
| "user_password"
|
||||
| "host_scoped"
|
||||
| undefined) || "api_key";
|
||||
const credentialTypes =
|
||||
typesArray && typesArray.length > 0 ? typesArray : [singleType];
|
||||
|
||||
return {
|
||||
provider: (credInfo.provider as string) || "unknown",
|
||||
providerName:
|
||||
(credInfo.provider_name as string) ||
|
||||
(credInfo.provider as string) ||
|
||||
"Unknown Provider",
|
||||
credentialTypes,
|
||||
title:
|
||||
(credInfo.title as string) ||
|
||||
`${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
|
||||
scopes: credInfo.scopes as string[] | undefined,
|
||||
};
|
||||
});
|
||||
return {
|
||||
type: "credentials_needed",
|
||||
toolName,
|
||||
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 properties: Record<string, any> = {};
|
||||
const requiredProps: string[] = [];
|
||||
inputs.forEach((input) => {
|
||||
const name = input.name as string;
|
||||
if (name) {
|
||||
properties[name] = {
|
||||
title: input.name as string,
|
||||
description: (input.description as string) || "",
|
||||
type: (input.type as string) || "string",
|
||||
default: input.default,
|
||||
enum: input.options,
|
||||
format: input.format,
|
||||
};
|
||||
if ((input.required as boolean) === true) {
|
||||
requiredProps.push(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const inputSchema: Record<string, any> = {
|
||||
type: "object",
|
||||
properties,
|
||||
};
|
||||
if (requiredProps.length > 0) {
|
||||
inputSchema.required = requiredProps;
|
||||
}
|
||||
|
||||
const credentialsSchema: Record<string, any> = {};
|
||||
if (credentials && credentials.length > 0) {
|
||||
credentials.forEach((cred) => {
|
||||
const id = cred.id as string;
|
||||
if (id) {
|
||||
const credentialTypes = Array.isArray(cred.types)
|
||||
? cred.types
|
||||
: [(cred.type as string) || "api_key"];
|
||||
credentialsSchema[id] = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
credentials_provider: [cred.provider as string],
|
||||
credentials_types: credentialTypes,
|
||||
credentials_scopes: cred.scopes as string[] | undefined,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "inputs_needed",
|
||||
toolName,
|
||||
agentName,
|
||||
agentId,
|
||||
graphVersion,
|
||||
inputSchema,
|
||||
credentialsSchema:
|
||||
Object.keys(credentialsSchema).length > 0
|
||||
? credentialsSchema
|
||||
: undefined,
|
||||
message: `Please provide the required inputs to run ${agentName}.`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Failed to extract inputs from setup info:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { INITIAL_STREAM_ID } from "../../chat-constants";
|
||||
import { useChatStore } from "../../chat-store";
|
||||
import { toast } from "sonner";
|
||||
import { useChatStream } from "../../useChatStream";
|
||||
import { usePageContext } from "../../usePageContext";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import {
|
||||
getToolIdFromMessage,
|
||||
hasToolId,
|
||||
isOperationMessage,
|
||||
type StreamChunk,
|
||||
} from "../../chat-types";
|
||||
import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
|
||||
import {
|
||||
createUserMessage,
|
||||
filterAuthMessages,
|
||||
hasSentInitialPrompt,
|
||||
markInitialPromptSent,
|
||||
processInitialMessages,
|
||||
} from "./helpers";
|
||||
|
||||
const TOOL_RESULT_TYPES = new Set([
|
||||
"tool_response",
|
||||
"agent_carousel",
|
||||
"execution_started",
|
||||
"clarification_needed",
|
||||
]);
|
||||
|
||||
// Helper to generate deduplication key for a message
|
||||
function getMessageKey(msg: ChatMessageData): string {
|
||||
if (msg.type === "message") {
|
||||
// Don't include timestamp - dedupe by role + content only
|
||||
// This handles the case where local and server timestamps differ
|
||||
// Server messages are authoritative, so duplicates from local state are filtered
|
||||
return `msg:${msg.role}:${msg.content}`;
|
||||
} else if (msg.type === "tool_call") {
|
||||
return `toolcall:${msg.toolId}`;
|
||||
} else if (TOOL_RESULT_TYPES.has(msg.type)) {
|
||||
// Unified key for all tool result types - same toolId with different types
|
||||
// (tool_response vs agent_carousel) should deduplicate to the same key
|
||||
const toolId = getToolIdFromMessage(msg);
|
||||
// If no toolId, fall back to content-based key to avoid empty key collisions
|
||||
if (!toolId) {
|
||||
return `toolresult:content:${JSON.stringify(msg).slice(0, 200)}`;
|
||||
}
|
||||
return `toolresult:${toolId}`;
|
||||
} else if (isOperationMessage(msg)) {
|
||||
const toolId = getToolIdFromMessage(msg) || "";
|
||||
return `op:${toolId}:${msg.toolName}`;
|
||||
} else {
|
||||
return `${msg.type}:${JSON.stringify(msg).slice(0, 100)}`;
|
||||
}
|
||||
}
|
||||
|
||||
interface Args {
|
||||
sessionId: string | null;
|
||||
initialMessages: SessionDetailResponse["messages"];
|
||||
initialPrompt?: string;
|
||||
onOperationStarted?: () => void;
|
||||
/** Active stream info from the server for reconnection */
|
||||
activeStream?: {
|
||||
taskId: string;
|
||||
lastMessageId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useChatContainer({
|
||||
sessionId,
|
||||
initialMessages,
|
||||
initialPrompt,
|
||||
onOperationStarted,
|
||||
activeStream,
|
||||
}: Args) {
|
||||
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
||||
const [streamingChunks, setStreamingChunks] = useState<string[]>([]);
|
||||
const [hasTextChunks, setHasTextChunks] = useState(false);
|
||||
const [isStreamingInitiated, setIsStreamingInitiated] = useState(false);
|
||||
const [isRegionBlockedModalOpen, setIsRegionBlockedModalOpen] =
|
||||
useState(false);
|
||||
const hasResponseRef = useRef(false);
|
||||
const streamingChunksRef = useRef<string[]>([]);
|
||||
const textFinalizedRef = useRef(false);
|
||||
const streamEndedRef = useRef(false);
|
||||
const previousSessionIdRef = useRef<string | null>(null);
|
||||
const {
|
||||
error,
|
||||
sendMessage: sendStreamMessage,
|
||||
stopStreaming,
|
||||
} = useChatStream();
|
||||
const activeStreams = useChatStore((s) => s.activeStreams);
|
||||
const subscribeToStream = useChatStore((s) => s.subscribeToStream);
|
||||
const setActiveTask = useChatStore((s) => s.setActiveTask);
|
||||
const getActiveTask = useChatStore((s) => s.getActiveTask);
|
||||
const reconnectToTask = useChatStore((s) => s.reconnectToTask);
|
||||
const isStreaming = isStreamingInitiated || hasTextChunks;
|
||||
// Track whether we've already connected to this activeStream to avoid duplicate connections
|
||||
const connectedActiveStreamRef = useRef<string | null>(null);
|
||||
// Track if component is mounted to prevent state updates after unmount
|
||||
const isMountedRef = useRef(true);
|
||||
// Track current dispatcher to prevent multiple dispatchers from adding messages
|
||||
const currentDispatcherIdRef = useRef(0);
|
||||
|
||||
// Set mounted flag - reset on every mount, cleanup on unmount
|
||||
useEffect(function trackMountedState() {
|
||||
isMountedRef.current = true;
|
||||
return function cleanup() {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Callback to store active task info for SSE reconnection
|
||||
function handleActiveTaskStarted(taskInfo: {
|
||||
taskId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
}) {
|
||||
if (!sessionId) return;
|
||||
setActiveTask(sessionId, {
|
||||
taskId: taskInfo.taskId,
|
||||
operationId: taskInfo.operationId,
|
||||
toolName: taskInfo.toolName,
|
||||
lastMessageId: INITIAL_STREAM_ID,
|
||||
});
|
||||
}
|
||||
|
||||
// Create dispatcher for stream events - stable reference for current sessionId
|
||||
// Each dispatcher gets a unique ID to prevent stale dispatchers from updating state
|
||||
function createDispatcher() {
|
||||
if (!sessionId) return () => {};
|
||||
// Increment dispatcher ID - only the most recent dispatcher should update state
|
||||
const dispatcherId = ++currentDispatcherIdRef.current;
|
||||
|
||||
const baseDispatcher = createStreamEventDispatcher({
|
||||
setHasTextChunks,
|
||||
setStreamingChunks,
|
||||
streamingChunksRef,
|
||||
hasResponseRef,
|
||||
textFinalizedRef,
|
||||
streamEndedRef,
|
||||
setMessages,
|
||||
setIsRegionBlockedModalOpen,
|
||||
sessionId,
|
||||
setIsStreamingInitiated,
|
||||
onOperationStarted,
|
||||
onActiveTaskStarted: handleActiveTaskStarted,
|
||||
});
|
||||
|
||||
// Wrap dispatcher to check if it's still the current one
|
||||
return function guardedDispatcher(chunk: StreamChunk) {
|
||||
// Skip if component unmounted or this is a stale dispatcher
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (dispatcherId !== currentDispatcherIdRef.current) {
|
||||
return;
|
||||
}
|
||||
baseDispatcher(chunk);
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(
|
||||
function handleSessionChange() {
|
||||
const isSessionChange = sessionId !== previousSessionIdRef.current;
|
||||
|
||||
// Handle session change - reset state
|
||||
if (isSessionChange) {
|
||||
const prevSession = previousSessionIdRef.current;
|
||||
if (prevSession) {
|
||||
stopStreaming(prevSession);
|
||||
}
|
||||
previousSessionIdRef.current = sessionId;
|
||||
connectedActiveStreamRef.current = null;
|
||||
setMessages([]);
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
setIsStreamingInitiated(false);
|
||||
hasResponseRef.current = false;
|
||||
textFinalizedRef.current = false;
|
||||
streamEndedRef.current = false;
|
||||
}
|
||||
|
||||
if (!sessionId) return;
|
||||
|
||||
// Priority 1: Check if server told us there's an active stream (most authoritative)
|
||||
if (activeStream) {
|
||||
const streamKey = `${sessionId}:${activeStream.taskId}`;
|
||||
|
||||
if (connectedActiveStreamRef.current === streamKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if there's already an active stream for this session in the store
|
||||
const existingStream = activeStreams.get(sessionId);
|
||||
if (existingStream && existingStream.status === "streaming") {
|
||||
connectedActiveStreamRef.current = streamKey;
|
||||
return;
|
||||
}
|
||||
|
||||
connectedActiveStreamRef.current = streamKey;
|
||||
|
||||
// Clear all state before reconnection to prevent duplicates
|
||||
// Server's initialMessages is authoritative; local state will be rebuilt from SSE replay
|
||||
setMessages([]);
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
textFinalizedRef.current = false;
|
||||
streamEndedRef.current = false;
|
||||
hasResponseRef.current = false;
|
||||
|
||||
setIsStreamingInitiated(true);
|
||||
setActiveTask(sessionId, {
|
||||
taskId: activeStream.taskId,
|
||||
operationId: activeStream.operationId,
|
||||
toolName: activeStream.toolName,
|
||||
lastMessageId: activeStream.lastMessageId,
|
||||
});
|
||||
reconnectToTask(
|
||||
sessionId,
|
||||
activeStream.taskId,
|
||||
activeStream.lastMessageId,
|
||||
createDispatcher(),
|
||||
);
|
||||
// Don't return cleanup here - the guarded dispatcher handles stale events
|
||||
// and the stream will complete naturally. Cleanup would prematurely stop
|
||||
// the stream when effect re-runs due to activeStreams changing.
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check localStorage/in-memory on session change
|
||||
if (!isSessionChange) return;
|
||||
|
||||
// Priority 2: Check localStorage for active task
|
||||
const activeTask = getActiveTask(sessionId);
|
||||
if (activeTask) {
|
||||
// Clear all state before reconnection to prevent duplicates
|
||||
// Server's initialMessages is authoritative; local state will be rebuilt from SSE replay
|
||||
setMessages([]);
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
textFinalizedRef.current = false;
|
||||
streamEndedRef.current = false;
|
||||
hasResponseRef.current = false;
|
||||
|
||||
setIsStreamingInitiated(true);
|
||||
reconnectToTask(
|
||||
sessionId,
|
||||
activeTask.taskId,
|
||||
activeTask.lastMessageId,
|
||||
createDispatcher(),
|
||||
);
|
||||
// Don't return cleanup here - the guarded dispatcher handles stale events
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 3: Check for an in-memory active stream (same-tab scenario)
|
||||
const inMemoryStream = activeStreams.get(sessionId);
|
||||
if (!inMemoryStream || inMemoryStream.status !== "streaming") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStreamingInitiated(true);
|
||||
const skipReplay = initialMessages.length > 0;
|
||||
return subscribeToStream(sessionId, createDispatcher(), skipReplay);
|
||||
},
|
||||
[
|
||||
sessionId,
|
||||
stopStreaming,
|
||||
activeStreams,
|
||||
subscribeToStream,
|
||||
onOperationStarted,
|
||||
getActiveTask,
|
||||
reconnectToTask,
|
||||
activeStream,
|
||||
setActiveTask,
|
||||
],
|
||||
);
|
||||
|
||||
// Collect toolIds from completed tool results in initialMessages
|
||||
// Used to filter out operation messages when their results arrive
|
||||
const completedToolIds = useMemo(() => {
|
||||
const processedInitial = processInitialMessages(initialMessages);
|
||||
const ids = new Set<string>();
|
||||
for (const msg of processedInitial) {
|
||||
if (
|
||||
msg.type === "tool_response" ||
|
||||
msg.type === "agent_carousel" ||
|
||||
msg.type === "execution_started"
|
||||
) {
|
||||
const toolId = hasToolId(msg) ? msg.toolId : undefined;
|
||||
if (toolId) {
|
||||
ids.add(toolId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [initialMessages]);
|
||||
|
||||
// Clean up local operation messages when their completed results arrive from polling
|
||||
// This effect runs when completedToolIds changes (i.e., when polling brings new results)
|
||||
useEffect(
|
||||
function cleanupCompletedOperations() {
|
||||
if (completedToolIds.size === 0) return;
|
||||
|
||||
setMessages((prev) => {
|
||||
const filtered = prev.filter((msg) => {
|
||||
if (isOperationMessage(msg)) {
|
||||
const toolId = getToolIdFromMessage(msg);
|
||||
if (toolId && completedToolIds.has(toolId)) {
|
||||
return false; // Remove - operation completed
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Only update state if something was actually filtered
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
},
|
||||
[completedToolIds],
|
||||
);
|
||||
|
||||
// Combine initial messages from backend with local streaming messages,
|
||||
// Server messages maintain correct order; only append truly new local messages
|
||||
const allMessages = useMemo(() => {
|
||||
const processedInitial = processInitialMessages(initialMessages);
|
||||
|
||||
// Build a set of keys from server messages for deduplication
|
||||
const serverKeys = new Set<string>();
|
||||
for (const msg of processedInitial) {
|
||||
serverKeys.add(getMessageKey(msg));
|
||||
}
|
||||
|
||||
// Filter local messages: remove duplicates and completed operation messages
|
||||
const newLocalMessages = messages.filter((msg) => {
|
||||
// Remove operation messages for completed tools
|
||||
if (isOperationMessage(msg)) {
|
||||
const toolId = getToolIdFromMessage(msg);
|
||||
if (toolId && completedToolIds.has(toolId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Remove messages that already exist in server data
|
||||
const key = getMessageKey(msg);
|
||||
return !serverKeys.has(key);
|
||||
});
|
||||
|
||||
// Server messages first (correct order), then new local messages
|
||||
const combined = [...processedInitial, ...newLocalMessages];
|
||||
|
||||
// Post-processing: Remove duplicate assistant messages that can occur during
|
||||
// race conditions (e.g., rapid screen switching during SSE reconnection).
|
||||
// Two assistant messages are considered duplicates if:
|
||||
// - They are both text messages with role "assistant"
|
||||
// - One message's content starts with the other's content (partial vs complete)
|
||||
// - Or they have very similar content (>80% overlap at the start)
|
||||
const deduplicated: ChatMessageData[] = [];
|
||||
for (let i = 0; i < combined.length; i++) {
|
||||
const current = combined[i];
|
||||
|
||||
// Check if this is an assistant text message
|
||||
if (current.type !== "message" || current.role !== "assistant") {
|
||||
deduplicated.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for duplicate assistant messages in the rest of the array
|
||||
let dominated = false;
|
||||
for (let j = 0; j < combined.length; j++) {
|
||||
if (i === j) continue;
|
||||
const other = combined[j];
|
||||
if (other.type !== "message" || other.role !== "assistant") continue;
|
||||
|
||||
const currentContent = current.content || "";
|
||||
const otherContent = other.content || "";
|
||||
|
||||
// Skip empty messages
|
||||
if (!currentContent.trim() || !otherContent.trim()) continue;
|
||||
|
||||
// Check if current is a prefix of other (current is incomplete version)
|
||||
if (
|
||||
otherContent.length > currentContent.length &&
|
||||
otherContent.startsWith(currentContent.slice(0, 100))
|
||||
) {
|
||||
// Current is a shorter/incomplete version of other - skip it
|
||||
dominated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if messages are nearly identical (within a small difference)
|
||||
// This catches cases where content differs only slightly
|
||||
const minLen = Math.min(currentContent.length, otherContent.length);
|
||||
const compareLen = Math.min(minLen, 200); // Compare first 200 chars
|
||||
if (
|
||||
compareLen > 50 &&
|
||||
currentContent.slice(0, compareLen) ===
|
||||
otherContent.slice(0, compareLen)
|
||||
) {
|
||||
// Same prefix - keep the longer one
|
||||
if (otherContent.length > currentContent.length) {
|
||||
dominated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dominated) {
|
||||
deduplicated.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicated;
|
||||
}, [initialMessages, messages, completedToolIds]);
|
||||
|
||||
async function sendMessage(
|
||||
content: string,
|
||||
isUserMessage: boolean = true,
|
||||
context?: { url: string; content: string },
|
||||
) {
|
||||
if (!sessionId) return;
|
||||
|
||||
setIsRegionBlockedModalOpen(false);
|
||||
if (isUserMessage) {
|
||||
const userMessage = createUserMessage(content);
|
||||
setMessages((prev) => [...filterAuthMessages(prev), userMessage]);
|
||||
} else {
|
||||
setMessages((prev) => filterAuthMessages(prev));
|
||||
}
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
setIsStreamingInitiated(true);
|
||||
hasResponseRef.current = false;
|
||||
textFinalizedRef.current = false;
|
||||
streamEndedRef.current = false;
|
||||
|
||||
try {
|
||||
await sendStreamMessage(
|
||||
sessionId,
|
||||
content,
|
||||
createDispatcher(),
|
||||
isUserMessage,
|
||||
context,
|
||||
);
|
||||
} catch (err) {
|
||||
setIsStreamingInitiated(false);
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to send message";
|
||||
toast.error("Failed to send message", {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleStopStreaming() {
|
||||
stopStreaming();
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
setIsStreamingInitiated(false);
|
||||
}
|
||||
|
||||
const { capturePageContext } = usePageContext();
|
||||
const sendMessageRef = useRef(sendMessage);
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
useEffect(
|
||||
function handleInitialPrompt() {
|
||||
if (!initialPrompt || !sessionId) return;
|
||||
if (initialMessages.length > 0) return;
|
||||
if (hasSentInitialPrompt(sessionId)) return;
|
||||
|
||||
markInitialPromptSent(sessionId);
|
||||
const context = capturePageContext();
|
||||
sendMessageRef.current(initialPrompt, true, context);
|
||||
},
|
||||
[initialPrompt, sessionId, initialMessages.length, capturePageContext],
|
||||
);
|
||||
|
||||
async function sendMessageWithContext(
|
||||
content: string,
|
||||
isUserMessage: boolean = true,
|
||||
) {
|
||||
const context = capturePageContext();
|
||||
await sendMessage(content, isUserMessage, context);
|
||||
}
|
||||
|
||||
function handleRegionModalOpenChange(open: boolean) {
|
||||
setIsRegionBlockedModalOpen(open);
|
||||
}
|
||||
|
||||
function handleRegionModalClose() {
|
||||
setIsRegionBlockedModalOpen(false);
|
||||
}
|
||||
|
||||
return {
|
||||
messages: allMessages,
|
||||
streamingChunks,
|
||||
isStreaming,
|
||||
error,
|
||||
isRegionBlockedModalOpen,
|
||||
setIsRegionBlockedModalOpen,
|
||||
sendMessageWithContext,
|
||||
handleRegionModalOpenChange,
|
||||
handleRegionModalClose,
|
||||
sendMessage,
|
||||
stopStreaming: handleStopStreaming,
|
||||
};
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
|
||||
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;
|
||||
credentialTypes: Array<
|
||||
"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.credentialTypes,
|
||||
credentials_scopes: credential.scopes,
|
||||
discriminator: undefined,
|
||||
discriminator_mapping: undefined,
|
||||
discriminator_values: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatCredentialsSetup({
|
||||
credentials,
|
||||
agentName: _agentName,
|
||||
message,
|
||||
onAllCredentialsComplete,
|
||||
onCancel: _onCancel,
|
||||
}: Props) {
|
||||
const { selectedCredentials, isAllComplete, handleCredentialSelect } =
|
||||
useChatCredentialsSetup(credentials);
|
||||
|
||||
// Track if we've already called completion to prevent double calls
|
||||
const hasCalledCompleteRef = useRef(false);
|
||||
|
||||
// Reset the completion flag when credentials change (new credential setup flow)
|
||||
useEffect(
|
||||
function resetCompletionFlag() {
|
||||
hasCalledCompleteRef.current = false;
|
||||
},
|
||||
[credentials],
|
||||
);
|
||||
|
||||
// Auto-call completion when all credentials are configured
|
||||
useEffect(
|
||||
function autoCompleteWhenReady() {
|
||||
if (isAllComplete && !hasCalledCompleteRef.current) {
|
||||
hasCalledCompleteRef.current = true;
|
||||
onAllCredentialsComplete();
|
||||
}
|
||||
},
|
||||
[isAllComplete, onAllCredentialsComplete],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group relative flex w-full justify-start gap-3 px-4 py-3">
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="group relative min-w-20 overflow-hidden rounded-xl border border-slate-100 bg-slate-50/20 px-6 py-2.5 text-sm leading-relaxed backdrop-blur-xl">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-200/20 via-slate-300/10 to-transparent" />
|
||||
<div className="relative z-10 space-y-3 text-slate-900">
|
||||
<div>
|
||||
<Text variant="h4" className="mb-1 text-slate-900">
|
||||
Credentials Required
|
||||
</Text>
|
||||
<Text variant="small" className="text-slate-600">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{credentials.map((cred, index) => {
|
||||
const schema = createSchemaFromCredentialInfo(cred);
|
||||
const isSelected = !!selectedCredentials[cred.provider];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${cred.provider}-${index}`}
|
||||
className={cn(
|
||||
"relative rounded-lg border p-3",
|
||||
isSelected
|
||||
? "border-green-500 bg-green-50/50"
|
||||
: "border-slate-200 bg-white/50",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{isSelected ? (
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className="text-green-500"
|
||||
weight="bold"
|
||||
/>
|
||||
) : (
|
||||
<WarningIcon
|
||||
size={16}
|
||||
className="text-slate-500"
|
||||
weight="bold"
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
variant="small"
|
||||
className="font-semibold text-slate-900"
|
||||
>
|
||||
{cred.providerName}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<CredentialsInput
|
||||
schema={schema}
|
||||
selectedCredentials={selectedCredentials[cred.provider]}
|
||||
onSelectCredentials={(credMeta) =>
|
||||
handleCredentialSelect(cred.provider, credMeta)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import type { CredentialInfo } from "./ChatCredentialsSetup";
|
||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api";
|
||||
|
||||
export function useChatCredentialsSetup(credentials: CredentialInfo[]) {
|
||||
const [selectedCredentials, setSelectedCredentials] = useState<
|
||||
Record<string, CredentialsMetaInput>
|
||||
>({});
|
||||
|
||||
// Check if all credentials are configured
|
||||
const isAllComplete = useMemo(
|
||||
function checkAllComplete() {
|
||||
if (credentials.length === 0) return false;
|
||||
return credentials.every((cred) => selectedCredentials[cred.provider]);
|
||||
},
|
||||
[credentials, selectedCredentials],
|
||||
);
|
||||
|
||||
function handleCredentialSelect(
|
||||
provider: string,
|
||||
credential?: CredentialsMetaInput,
|
||||
) {
|
||||
if (credential) {
|
||||
setSelectedCredentials((prev) => ({
|
||||
...prev,
|
||||
[provider]: credential,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedCredentials,
|
||||
isAllComplete,
|
||||
handleCredentialSelect,
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ChatErrorStateProps {
|
||||
error: Error;
|
||||
onRetry?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatErrorState({
|
||||
error,
|
||||
onRetry,
|
||||
className,
|
||||
}: ChatErrorStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-1 items-center justify-center p-6", className)}
|
||||
>
|
||||
<ErrorCard
|
||||
responseError={{
|
||||
message: error.message,
|
||||
}}
|
||||
context="chat session"
|
||||
onRetry={onRetry}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function ChatLoader() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 animate-loader rounded-full bg-black" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ChatLoadingStateProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatLoadingState({ className }: ChatLoadingStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-1 items-center justify-center p-6", className)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowsClockwiseIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessage";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget";
|
||||
import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup";
|
||||
import { ClarificationQuestionsWidget } from "../ClarificationQuestionsWidget/ClarificationQuestionsWidget";
|
||||
import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage";
|
||||
import { PendingOperationWidget } from "../PendingOperationWidget/PendingOperationWidget";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage";
|
||||
import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage";
|
||||
import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage";
|
||||
import { UserChatBubble } from "../UserChatBubble/UserChatBubble";
|
||||
import { useChatMessage, type ChatMessageData } from "./useChatMessage";
|
||||
|
||||
function stripInternalReasoning(content: string): string {
|
||||
const cleaned = content.replace(
|
||||
/<internal_reasoning>[\s\S]*?<\/internal_reasoning>/gi,
|
||||
"",
|
||||
);
|
||||
return cleaned.replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
function getDisplayContent(message: ChatMessageData, isUser: boolean): string {
|
||||
if (message.type !== "message") return "";
|
||||
if (isUser) return message.content;
|
||||
return stripInternalReasoning(message.content);
|
||||
}
|
||||
|
||||
export interface ChatMessageProps {
|
||||
message: ChatMessageData;
|
||||
messages?: ChatMessageData[];
|
||||
index?: number;
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
onDismissLogin?: () => void;
|
||||
onDismissCredentials?: () => void;
|
||||
onSendMessage?: (content: string, isUserMessage?: boolean) => void;
|
||||
agentOutput?: ChatMessageData;
|
||||
isFinalMessage?: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
message,
|
||||
messages = [],
|
||||
index = -1,
|
||||
isStreaming = false,
|
||||
className,
|
||||
onDismissCredentials,
|
||||
onSendMessage,
|
||||
agentOutput,
|
||||
isFinalMessage = true,
|
||||
}: ChatMessageProps) {
|
||||
const { user } = useSupabase();
|
||||
const router = useRouter();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const {
|
||||
isUser,
|
||||
isToolCall,
|
||||
isToolResponse,
|
||||
isLoginNeeded,
|
||||
isCredentialsNeeded,
|
||||
isClarificationNeeded,
|
||||
isOperationStarted,
|
||||
isOperationPending,
|
||||
isOperationInProgress,
|
||||
} = useChatMessage(message);
|
||||
const displayContent = getDisplayContent(message, isUser);
|
||||
|
||||
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 function handleCopy() {
|
||||
if (message.type !== "message") return;
|
||||
if (!displayContent) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
}
|
||||
},
|
||||
[displayContent, message],
|
||||
);
|
||||
|
||||
const handleTryAgain = useCallback(() => {
|
||||
if (message.type !== "message" || !onSendMessage) return;
|
||||
onSendMessage(message.content, message.role === "user");
|
||||
}, [message, onSendMessage]);
|
||||
|
||||
const handleViewExecution = useCallback(() => {
|
||||
if (message.type === "execution_started" && message.libraryAgentLink) {
|
||||
router.push(message.libraryAgentLink);
|
||||
}
|
||||
}, [message, router]);
|
||||
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isClarificationNeeded && message.type === "clarification_needed") {
|
||||
const hasUserReplyAfter =
|
||||
index >= 0 &&
|
||||
messages
|
||||
.slice(index + 1)
|
||||
.some((m) => m.type === "message" && m.role === "user");
|
||||
|
||||
const handleClarificationAnswers = (answers: Record<string, string>) => {
|
||||
if (onSendMessage) {
|
||||
// Iterate over questions (preserves original order) instead of answers
|
||||
const contextMessage = message.questions
|
||||
.map((q) => {
|
||||
const answer = answers[q.keyword] || "";
|
||||
return `> ${q.question}\n\n${answer}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
onSendMessage(
|
||||
`**Here are my answers:**\n\n${contextMessage}\n\nPlease proceed with creating the agent.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ClarificationQuestionsWidget
|
||||
questions={message.questions}
|
||||
message={message.message}
|
||||
sessionId={message.sessionId}
|
||||
onSubmitAnswers={handleClarificationAnswers}
|
||||
isAnswered={hasUserReplyAfter}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render login needed messages
|
||||
if (isLoginNeeded && message.type === "login_needed") {
|
||||
// If user is already logged in, show success message instead of auth prompt
|
||||
if (user) {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<div className="my-4 overflow-hidden rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-emerald-50">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-600">
|
||||
<CheckCircleIcon
|
||||
size={20}
|
||||
weight="fill"
|
||||
className="text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">
|
||||
Successfully Authenticated
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
You're now signed in and ready to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show auth prompt if not logged in
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<AuthPromptWidget
|
||||
message={message.message}
|
||||
sessionId={message.sessionId}
|
||||
agentInfo={message.agentInfo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render tool call messages
|
||||
if (isToolCall && message.type === "tool_call") {
|
||||
// Check if this tool call is currently streaming
|
||||
// A tool call is streaming if:
|
||||
// 1. isStreaming is true
|
||||
// 2. This is the last tool_call message
|
||||
// 3. There's no tool_response for this tool call yet
|
||||
const isToolCallStreaming =
|
||||
isStreaming &&
|
||||
index >= 0 &&
|
||||
(() => {
|
||||
// Find the last tool_call index
|
||||
let lastToolCallIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].type === "tool_call") {
|
||||
lastToolCallIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Check if this is the last tool_call and there's no response yet
|
||||
if (index === lastToolCallIndex) {
|
||||
// Check if there's a tool_response for this tool call
|
||||
const hasResponse = messages
|
||||
.slice(index + 1)
|
||||
.some(
|
||||
(msg) =>
|
||||
msg.type === "tool_response" && msg.toolId === message.toolId,
|
||||
);
|
||||
return !hasResponse;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<ToolCallMessage
|
||||
toolId={message.toolId}
|
||||
toolName={message.toolName}
|
||||
arguments={message.arguments}
|
||||
isStreaming={isToolCallStreaming}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render no_results messages - use dedicated component, not ToolResponseMessage
|
||||
if (message.type === "no_results") {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<NoResultsMessage
|
||||
message={message.message}
|
||||
suggestions={message.suggestions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render agent_carousel messages - use dedicated component, not ToolResponseMessage
|
||||
if (message.type === "agent_carousel") {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<AgentCarouselMessage
|
||||
agents={message.agents}
|
||||
totalCount={message.totalCount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render execution_started messages - use dedicated component, not ToolResponseMessage
|
||||
if (message.type === "execution_started") {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<ExecutionStartedMessage
|
||||
executionId={message.executionId}
|
||||
agentName={message.agentName}
|
||||
message={message.message}
|
||||
onViewExecution={
|
||||
message.libraryAgentLink ? handleViewExecution : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render operation_started messages (long-running background operations)
|
||||
if (isOperationStarted && message.type === "operation_started") {
|
||||
return (
|
||||
<PendingOperationWidget
|
||||
status="started"
|
||||
message={message.message}
|
||||
toolName={message.toolName}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render operation_pending messages (operations in progress when refreshing)
|
||||
if (isOperationPending && message.type === "operation_pending") {
|
||||
return (
|
||||
<PendingOperationWidget
|
||||
status="pending"
|
||||
message={message.message}
|
||||
toolName={message.toolName}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render operation_in_progress messages (duplicate request while operation running)
|
||||
if (isOperationInProgress && message.type === "operation_in_progress") {
|
||||
return (
|
||||
<PendingOperationWidget
|
||||
status="in_progress"
|
||||
message={message.message}
|
||||
toolName={message.toolName}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render tool response messages (but skip agent_output if it's being rendered inside assistant message)
|
||||
if (isToolResponse && message.type === "tool_response") {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<ToolResponseMessage
|
||||
toolId={message.toolId}
|
||||
toolName={message.toolName}
|
||||
result={message.result}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
</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">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col",
|
||||
isUser && "items-end",
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<UserChatBubble>
|
||||
<MarkdownContent content={displayContent} />
|
||||
</UserChatBubble>
|
||||
) : (
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={displayContent} />
|
||||
{agentOutput && agentOutput.type === "tool_response" && (
|
||||
<div className="mt-4">
|
||||
<ToolResponseMessage
|
||||
toolId={agentOutput.toolId}
|
||||
toolName={agentOutput.toolName || "Agent Output"}
|
||||
result={agentOutput.result}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AIChatBubble>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-0",
|
||||
isUser ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{isUser && onSendMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleTryAgain}
|
||||
aria-label="Try again"
|
||||
>
|
||||
<ArrowsClockwiseIcon className="size-4 text-zinc-600" />
|
||||
</Button>
|
||||
)}
|
||||
{!isUser && isFinalMessage && !isStreaming && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy message"
|
||||
className="p-1"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4 text-green-600" />
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3 text-zinc-600"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown message types
|
||||
return null;
|
||||
}
|
||||
@@ -1,157 +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;
|
||||
credentialTypes: Array<
|
||||
"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";
|
||||
toolId: string;
|
||||
toolName: string;
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version?: number;
|
||||
image_url?: string;
|
||||
}>;
|
||||
totalCount?: number;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "execution_started";
|
||||
toolId: string;
|
||||
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;
|
||||
}
|
||||
| {
|
||||
type: "clarification_needed";
|
||||
toolName: string;
|
||||
questions: Array<{
|
||||
question: string;
|
||||
keyword: string;
|
||||
example?: string;
|
||||
}>;
|
||||
message: string;
|
||||
sessionId: string;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "operation_started";
|
||||
toolName: string;
|
||||
toolId: string;
|
||||
operationId: string;
|
||||
taskId?: string; // For SSE reconnection
|
||||
message: string;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "operation_pending";
|
||||
toolName: string;
|
||||
toolId: string;
|
||||
operationId: string;
|
||||
message: string;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
type: "operation_in_progress";
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
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",
|
||||
isClarificationNeeded: message.type === "clarification_needed",
|
||||
isOperationStarted: message.type === "operation_started",
|
||||
isOperationPending: message.type === "operation_pending",
|
||||
isOperationInProgress: message.type === "operation_in_progress",
|
||||
};
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
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 } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface ClarifyingQuestion {
|
||||
question: string;
|
||||
keyword: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
questions: ClarifyingQuestion[];
|
||||
message: string;
|
||||
sessionId?: string;
|
||||
onSubmitAnswers: (answers: Record<string, string>) => void;
|
||||
onCancel?: () => void;
|
||||
isAnswered?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getStorageKey(sessionId?: string): string | null {
|
||||
if (!sessionId) return null;
|
||||
return `clarification_answers_${sessionId}`;
|
||||
}
|
||||
|
||||
export function ClarificationQuestionsWidget({
|
||||
questions,
|
||||
message,
|
||||
sessionId,
|
||||
onSubmitAnswers,
|
||||
onCancel,
|
||||
isAnswered = false,
|
||||
className,
|
||||
}: Props) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const lastSessionIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const storageKey = getStorageKey(sessionId);
|
||||
if (!storageKey) {
|
||||
setAnswers({});
|
||||
setIsSubmitted(false);
|
||||
lastSessionIdRef.current = sessionId;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as Record<string, string>;
|
||||
setAnswers(parsed);
|
||||
} else {
|
||||
setAnswers({});
|
||||
}
|
||||
setIsSubmitted(false);
|
||||
} catch {
|
||||
setAnswers({});
|
||||
setIsSubmitted(false);
|
||||
}
|
||||
lastSessionIdRef.current = sessionId;
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSessionIdRef.current !== sessionId) {
|
||||
return;
|
||||
}
|
||||
const storageKey = getStorageKey(sessionId);
|
||||
if (!storageKey) return;
|
||||
|
||||
const hasAnswers = Object.values(answers).some((v) => v.trim());
|
||||
try {
|
||||
if (hasAnswers) {
|
||||
localStorage.setItem(storageKey, JSON.stringify(answers));
|
||||
} else {
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch {}
|
||||
}, [answers, sessionId]);
|
||||
|
||||
function handleAnswerChange(keyword: string, value: string) {
|
||||
setAnswers((prev) => ({ ...prev, [keyword]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
|
||||
if (!allAnswered) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitted(true);
|
||||
onSubmitAnswers(answers);
|
||||
|
||||
const storageKey = getStorageKey(sessionId);
|
||||
try {
|
||||
if (storageKey) {
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
|
||||
|
||||
if (isAnswered || isSubmitted) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex w-full justify-start gap-3 px-4 py-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Card className="w-full p-4">
|
||||
<Text variant="h4" className="mb-1 text-slate-900">
|
||||
Answers submitted
|
||||
</Text>
|
||||
<Text variant="small" className="text-slate-600">
|
||||
Processing your responses...
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex w-full justify-start gap-3 px-4 py-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Card className="w-full space-y-4 rounded-xl p-4">
|
||||
<div>
|
||||
<Text variant="h4" className="mb-1 text-slate-900">
|
||||
I need more information
|
||||
</Text>
|
||||
<Text variant="body" className="italic text-slate-600">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{questions.map((q, index) => {
|
||||
const isAnswered = !!answers[q.keyword]?.trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${q.keyword}-${index}`}
|
||||
className={cn(
|
||||
"relative rounded-lg border p-3",
|
||||
isAnswered
|
||||
? "border-green-500 bg-green-50/50"
|
||||
: "border-slate-100 bg-white/50",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
{isAnswered ? (
|
||||
<CheckCircleIcon
|
||||
size={16}
|
||||
className="mt-0.5 text-green-500"
|
||||
weight="bold"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative top-0 flex h-6 w-6 items-center justify-center rounded-full border border-purple-200 text-xs text-purple-400">
|
||||
{index + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Text
|
||||
variant="body"
|
||||
className="mb-2 font-semibold text-slate-900"
|
||||
>
|
||||
{q.question}
|
||||
</Text>
|
||||
{q.example && (
|
||||
<Text
|
||||
variant="body"
|
||||
className="mb-2 italic text-slate-500"
|
||||
>
|
||||
Example: {q.example}
|
||||
</Text>
|
||||
)}
|
||||
<Input
|
||||
type="textarea"
|
||||
id={`clarification-${q.keyword}-${index}`}
|
||||
label={q.question}
|
||||
hideLabel
|
||||
placeholder="Your answer..."
|
||||
rows={2}
|
||||
value={answers[q.keyword] || ""}
|
||||
onChange={(e) =>
|
||||
handleAnswerChange(q.keyword, e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allAnswered}
|
||||
className="flex-1"
|
||||
variant="primary"
|
||||
>
|
||||
Submit Answers
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button onClick={onCancel} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowSquareOut, CheckCircle, Play } from "@phosphor-icons/react";
|
||||
|
||||
export interface ExecutionStartedMessageProps {
|
||||
executionId: string;
|
||||
agentName?: string;
|
||||
message?: string;
|
||||
onViewExecution?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExecutionStartedMessage({
|
||||
executionId,
|
||||
agentName,
|
||||
message = "Agent execution started successfully",
|
||||
onViewExecution,
|
||||
className,
|
||||
}: ExecutionStartedMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 my-2 flex flex-col gap-4 rounded-lg border border-green-200 bg-green-50 p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Icon & Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-green-500">
|
||||
<CheckCircle size={24} weight="bold" className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Text variant="h3" className="mb-1 text-green-900">
|
||||
Execution Started
|
||||
</Text>
|
||||
<Text variant="body" className="text-green-700">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="rounded-md bg-green-100 p-4">
|
||||
<div className="space-y-2">
|
||||
{agentName && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="small" className="font-semibold text-green-900">
|
||||
Agent:
|
||||
</Text>
|
||||
<Text variant="body" className="text-green-800">
|
||||
{agentName}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="small" className="font-semibold text-green-900">
|
||||
Execution ID:
|
||||
</Text>
|
||||
<Text variant="small" className="font-mono text-green-800">
|
||||
{executionId.slice(0, 16)}...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{onViewExecution && (
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={onViewExecution}
|
||||
variant="primary"
|
||||
className="flex flex-1 items-center justify-center gap-2"
|
||||
>
|
||||
<ArrowSquareOut size={20} weight="bold" />
|
||||
View Execution
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<Play size={16} weight="fill" />
|
||||
<Text variant="small">
|
||||
Your agent is now running. You can monitor its progress in the monitor
|
||||
page.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EyeSlash } from "@phosphor-icons/react";
|
||||
import React, { useState } 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a workspace:// URL to a proxy URL that routes through Next.js to the backend.
|
||||
* workspace://abc123 -> /api/proxy/api/workspace/files/abc123/download
|
||||
*
|
||||
* Uses the generated API URL helper and routes through the Next.js proxy
|
||||
* which handles authentication and proper backend routing.
|
||||
*/
|
||||
/**
|
||||
* URL transformer for ReactMarkdown.
|
||||
* Converts workspace:// URLs to proxy URLs that route through Next.js to the backend.
|
||||
* workspace://abc123 -> /api/proxy/api/workspace/files/abc123/download
|
||||
*
|
||||
* This is needed because ReactMarkdown sanitizes URLs and only allows
|
||||
* http, https, mailto, and tel protocols by default.
|
||||
*/
|
||||
function resolveWorkspaceUrl(src: string): string {
|
||||
if (src.startsWith("workspace://")) {
|
||||
// Strip MIME type fragment if present (e.g., workspace://abc123#video/mp4 → abc123)
|
||||
const withoutPrefix = src.replace("workspace://", "");
|
||||
const fileId = withoutPrefix.split("#")[0];
|
||||
// Use the generated API URL helper to get the correct path
|
||||
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
|
||||
// Route through the Next.js proxy (same pattern as customMutator for client-side)
|
||||
return `/api/proxy${apiPath}`;
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the image URL is a workspace file (AI cannot see these yet).
|
||||
* After URL transformation, workspace files have URLs like /api/proxy/api/workspace/files/...
|
||||
*/
|
||||
function isWorkspaceImage(src: string | undefined): boolean {
|
||||
return src?.includes("/workspace/files/") ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a workspace video with controls and an optional "AI cannot see" badge.
|
||||
*/
|
||||
function WorkspaceVideo({
|
||||
src,
|
||||
aiCannotSee,
|
||||
}: {
|
||||
src: string;
|
||||
aiCannotSee: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="relative my-2 inline-block">
|
||||
<video
|
||||
controls
|
||||
className="h-auto max-w-full rounded-md border border-zinc-200"
|
||||
preload="metadata"
|
||||
>
|
||||
<source src={src} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{aiCannotSee && (
|
||||
<span
|
||||
className="absolute bottom-2 right-2 flex items-center gap-1 rounded bg-black/70 px-2 py-1 text-xs text-white"
|
||||
title="The AI cannot see this video"
|
||||
>
|
||||
<EyeSlash size={14} />
|
||||
<span>AI cannot see this video</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom image component that shows an indicator when the AI cannot see the image.
|
||||
* Also handles the "video:" alt-text prefix convention to render <video> elements.
|
||||
* For workspace files with unknown types, falls back to <video> if <img> fails.
|
||||
* Note: src is already transformed by urlTransform, so workspace:// is now /api/workspace/...
|
||||
*/
|
||||
function MarkdownImage(props: Record<string, unknown>) {
|
||||
const src = props.src as string | undefined;
|
||||
const alt = props.alt as string | undefined;
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
|
||||
const aiCannotSee = isWorkspaceImage(src);
|
||||
|
||||
// If no src, show a placeholder
|
||||
if (!src) {
|
||||
return (
|
||||
<span className="my-2 inline-block rounded border border-amber-200 bg-amber-50 px-2 py-1 text-sm text-amber-700">
|
||||
[Image: {alt || "missing src"}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Detect video: prefix in alt text (set by formatOutputValue in helpers.ts)
|
||||
if (alt?.startsWith("video:")) {
|
||||
return <WorkspaceVideo src={src} aiCannotSee={aiCannotSee} />;
|
||||
}
|
||||
|
||||
// If the <img> failed to load and this is a workspace file, try as video.
|
||||
// This handles generic output keys like "file_out" where the MIME type
|
||||
// isn't known from the key name alone.
|
||||
if (imgFailed && aiCannotSee) {
|
||||
return <WorkspaceVideo src={src} aiCannotSee={aiCannotSee} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative my-2 inline-block">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || "Image"}
|
||||
className="h-auto max-w-full rounded-md border border-zinc-200"
|
||||
loading="lazy"
|
||||
onError={() => {
|
||||
if (aiCannotSee) setImgFailed(true);
|
||||
}}
|
||||
/>
|
||||
{aiCannotSee && (
|
||||
<span
|
||||
className="absolute bottom-2 right-2 flex items-center gap-1 rounded bg-black/70 px-2 py-1 text-xs text-white"
|
||||
title="The AI cannot see this image"
|
||||
>
|
||||
<EyeSlash size={14} />
|
||||
<span>AI cannot see this image</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={cn("markdown-content", className)}>
|
||||
<ReactMarkdown
|
||||
skipHtml={true}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
urlTransform={resolveWorkspaceUrl}
|
||||
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>
|
||||
),
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<MarkdownImage src={src} alt={alt} {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +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-purple-100",
|
||||
border: "border-purple-100",
|
||||
text: "text-slate-900",
|
||||
};
|
||||
|
||||
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")} />
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 transition-all duration-500 ease-in-out",
|
||||
theme.text,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
|
||||
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
|
||||
import { LastToolResponse } from "./components/LastToolResponse/LastToolResponse";
|
||||
import { MessageItem } from "./components/MessageItem/MessageItem";
|
||||
import { findLastMessageIndex, shouldSkipAgentOutput } from "./helpers";
|
||||
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 className="relative flex min-h-0 flex-1 flex-col">
|
||||
{/* Top fade shadow */}
|
||||
<div className="pointer-events-none absolute top-0 z-10 h-8 w-full bg-gradient-to-b from-[#f8f8f9] to-transparent" />
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto overflow-x-hidden",
|
||||
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex min-w-0 flex-col hyphens-auto break-words py-4">
|
||||
{/* Render all persisted messages */}
|
||||
{(() => {
|
||||
const lastAssistantMessageIndex = findLastMessageIndex(
|
||||
messages,
|
||||
(msg) => msg.type === "message" && msg.role === "assistant",
|
||||
);
|
||||
|
||||
const lastToolResponseIndex = findLastMessageIndex(
|
||||
messages,
|
||||
(msg) => msg.type === "tool_response",
|
||||
);
|
||||
|
||||
return messages.map((message, index) => {
|
||||
// Skip agent_output tool_responses that should be rendered inside assistant messages
|
||||
if (shouldSkipAgentOutput(message, messages[index - 1])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render last tool_response as AIChatBubble
|
||||
if (
|
||||
message.type === "tool_response" &&
|
||||
index === lastToolResponseIndex
|
||||
) {
|
||||
return (
|
||||
<LastToolResponse
|
||||
key={index}
|
||||
message={message}
|
||||
prevMessage={messages[index - 1]}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
key={index}
|
||||
message={message}
|
||||
messages={messages}
|
||||
index={index}
|
||||
lastAssistantMessageIndex={lastAssistantMessageIndex}
|
||||
isStreaming={isStreaming}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Bottom fade shadow */}
|
||||
<div className="pointer-events-none absolute bottom-0 z-10 h-8 w-full bg-gradient-to-t from-[#f8f8f9] to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { ChatMessageData } from "../../../ChatMessage/useChatMessage";
|
||||
import { ToolResponseMessage } from "../../../ToolResponseMessage/ToolResponseMessage";
|
||||
import { shouldSkipAgentOutput } from "../../helpers";
|
||||
|
||||
export interface LastToolResponseProps {
|
||||
message: ChatMessageData;
|
||||
prevMessage: ChatMessageData | undefined;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function LastToolResponse({
|
||||
message,
|
||||
prevMessage,
|
||||
onSendMessage,
|
||||
}: LastToolResponseProps) {
|
||||
if (message.type !== "tool_response") return null;
|
||||
|
||||
if (shouldSkipAgentOutput(message, prevMessage)) return null;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 overflow-x-hidden hyphens-auto break-words px-4 py-2">
|
||||
<ToolResponseMessage
|
||||
toolId={message.toolId}
|
||||
toolName={message.toolName}
|
||||
result={message.result}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ChatMessage } from "../../../ChatMessage/ChatMessage";
|
||||
import type { ChatMessageData } from "../../../ChatMessage/useChatMessage";
|
||||
import { useMessageItem } from "./useMessageItem";
|
||||
|
||||
export interface MessageItemProps {
|
||||
message: ChatMessageData;
|
||||
messages: ChatMessageData[];
|
||||
index: number;
|
||||
lastAssistantMessageIndex: number;
|
||||
isStreaming?: boolean;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function MessageItem({
|
||||
message,
|
||||
messages,
|
||||
index,
|
||||
lastAssistantMessageIndex,
|
||||
isStreaming = false,
|
||||
onSendMessage,
|
||||
}: MessageItemProps) {
|
||||
const { messageToRender, agentOutput, isFinalMessage } = useMessageItem({
|
||||
message,
|
||||
messages,
|
||||
index,
|
||||
lastAssistantMessageIndex,
|
||||
});
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
message={messageToRender}
|
||||
messages={messages}
|
||||
index={index}
|
||||
isStreaming={isStreaming}
|
||||
onSendMessage={onSendMessage}
|
||||
agentOutput={agentOutput}
|
||||
isFinalMessage={isFinalMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { ChatMessageData } from "../../../ChatMessage/useChatMessage";
|
||||
import { isAgentOutputResult, isToolOutputPattern } from "../../helpers";
|
||||
|
||||
export interface UseMessageItemArgs {
|
||||
message: ChatMessageData;
|
||||
messages: ChatMessageData[];
|
||||
index: number;
|
||||
lastAssistantMessageIndex: number;
|
||||
}
|
||||
|
||||
export function useMessageItem({
|
||||
message,
|
||||
messages,
|
||||
index,
|
||||
lastAssistantMessageIndex,
|
||||
}: UseMessageItemArgs) {
|
||||
let agentOutput: ChatMessageData | undefined;
|
||||
let messageToRender: ChatMessageData = message;
|
||||
|
||||
// Check if assistant message follows a tool_call and looks like a tool output
|
||||
if (message.type === "message" && message.role === "assistant") {
|
||||
const prevMessage = messages[index - 1];
|
||||
|
||||
// Check if next message is an agent_output tool_response to include in current assistant message
|
||||
const nextMessage = messages[index + 1];
|
||||
if (
|
||||
nextMessage &&
|
||||
nextMessage.type === "tool_response" &&
|
||||
nextMessage.result
|
||||
) {
|
||||
if (isAgentOutputResult(nextMessage.result)) {
|
||||
agentOutput = nextMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Only convert to tool_response if it follows a tool_call AND looks like a tool output
|
||||
if (prevMessage && prevMessage.type === "tool_call") {
|
||||
if (isToolOutputPattern(message.content)) {
|
||||
// Convert this message to a tool_response format for rendering
|
||||
messageToRender = {
|
||||
type: "tool_response",
|
||||
toolId: prevMessage.toolId,
|
||||
toolName: prevMessage.toolName,
|
||||
result: message.content,
|
||||
success: true,
|
||||
timestamp: message.timestamp,
|
||||
} as ChatMessageData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isFinalMessage =
|
||||
messageToRender.type !== "message" ||
|
||||
messageToRender.role !== "assistant" ||
|
||||
index === lastAssistantMessageIndex;
|
||||
|
||||
return {
|
||||
messageToRender,
|
||||
agentOutput,
|
||||
isFinalMessage,
|
||||
};
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
|
||||
export function parseToolResult(
|
||||
result: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
try {
|
||||
return typeof result === "string"
|
||||
? JSON.parse(result)
|
||||
: (result as Record<string, unknown>);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAgentOutputResult(result: unknown): boolean {
|
||||
const parsed = parseToolResult(result);
|
||||
return parsed?.type === "agent_output";
|
||||
}
|
||||
|
||||
export function isToolOutputPattern(content: string): boolean {
|
||||
const normalizedContent = content.toLowerCase().trim();
|
||||
|
||||
return (
|
||||
normalizedContent.startsWith("no agents found") ||
|
||||
normalizedContent.startsWith("no results found") ||
|
||||
normalizedContent.includes("no agents found matching") ||
|
||||
!!normalizedContent.match(/^no \w+ found/i) ||
|
||||
(content.length < 150 && normalizedContent.includes("try different")) ||
|
||||
(content.length < 200 &&
|
||||
!normalizedContent.includes("i'll") &&
|
||||
!normalizedContent.includes("let me") &&
|
||||
!normalizedContent.includes("i can") &&
|
||||
!normalizedContent.includes("i will"))
|
||||
);
|
||||
}
|
||||
|
||||
export function formatToolResultValue(result: unknown): string {
|
||||
return typeof result === "string"
|
||||
? result
|
||||
: result
|
||||
? JSON.stringify(result, null, 2)
|
||||
: "";
|
||||
}
|
||||
|
||||
export function findLastMessageIndex(
|
||||
messages: ChatMessageData[],
|
||||
predicate: (msg: ChatMessageData) => boolean,
|
||||
): number {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (predicate(messages[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function shouldSkipAgentOutput(
|
||||
message: ChatMessageData,
|
||||
prevMessage: ChatMessageData | undefined,
|
||||
): boolean {
|
||||
if (message.type !== "tool_response" || !message.result) return false;
|
||||
|
||||
const isAgentOutput = isAgentOutputResult(message.result);
|
||||
return (
|
||||
isAgentOutput &&
|
||||
!!prevMessage &&
|
||||
prevMessage.type === "message" &&
|
||||
prevMessage.role === "assistant"
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface UseMessageListArgs {
|
||||
messageCount: number;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function useMessageList({
|
||||
messageCount,
|
||||
isStreaming,
|
||||
}: UseMessageListArgs) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messageCount, isStreaming, scrollToBottom]);
|
||||
|
||||
return {
|
||||
messagesEndRef,
|
||||
messagesContainerRef,
|
||||
scrollToBottom,
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MagnifyingGlass, X } from "@phosphor-icons/react";
|
||||
|
||||
export interface NoResultsMessageProps {
|
||||
message: string;
|
||||
suggestions?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NoResultsMessage({
|
||||
message,
|
||||
suggestions = [],
|
||||
className,
|
||||
}: NoResultsMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 my-2 flex flex-col items-center gap-4 rounded-lg border border-gray-200 bg-gray-50 p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
|
||||
<MagnifyingGlass size={32} weight="bold" className="text-gray-500" />
|
||||
</div>
|
||||
<div className="absolute -right-1 -top-1 flex h-8 w-8 items-center justify-center rounded-full bg-gray-400">
|
||||
<X size={20} weight="bold" className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="text-center">
|
||||
<Text variant="h3" className="mb-2 text-gray-900">
|
||||
No Results Found
|
||||
</Text>
|
||||
<Text variant="body" className="text-gray-700">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="w-full space-y-2">
|
||||
<Text variant="small" className="font-semibold text-gray-900">
|
||||
Try these suggestions:
|
||||
</Text>
|
||||
<ul className="space-y-1 rounded-md bg-gray-100 p-4">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-start gap-2 text-sm text-gray-700"
|
||||
>
|
||||
<span className="mt-1 text-gray-500">•</span>
|
||||
<span>{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/atoms/Card/Card";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CircleNotch, CheckCircle, XCircle } from "@phosphor-icons/react";
|
||||
|
||||
type OperationStatus =
|
||||
| "pending"
|
||||
| "started"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "error";
|
||||
|
||||
interface Props {
|
||||
status: OperationStatus;
|
||||
message: string;
|
||||
toolName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getOperationTitle(toolName?: string): string {
|
||||
if (!toolName) return "Operation";
|
||||
// Convert tool name to human-readable format
|
||||
// e.g., "create_agent" -> "Creating Agent", "edit_agent" -> "Editing Agent"
|
||||
if (toolName === "create_agent") return "Creating Agent";
|
||||
if (toolName === "edit_agent") return "Editing Agent";
|
||||
// Default: capitalize and format tool name
|
||||
return toolName
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function PendingOperationWidget({
|
||||
status,
|
||||
message,
|
||||
toolName,
|
||||
className,
|
||||
}: Props) {
|
||||
const isPending =
|
||||
status === "pending" || status === "started" || status === "in_progress";
|
||||
const isCompleted = status === "completed";
|
||||
const isError = status === "error";
|
||||
|
||||
const operationTitle = getOperationTitle(toolName);
|
||||
|
||||
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={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-lg",
|
||||
isPending && "bg-blue-500",
|
||||
isCompleted && "bg-green-500",
|
||||
isError && "bg-red-500",
|
||||
)}
|
||||
>
|
||||
{isPending && (
|
||||
<CircleNotch
|
||||
className="h-4 w-4 animate-spin text-white"
|
||||
weight="bold"
|
||||
/>
|
||||
)}
|
||||
{isCompleted && (
|
||||
<CheckCircle className="h-4 w-4 text-white" weight="bold" />
|
||||
)}
|
||||
{isError && (
|
||||
<XCircle className="h-4 w-4 text-white" weight="bold" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Card className="space-y-2 p-4">
|
||||
<div>
|
||||
<Text variant="h4" className="mb-1 text-slate-900">
|
||||
{isPending && operationTitle}
|
||||
{isCompleted && `${operationTitle} Complete`}
|
||||
{isError && `${operationTitle} Failed`}
|
||||
</Text>
|
||||
<Text variant="small" className="text-slate-600">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<Text variant="small" className="italic text-slate-500">
|
||||
Check your library in a few minutes.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{toolName && (
|
||||
<Text variant="small" className="text-slate-400">
|
||||
Tool: {toolName}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface QuickActionsWelcomeProps {
|
||||
title: string;
|
||||
description: string;
|
||||
actions: string[];
|
||||
onActionClick: (action: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuickActionsWelcome({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
onActionClick,
|
||||
disabled = false,
|
||||
className,
|
||||
}: QuickActionsWelcomeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-1 items-center justify-center p-8", className)}
|
||||
>
|
||||
<div className="w-full max-w-3xl">
|
||||
<div className="mb-12 text-center">
|
||||
<Text
|
||||
variant="h2"
|
||||
className="mb-3 text-2xl font-semibold text-zinc-900"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{actions.map((action) => {
|
||||
// Use slate theme for all cards
|
||||
const theme = {
|
||||
bg: "bg-slate-50/10",
|
||||
border: "border-slate-100",
|
||||
hoverBg: "hover:bg-slate-50/20",
|
||||
hoverBorder: "hover:border-slate-200",
|
||||
gradient: "from-slate-200/20 via-slate-300/10 to-transparent",
|
||||
text: "text-slate-900",
|
||||
hoverText: "group-hover:text-slate-900",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action}
|
||||
onClick={() => onActionClick(action)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-xl border p-5 text-left backdrop-blur-xl",
|
||||
"transition-all duration-200",
|
||||
theme.bg,
|
||||
theme.border,
|
||||
theme.hoverBg,
|
||||
theme.hoverBorder,
|
||||
"hover:shadow-sm",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none",
|
||||
)}
|
||||
>
|
||||
{/* Gradient flare background */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gradient-to-br",
|
||||
theme.gradient,
|
||||
)}
|
||||
/>
|
||||
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn(
|
||||
"relative z-10 font-medium",
|
||||
theme.text,
|
||||
theme.hoverText,
|
||||
)}
|
||||
>
|
||||
{action}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Drawer } from "vaul";
|
||||
|
||||
interface SessionsDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
currentSessionId?: string | null;
|
||||
}
|
||||
|
||||
export function SessionsDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectSession,
|
||||
currentSessionId,
|
||||
}: SessionsDrawerProps) {
|
||||
const { data, isLoading } = useGetV2ListSessions(
|
||||
{ limit: 100 },
|
||||
{
|
||||
query: {
|
||||
enabled: isOpen,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sessions =
|
||||
data?.status === 200
|
||||
? data.data.sessions.filter((session) => {
|
||||
// Filter out sessions without messages (sessions that were never updated)
|
||||
// If updated_at equals created_at, the session was created but never had messages
|
||||
return session.updated_at !== session.created_at;
|
||||
})
|
||||
: [];
|
||||
|
||||
function handleSelectSession(sessionId: string) {
|
||||
onSelectSession(sessionId);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
direction="right"
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
|
||||
<Drawer.Content
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-[70] flex h-full w-96 flex-col border-l border-zinc-200 bg-white",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Drawer.Title className="text-lg font-semibold">
|
||||
Chat Sessions
|
||||
</Drawer.Title>
|
||||
<button
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
className="flex size-8 items-center justify-center rounded hover:bg-zinc-100"
|
||||
>
|
||||
<X width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading sessions...
|
||||
</Text>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
You don't have previously started chats
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => {
|
||||
const isActive = session.id === currentSessionId;
|
||||
const updatedAt = session.updated_at
|
||||
? formatDistanceToNow(new Date(session.updated_at), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-3 text-left transition-colors",
|
||||
isActive
|
||||
? "border-indigo-500 bg-zinc-50"
|
||||
: "border-zinc-200 bg-zinc-100/50 hover:border-zinc-300 hover:bg-zinc-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn(
|
||||
"font-medium",
|
||||
isActive ? "text-indigo-900" : "text-zinc-900",
|
||||
)}
|
||||
>
|
||||
{session.title || "Untitled Chat"}
|
||||
</Text>
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span>{session.id.slice(0, 8)}...</span>
|
||||
{updatedAt && <span>•</span>}
|
||||
<span>{updatedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
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 min-w-0 flex-1 flex-col">
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={displayText} />
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface UseStreamingMessageArgs {
|
||||
chunks: string[];
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function useStreamingMessage({
|
||||
chunks,
|
||||
onComplete,
|
||||
}: UseStreamingMessageArgs) {
|
||||
const [isComplete, _setIsComplete] = useState(false);
|
||||
const displayText = chunks.join("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}, [isComplete, onComplete]);
|
||||
|
||||
return {
|
||||
displayText,
|
||||
isComplete,
|
||||
};
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Progress } from "@/components/atoms/Progress/Progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { useAsymptoticProgress } from "../ToolCallMessage/useAsymptoticProgress";
|
||||
|
||||
export interface ThinkingMessageProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
const [showSlowLoader, setShowSlowLoader] = useState(false);
|
||||
const [showCoffeeMessage, setShowCoffeeMessage] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const coffeeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const progress = useAsymptoticProgress(showCoffeeMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current === null) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowSlowLoader(true);
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
if (coffeeTimerRef.current === null) {
|
||||
coffeeTimerRef.current = setTimeout(() => {
|
||||
setShowCoffeeMessage(true);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (coffeeTimerRef.current) {
|
||||
clearTimeout(coffeeTimerRef.current);
|
||||
coffeeTimerRef.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 min-w-0 flex-1 flex-col">
|
||||
<AIChatBubble>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
{showCoffeeMessage ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex w-full max-w-[280px] flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>Working on it...</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2 w-full" />
|
||||
</div>
|
||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||
This could take a few minutes, grab a coffee ☕️
|
||||
</span>
|
||||
</div>
|
||||
) : showSlowLoader ? (
|
||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||
Taking a bit more time...
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||
Thinking...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolArguments } from "@/types/chat";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import {
|
||||
formatToolArguments,
|
||||
getToolActionPhrase,
|
||||
getToolIcon,
|
||||
} from "./helpers";
|
||||
|
||||
export interface ToolCallMessageProps {
|
||||
toolId?: string;
|
||||
toolName: string;
|
||||
arguments?: ToolArguments;
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolCallMessage({
|
||||
toolName,
|
||||
arguments: toolArguments,
|
||||
isStreaming = false,
|
||||
className,
|
||||
}: ToolCallMessageProps) {
|
||||
const actionPhrase = getToolActionPhrase(toolName);
|
||||
const argumentsText = formatToolArguments(toolName, toolArguments);
|
||||
const displayText = `${actionPhrase}${argumentsText}`;
|
||||
const IconComponent = getToolIcon(toolName);
|
||||
|
||||
return (
|
||||
<AIChatBubble className={className}>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent
|
||||
size={14}
|
||||
weight={isStreaming ? "regular" : "regular"}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isStreaming ? "text-neutral-500" : "text-neutral-400",
|
||||
)}
|
||||
/>
|
||||
<Text
|
||||
variant="small"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
isStreaming
|
||||
? "bg-gradient-to-r from-neutral-600 via-neutral-500 to-neutral-600 bg-[length:200%_100%] bg-clip-text text-transparent [animation:shimmer_2s_ease-in-out_infinite]"
|
||||
: "text-neutral-500",
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
</Text>
|
||||
</div>
|
||||
</AIChatBubble>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import type { ToolArguments } from "@/types/chat";
|
||||
import {
|
||||
BrainIcon,
|
||||
EyeIcon,
|
||||
FileMagnifyingGlassIcon,
|
||||
FileTextIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PackageIcon,
|
||||
PencilLineIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SquaresFourIcon,
|
||||
type Icon,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
/**
|
||||
* 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 normalizedName = toolName.trim();
|
||||
if (!normalizedName) return "Executing";
|
||||
if (normalizedName.toLowerCase().startsWith("executing")) {
|
||||
return normalizedName;
|
||||
}
|
||||
if (normalizedName.toLowerCase() === "unknown") return "Executing";
|
||||
const toolActionPhrases: Record<string, string> = {
|
||||
add_understanding: "Updating your business information",
|
||||
create_agent: "Creating a new agent",
|
||||
edit_agent: "Editing the agent",
|
||||
find_agent: "Looking for agents in the marketplace",
|
||||
find_block: "Searching for blocks",
|
||||
find_library_agent: "Looking for library agents",
|
||||
run_agent: "Running the agent",
|
||||
run_block: "Running the block",
|
||||
view_agent_output: "Retrieving agent output",
|
||||
search_docs: "Searching documentation",
|
||||
get_doc_page: "Loading documentation page",
|
||||
agent_carousel: "Looking for agents in the marketplace",
|
||||
execution_started: "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
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())
|
||||
.replace(/^/, "Executing ")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats tool call arguments into user-friendly text.
|
||||
* Handles different tool types and formats their arguments nicely.
|
||||
*
|
||||
* @param toolName - The tool name
|
||||
* @param args - The tool arguments
|
||||
* @returns Formatted user-friendly text to append to action phrase
|
||||
*/
|
||||
export function formatToolArguments(
|
||||
toolName: string,
|
||||
args: ToolArguments | undefined,
|
||||
): string {
|
||||
if (!args || Object.keys(args).length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "find_agent":
|
||||
case "find_library_agent":
|
||||
case "agent_carousel":
|
||||
if (args.query) {
|
||||
return ` matching "${args.query as string}"`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "find_block":
|
||||
if (args.query) {
|
||||
return ` matching "${args.query as string}"`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "search_docs":
|
||||
if (args.query) {
|
||||
return ` for "${args.query as string}"`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "get_doc_page":
|
||||
if (args.path) {
|
||||
return ` "${args.path as string}"`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "run_agent":
|
||||
if (args.username_agent_slug) {
|
||||
return ` "${args.username_agent_slug as string}"`;
|
||||
}
|
||||
if (args.library_agent_id) {
|
||||
return ` (library agent)`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "run_block":
|
||||
if (args.block_id) {
|
||||
return ` "${args.block_id as string}"`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "view_agent_output":
|
||||
if (args.library_agent_id) {
|
||||
return ` (library agent)`;
|
||||
}
|
||||
if (args.username_agent_slug) {
|
||||
return ` "${args.username_agent_slug as string}"`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "create_agent":
|
||||
case "edit_agent":
|
||||
if (args.name) {
|
||||
return ` "${args.name as string}"`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "add_understanding":
|
||||
const understandingFields = Object.entries(args)
|
||||
.filter(
|
||||
([_, value]) => value !== null && value !== undefined && value !== "",
|
||||
)
|
||||
.map(([key, value]) => {
|
||||
if (key === "user_name" && typeof value === "string") {
|
||||
return `for ${value}`;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return `${key}: ${value}`;
|
||||
}
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return `${key}: ${value.slice(0, 2).join(", ")}${value.length > 2 ? ` (+${value.length - 2} more)` : ""}`;
|
||||
}
|
||||
return key;
|
||||
});
|
||||
if (understandingFields.length > 0) {
|
||||
return ` ${understandingFields[0]}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tool names to their corresponding Phosphor icon components.
|
||||
*
|
||||
* @param toolName - The tool name from the backend
|
||||
* @returns The Icon component for the tool
|
||||
*/
|
||||
export function getToolIcon(toolName: string): Icon {
|
||||
const iconMap: Record<string, Icon> = {
|
||||
add_understanding: BrainIcon,
|
||||
create_agent: PlusIcon,
|
||||
edit_agent: PencilLineIcon,
|
||||
find_agent: SquaresFourIcon,
|
||||
find_library_agent: MagnifyingGlassIcon,
|
||||
find_block: PackageIcon,
|
||||
run_agent: PlayIcon,
|
||||
run_block: PlayIcon,
|
||||
view_agent_output: EyeIcon,
|
||||
search_docs: FileMagnifyingGlassIcon,
|
||||
get_doc_page: FileTextIcon,
|
||||
agent_carousel: MagnifyingGlassIcon,
|
||||
execution_started: PlayIcon,
|
||||
get_required_setup_info: SquaresFourIcon,
|
||||
schedule_agent: PlayIcon,
|
||||
};
|
||||
|
||||
return iconMap[toolName] || SquaresFourIcon;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hook that returns a progress value that starts fast and slows down,
|
||||
* asymptotically approaching but never reaching the max value.
|
||||
*
|
||||
* Uses a half-life formula: progress = max * (1 - 0.5^(time/halfLife))
|
||||
* This creates the "game loading bar" effect where:
|
||||
* - 50% is reached at halfLifeSeconds
|
||||
* - 75% is reached at 2 * halfLifeSeconds
|
||||
* - 87.5% is reached at 3 * halfLifeSeconds
|
||||
* - and so on...
|
||||
*
|
||||
* @param isActive - Whether the progress should be animating
|
||||
* @param halfLifeSeconds - Time in seconds to reach 50% progress (default: 30)
|
||||
* @param maxProgress - Maximum progress value to approach (default: 100)
|
||||
* @param intervalMs - Update interval in milliseconds (default: 100)
|
||||
* @returns Current progress value (0-maxProgress)
|
||||
*/
|
||||
export function useAsymptoticProgress(
|
||||
isActive: boolean,
|
||||
halfLifeSeconds = 30,
|
||||
maxProgress = 100,
|
||||
intervalMs = 100,
|
||||
) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const elapsedTimeRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setProgress(0);
|
||||
elapsedTimeRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
elapsedTimeRef.current += intervalMs / 1000;
|
||||
// Half-life approach: progress = max * (1 - 0.5^(time/halfLife))
|
||||
// At t=halfLife: 50%, at t=2*halfLife: 75%, at t=3*halfLife: 87.5%, etc.
|
||||
const newProgress =
|
||||
maxProgress *
|
||||
(1 - Math.pow(0.5, elapsedTimeRef.current / halfLifeSeconds));
|
||||
setProgress(newProgress);
|
||||
}, intervalMs);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isActive, halfLifeSeconds, maxProgress, intervalMs]);
|
||||
|
||||
return progress;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2GetLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
import { RunAgentModal } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/RunAgentModal";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
PencilLineIcon,
|
||||
PlayIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
|
||||
interface Props {
|
||||
agentName: string;
|
||||
libraryAgentId: string;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function AgentCreatedPrompt({
|
||||
agentName,
|
||||
libraryAgentId,
|
||||
onSendMessage,
|
||||
}: Props) {
|
||||
// Fetch library agent eagerly so modal is ready when user clicks
|
||||
const { data: libraryAgentResponse, isLoading } = useGetV2GetLibraryAgent(
|
||||
libraryAgentId,
|
||||
{
|
||||
query: {
|
||||
enabled: !!libraryAgentId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const libraryAgent =
|
||||
libraryAgentResponse?.status === 200 ? libraryAgentResponse.data : null;
|
||||
|
||||
function handleRunWithPlaceholders() {
|
||||
onSendMessage?.(
|
||||
`Run the agent "${agentName}" with placeholder/example values so I can test it.`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleRunCreated(execution: GraphExecutionMeta) {
|
||||
onSendMessage?.(
|
||||
`I've started the agent "${agentName}". The execution ID is ${execution.id}. Please monitor its progress and let me know when it completes.`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleScheduleCreated(schedule: GraphExecutionJobInfo) {
|
||||
const scheduleInfo = schedule.cron
|
||||
? `with cron schedule "${schedule.cron}"`
|
||||
: "to run on the specified schedule";
|
||||
onSendMessage?.(
|
||||
`I've scheduled the agent "${agentName}" ${scheduleInfo}. The schedule ID is ${schedule.id}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIChatBubble>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircleIcon
|
||||
size={18}
|
||||
weight="fill"
|
||||
className="text-green-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="body-medium" className="text-neutral-900">
|
||||
Agent Created Successfully
|
||||
</Text>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
"{agentName}" is ready to test
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text variant="small-medium" className="text-neutral-700">
|
||||
Ready to test?
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={handleRunWithPlaceholders}
|
||||
className="gap-2"
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
Run with example values
|
||||
</Button>
|
||||
{libraryAgent ? (
|
||||
<RunAgentModal
|
||||
triggerSlot={
|
||||
<Button variant="outline" size="small" className="gap-2">
|
||||
<PencilLineIcon size={16} />
|
||||
Run with my inputs
|
||||
</Button>
|
||||
}
|
||||
agent={libraryAgent}
|
||||
onRunCreated={handleRunCreated}
|
||||
onScheduleCreated={handleScheduleCreated}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
loading={isLoading}
|
||||
disabled
|
||||
className="gap-2"
|
||||
>
|
||||
<PencilLineIcon size={16} />
|
||||
Run with my inputs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
or just ask me
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</AIChatBubble>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolResult } from "@/types/chat";
|
||||
import { WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { AgentCreatedPrompt } from "./AgentCreatedPrompt";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
import {
|
||||
formatToolResponse,
|
||||
getErrorMessage,
|
||||
isAgentSavedResponse,
|
||||
isErrorResponse,
|
||||
} from "./helpers";
|
||||
|
||||
export interface ToolResponseMessageProps {
|
||||
toolId?: string;
|
||||
toolName: string;
|
||||
result?: ToolResult;
|
||||
success?: boolean;
|
||||
className?: string;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function ToolResponseMessage({
|
||||
toolId: _toolId,
|
||||
toolName,
|
||||
result,
|
||||
success: _success,
|
||||
className,
|
||||
onSendMessage,
|
||||
}: ToolResponseMessageProps) {
|
||||
if (isErrorResponse(result)) {
|
||||
const errorMessage = getErrorMessage(result);
|
||||
return (
|
||||
<AIChatBubble className={className}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WarningCircleIcon
|
||||
size={14}
|
||||
weight="regular"
|
||||
className="shrink-0 text-neutral-400"
|
||||
/>
|
||||
<Text variant="small" className={cn("text-xs text-neutral-500")}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
</div>
|
||||
</AIChatBubble>
|
||||
);
|
||||
}
|
||||
|
||||
// Check for agent_saved response - show special prompt
|
||||
const agentSavedData = isAgentSavedResponse(result);
|
||||
if (agentSavedData.isSaved) {
|
||||
return (
|
||||
<AgentCreatedPrompt
|
||||
agentName={agentSavedData.agentName}
|
||||
libraryAgentId={agentSavedData.libraryAgentId}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const formattedText = formatToolResponse(result, toolName);
|
||||
|
||||
return (
|
||||
<AIChatBubble className={className}>
|
||||
<MarkdownContent content={formattedText} />
|
||||
</AIChatBubble>
|
||||
);
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
function stripInternalReasoning(content: string): string {
|
||||
return content
|
||||
.replace(/<internal_reasoning>[\s\S]*?<\/internal_reasoning>/gi, "")
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export interface AgentSavedData {
|
||||
isSaved: boolean;
|
||||
agentName: string;
|
||||
agentId: string;
|
||||
libraryAgentId: string;
|
||||
libraryAgentLink: string;
|
||||
}
|
||||
|
||||
export function isAgentSavedResponse(result: unknown): AgentSavedData {
|
||||
if (typeof result !== "object" || result === null) {
|
||||
return {
|
||||
isSaved: false,
|
||||
agentName: "",
|
||||
agentId: "",
|
||||
libraryAgentId: "",
|
||||
libraryAgentLink: "",
|
||||
};
|
||||
}
|
||||
const response = result as Record<string, unknown>;
|
||||
if (response.type === "agent_saved") {
|
||||
return {
|
||||
isSaved: true,
|
||||
agentName: (response.agent_name as string) || "Agent",
|
||||
agentId: (response.agent_id as string) || "",
|
||||
libraryAgentId: (response.library_agent_id as string) || "",
|
||||
libraryAgentLink: (response.library_agent_link as string) || "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
isSaved: false,
|
||||
agentName: "",
|
||||
agentId: "",
|
||||
libraryAgentId: "",
|
||||
libraryAgentLink: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isErrorResponse(result: unknown): boolean {
|
||||
if (typeof result === "string") {
|
||||
const lower = result.toLowerCase();
|
||||
return (
|
||||
lower.startsWith("error:") ||
|
||||
lower.includes("not found") ||
|
||||
lower.includes("does not exist") ||
|
||||
lower.includes("failed to") ||
|
||||
lower.includes("unable to")
|
||||
);
|
||||
}
|
||||
if (typeof result === "object" && result !== null) {
|
||||
const response = result as Record<string, unknown>;
|
||||
return response.type === "error" || response.error !== undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getErrorMessage(result: unknown): string {
|
||||
if (typeof result === "string") {
|
||||
return stripInternalReasoning(result.replace(/^error:\s*/i, ""));
|
||||
}
|
||||
if (typeof result === "object" && result !== null) {
|
||||
const response = result as Record<string, unknown>;
|
||||
if (response.message)
|
||||
return stripInternalReasoning(String(response.message));
|
||||
if (response.error) return stripInternalReasoning(String(response.error));
|
||||
}
|
||||
return "An error occurred";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a workspace file reference.
|
||||
* Format: workspace://{fileId} or workspace://{fileId}#{mimeType}
|
||||
*/
|
||||
function isWorkspaceRef(value: unknown): value is string {
|
||||
return typeof value === "string" && value.startsWith("workspace://");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract MIME type from a workspace reference fragment.
|
||||
* e.g., "workspace://abc123#video/mp4" → "video/mp4"
|
||||
* Returns undefined if no fragment is present.
|
||||
*/
|
||||
function getWorkspaceMimeType(value: string): string | undefined {
|
||||
const hashIndex = value.indexOf("#");
|
||||
if (hashIndex === -1) return undefined;
|
||||
return value.slice(hashIndex + 1) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the media category of a workspace ref or data URI.
|
||||
* Uses the MIME type fragment on workspace refs when available,
|
||||
* falls back to output key keyword matching for older refs without it.
|
||||
*/
|
||||
function getMediaCategory(
|
||||
value: string,
|
||||
outputKey?: string,
|
||||
): "video" | "image" | "audio" | "unknown" {
|
||||
// Data URIs carry their own MIME type
|
||||
if (value.startsWith("data:video/")) return "video";
|
||||
if (value.startsWith("data:image/")) return "image";
|
||||
if (value.startsWith("data:audio/")) return "audio";
|
||||
|
||||
// Workspace refs: prefer MIME type fragment
|
||||
if (isWorkspaceRef(value)) {
|
||||
const mime = getWorkspaceMimeType(value);
|
||||
if (mime) {
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Fallback: keyword matching on output key for older refs without fragment
|
||||
if (outputKey) {
|
||||
const lowerKey = outputKey.toLowerCase();
|
||||
|
||||
const videoKeywords = [
|
||||
"video",
|
||||
"mp4",
|
||||
"mov",
|
||||
"avi",
|
||||
"webm",
|
||||
"movie",
|
||||
"clip",
|
||||
];
|
||||
if (videoKeywords.some((kw) => lowerKey.includes(kw))) return "video";
|
||||
|
||||
const imageKeywords = [
|
||||
"image",
|
||||
"img",
|
||||
"photo",
|
||||
"picture",
|
||||
"thumbnail",
|
||||
"avatar",
|
||||
"icon",
|
||||
"screenshot",
|
||||
];
|
||||
if (imageKeywords.some((kw) => lowerKey.includes(kw))) return "image";
|
||||
}
|
||||
|
||||
// Default to image for backward compatibility
|
||||
return "image";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single output value, converting workspace refs to markdown images/videos.
|
||||
* Videos use a "video:" alt-text prefix so the MarkdownContent renderer can
|
||||
* distinguish them from images and render a <video> element.
|
||||
*/
|
||||
function formatOutputValue(value: unknown, outputKey?: string): string {
|
||||
if (typeof value === "string") {
|
||||
const category = getMediaCategory(value, outputKey);
|
||||
|
||||
if (category === "video") {
|
||||
// Format with "video:" prefix so MarkdownContent renders <video>
|
||||
return ``;
|
||||
}
|
||||
|
||||
if (category === "image") {
|
||||
return ``;
|
||||
}
|
||||
|
||||
// For audio, unknown workspace refs, data URIs, etc. - return as-is
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item, idx) => formatOutputValue(item, `${outputKey}_${idx}`))
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function getToolCompletionPhrase(toolName: string): string {
|
||||
const toolCompletionPhrases: Record<string, string> = {
|
||||
add_understanding: "Updated your business information",
|
||||
create_agent: "Created the agent",
|
||||
edit_agent: "Updated the agent",
|
||||
find_agent: "Found agents in the marketplace",
|
||||
find_block: "Found blocks",
|
||||
find_library_agent: "Found library agents",
|
||||
run_agent: "Started agent execution",
|
||||
run_block: "Executed the block",
|
||||
view_agent_output: "Retrieved agent output",
|
||||
search_docs: "Found documentation",
|
||||
get_doc_page: "Loaded documentation page",
|
||||
};
|
||||
|
||||
// Return mapped phrase or generate human-friendly fallback
|
||||
return (
|
||||
toolCompletionPhrases[toolName] ||
|
||||
`Completed ${toolName.replace(/_/g, " ").replace("...", "")}`
|
||||
);
|
||||
}
|
||||
|
||||
export function formatToolResponse(result: unknown, toolName: string): string {
|
||||
if (typeof result === "string") {
|
||||
const trimmed = result.trim();
|
||||
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return formatToolResponse(parsed, toolName);
|
||||
} catch {
|
||||
return stripInternalReasoning(trimmed);
|
||||
}
|
||||
}
|
||||
return stripInternalReasoning(result);
|
||||
}
|
||||
|
||||
if (typeof result !== "object" || result === null) {
|
||||
return String(result);
|
||||
}
|
||||
|
||||
const response = result as Record<string, unknown>;
|
||||
|
||||
// Handle different response types
|
||||
const responseType = response.type as string | undefined;
|
||||
|
||||
if (!responseType) {
|
||||
if (response.message) {
|
||||
return String(response.message);
|
||||
}
|
||||
return getToolCompletionPhrase(toolName);
|
||||
}
|
||||
|
||||
switch (responseType) {
|
||||
case "agents_found":
|
||||
const agents = (response.agents as Array<{ name: string }>) || [];
|
||||
const count =
|
||||
typeof response.count === "number" && !isNaN(response.count)
|
||||
? response.count
|
||||
: agents.length;
|
||||
if (count === 0) {
|
||||
return "No agents found matching your search.";
|
||||
}
|
||||
return `Found ${count} agent${count === 1 ? "" : "s"}: ${agents
|
||||
.slice(0, 3)
|
||||
.map((a) => a.name)
|
||||
.join(", ")}${count > 3 ? ` and ${count - 3} more` : ""}`;
|
||||
|
||||
case "agent_details":
|
||||
const agent = response.agent as { name: string; description?: string };
|
||||
if (agent) {
|
||||
return `Agent: ${agent.name}${agent.description ? `\n\n${agent.description}` : ""}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "block_list":
|
||||
const blocks = (response.blocks as Array<{ name: string }>) || [];
|
||||
const blockCount =
|
||||
typeof response.count === "number" && !isNaN(response.count)
|
||||
? response.count
|
||||
: blocks.length;
|
||||
if (blockCount === 0) {
|
||||
return "No blocks found matching your search.";
|
||||
}
|
||||
return `Found ${blockCount} block${blockCount === 1 ? "" : "s"}: ${blocks
|
||||
.slice(0, 3)
|
||||
.map((b) => b.name)
|
||||
.join(", ")}${blockCount > 3 ? ` and ${blockCount - 3} more` : ""}`;
|
||||
|
||||
case "block_output":
|
||||
const blockName = (response.block_name as string) || "Block";
|
||||
const outputs = response.outputs as Record<string, unknown[]> | undefined;
|
||||
if (outputs && Object.keys(outputs).length > 0) {
|
||||
const formattedOutputs: string[] = [];
|
||||
|
||||
for (const [key, values] of Object.entries(outputs)) {
|
||||
if (!Array.isArray(values) || values.length === 0) continue;
|
||||
|
||||
// Format each value in the output array
|
||||
for (const value of values) {
|
||||
const formatted = formatOutputValue(value, key);
|
||||
if (formatted) {
|
||||
formattedOutputs.push(formatted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (formattedOutputs.length > 0) {
|
||||
return `${blockName} executed successfully.\n\n${formattedOutputs.join("\n\n")}`;
|
||||
}
|
||||
return `${blockName} executed successfully.`;
|
||||
}
|
||||
return `${blockName} executed successfully.`;
|
||||
|
||||
case "doc_search_results":
|
||||
const docResults = (response.results as Array<{ title: string }>) || [];
|
||||
const docCount =
|
||||
typeof response.count === "number" && !isNaN(response.count)
|
||||
? response.count
|
||||
: docResults.length;
|
||||
if (docCount === 0) {
|
||||
return "No documentation found matching your search.";
|
||||
}
|
||||
return `Found ${docCount} documentation result${docCount === 1 ? "" : "s"}: ${docResults
|
||||
.slice(0, 3)
|
||||
.map((r) => r.title)
|
||||
.join(", ")}${docCount > 3 ? ` and ${docCount - 3} more` : ""}`;
|
||||
|
||||
case "doc_page":
|
||||
const title = (response.title as string) || "Documentation";
|
||||
const content = (response.content as string) || "";
|
||||
if (content) {
|
||||
const preview = content.substring(0, 200).trim();
|
||||
return `${title}\n\n${preview}${content.length > 200 ? "..." : ""}`;
|
||||
}
|
||||
return title;
|
||||
|
||||
case "understanding_updated":
|
||||
const currentUnderstanding = response.current_understanding as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const fields = (response.updated_fields as string[]) || [];
|
||||
|
||||
if (response.message && typeof response.message === "string") {
|
||||
let message = response.message;
|
||||
if (currentUnderstanding) {
|
||||
for (const [key, value] of Object.entries(currentUnderstanding)) {
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
const placeholder = key;
|
||||
const actualValue = String(value);
|
||||
message = message.replace(
|
||||
new RegExp(`\\b${placeholder}\\b`, "g"),
|
||||
actualValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
if (
|
||||
currentUnderstanding &&
|
||||
Object.keys(currentUnderstanding).length > 0
|
||||
) {
|
||||
const understandingEntries = Object.entries(currentUnderstanding)
|
||||
.filter(
|
||||
([_, value]) =>
|
||||
value !== null && value !== undefined && value !== "",
|
||||
)
|
||||
.map(([key, value]) => {
|
||||
if (key === "user_name" && typeof value === "string") {
|
||||
return `Updated information for ${value}`;
|
||||
}
|
||||
return `${key}: ${String(value)}`;
|
||||
});
|
||||
if (understandingEntries.length > 0) {
|
||||
return understandingEntries[0];
|
||||
}
|
||||
}
|
||||
if (fields.length > 0) {
|
||||
return `Updated business information: ${fields.join(", ")}`;
|
||||
}
|
||||
return "Updated your business information.";
|
||||
|
||||
case "agent_saved":
|
||||
const agentName = (response.agent_name as string) || "Agent";
|
||||
return `Successfully saved "${agentName}" to your library.`;
|
||||
|
||||
case "agent_preview":
|
||||
const previewAgentName = (response.agent_name as string) || "Agent";
|
||||
const nodeCount = (response.node_count as number) || 0;
|
||||
const linkCount = (response.link_count as number) || 0;
|
||||
const description = (response.description as string) || "";
|
||||
let previewText = `Preview: "${previewAgentName}"`;
|
||||
if (description) {
|
||||
previewText += `\n\n${description}`;
|
||||
}
|
||||
previewText += `\n\n${nodeCount} node${nodeCount === 1 ? "" : "s"}, ${linkCount} link${linkCount === 1 ? "" : "s"}`;
|
||||
return previewText;
|
||||
|
||||
case "clarification_needed":
|
||||
const questions =
|
||||
(response.questions as Array<{ question: string }>) || [];
|
||||
if (questions.length === 0) {
|
||||
return response.message
|
||||
? String(response.message)
|
||||
: "I need more information to proceed.";
|
||||
}
|
||||
if (questions.length === 1) {
|
||||
return questions[0].question;
|
||||
}
|
||||
return `I need clarification on ${questions.length} points:\n\n${questions
|
||||
.map((q, i) => `${i + 1}. ${q.question}`)
|
||||
.join("\n")}`;
|
||||
|
||||
case "agent_output":
|
||||
if (response.message) {
|
||||
return String(response.message);
|
||||
}
|
||||
const outputAgentName = (response.agent_name as string) || "Agent";
|
||||
const execution = response.execution as
|
||||
| {
|
||||
status?: string;
|
||||
outputs?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
if (execution) {
|
||||
const status = execution.status || "completed";
|
||||
const outputs = execution.outputs || {};
|
||||
const outputKeys = Object.keys(outputs);
|
||||
if (outputKeys.length > 0) {
|
||||
return `${outputAgentName} execution ${status}. Outputs: ${outputKeys.join(", ")}`;
|
||||
}
|
||||
return `${outputAgentName} execution ${status}.`;
|
||||
}
|
||||
return `${outputAgentName} output retrieved.`;
|
||||
|
||||
case "execution_started":
|
||||
const execAgentName = (response.graph_name as string) || "Agent";
|
||||
if (response.message) {
|
||||
return String(response.message);
|
||||
}
|
||||
return `Started execution of "${execAgentName}".`;
|
||||
|
||||
case "error":
|
||||
const errorMsg =
|
||||
(response.message as string) || response.error || "An error occurred";
|
||||
return stripInternalReasoning(String(errorMsg));
|
||||
|
||||
case "no_results":
|
||||
const suggestions = (response.suggestions as string[]) || [];
|
||||
let noResultsText = (response.message as string) || "No results found.";
|
||||
if (suggestions.length > 0) {
|
||||
noResultsText += `\n\nSuggestions: ${suggestions.join(", ")}`;
|
||||
}
|
||||
return noResultsText;
|
||||
|
||||
default:
|
||||
// Try to extract a message field
|
||||
if (response.message) {
|
||||
return String(response.message);
|
||||
}
|
||||
// Fallback: try to stringify nicely
|
||||
if (Object.keys(response).length === 0) {
|
||||
return getToolCompletionPhrase(toolName);
|
||||
}
|
||||
// If we have a response object but no recognized type, try to format it nicely
|
||||
// Don't return raw JSON - return a completion phrase instead
|
||||
return getToolCompletionPhrase(toolName);
|
||||
}
|
||||
|
||||
return getToolCompletionPhrase(toolName);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface UserChatBubbleProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserChatBubble({ children, className }: UserChatBubbleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative min-w-20 overflow-hidden rounded-xl bg-purple-100 px-3 text-left text-[1rem] leading-relaxed transition-all duration-500 ease-in-out",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
borderBottomRightRadius: 0,
|
||||
}}
|
||||
>
|
||||
<div className="relative z-10 text-slate-900 transition-all duration-500 ease-in-out">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import { INITIAL_STREAM_ID } from "./chat-constants";
|
||||
import type {
|
||||
ActiveStream,
|
||||
StreamChunk,
|
||||
VercelStreamChunk,
|
||||
} from "./chat-types";
|
||||
import {
|
||||
INITIAL_RETRY_DELAY,
|
||||
MAX_RETRIES,
|
||||
normalizeStreamChunk,
|
||||
parseSSELine,
|
||||
} from "./stream-utils";
|
||||
|
||||
function notifySubscribers(
|
||||
stream: ActiveStream,
|
||||
chunk: StreamChunk,
|
||||
skipStore = false,
|
||||
) {
|
||||
if (!skipStore) {
|
||||
stream.chunks.push(chunk);
|
||||
}
|
||||
for (const callback of stream.onChunkCallbacks) {
|
||||
try {
|
||||
callback(chunk);
|
||||
} catch (err) {
|
||||
console.warn("[StreamExecutor] Subscriber callback error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamExecutionOptions {
|
||||
stream: ActiveStream;
|
||||
mode: "new" | "reconnect";
|
||||
message?: string;
|
||||
isUserMessage?: boolean;
|
||||
context?: { url: string; content: string };
|
||||
taskId?: string;
|
||||
lastMessageId?: string;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
async function executeStreamInternal(
|
||||
options: StreamExecutionOptions,
|
||||
): Promise<void> {
|
||||
const {
|
||||
stream,
|
||||
mode,
|
||||
message,
|
||||
isUserMessage,
|
||||
context,
|
||||
taskId,
|
||||
lastMessageId = INITIAL_STREAM_ID,
|
||||
retryCount = 0,
|
||||
} = options;
|
||||
|
||||
const { sessionId, abortController } = stream;
|
||||
const isReconnect = mode === "reconnect";
|
||||
|
||||
if (isReconnect) {
|
||||
if (!taskId) {
|
||||
throw new Error("taskId is required for reconnect mode");
|
||||
}
|
||||
if (lastMessageId === null || lastMessageId === undefined) {
|
||||
throw new Error("lastMessageId is required for reconnect mode");
|
||||
}
|
||||
} else {
|
||||
if (!message) {
|
||||
throw new Error("message is required for new stream mode");
|
||||
}
|
||||
if (isUserMessage === undefined) {
|
||||
throw new Error("isUserMessage is required for new stream mode");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let url: string;
|
||||
let fetchOptions: RequestInit;
|
||||
|
||||
if (isReconnect) {
|
||||
url = `/api/chat/tasks/${taskId}/stream?last_message_id=${encodeURIComponent(lastMessageId)}`;
|
||||
fetchOptions = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
signal: abortController.signal,
|
||||
};
|
||||
} else {
|
||||
url = `/api/chat/sessions/${sessionId}/stream`;
|
||||
fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
is_user_message: isUserMessage,
|
||||
context: context || null,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorCode: string | undefined;
|
||||
let errorMessage = errorText || `HTTP ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(errorText);
|
||||
if (parsed.detail) {
|
||||
const detail =
|
||||
typeof parsed.detail === "string"
|
||||
? parsed.detail
|
||||
: parsed.detail.message || JSON.stringify(parsed.detail);
|
||||
errorMessage = detail;
|
||||
errorCode =
|
||||
typeof parsed.detail === "object" ? parsed.detail.code : undefined;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const isPermanentError =
|
||||
isReconnect &&
|
||||
(response.status === 404 ||
|
||||
response.status === 403 ||
|
||||
response.status === 410);
|
||||
|
||||
const error = new Error(errorMessage) as Error & {
|
||||
status?: number;
|
||||
isPermanent?: boolean;
|
||||
taskErrorCode?: string;
|
||||
};
|
||||
error.status = response.status;
|
||||
error.isPermanent = isPermanentError;
|
||||
error.taskErrorCode = errorCode;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
notifySubscribers(stream, { type: "stream_end" });
|
||||
stream.status = "completed";
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const data = parseSSELine(line);
|
||||
if (data !== null) {
|
||||
if (data === "[DONE]") {
|
||||
notifySubscribers(stream, { type: "stream_end" });
|
||||
stream.status = "completed";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawChunk = JSON.parse(data) as
|
||||
| StreamChunk
|
||||
| VercelStreamChunk;
|
||||
const chunk = normalizeStreamChunk(rawChunk);
|
||||
if (!chunk) continue;
|
||||
|
||||
notifySubscribers(stream, chunk);
|
||||
|
||||
if (chunk.type === "stream_end") {
|
||||
stream.status = "completed";
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunk.type === "error") {
|
||||
stream.status = "error";
|
||||
stream.error = new Error(
|
||||
chunk.message || chunk.content || "Stream error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
notifySubscribers(stream, { type: "stream_end" });
|
||||
stream.status = "completed";
|
||||
return;
|
||||
}
|
||||
|
||||
const isPermanentError =
|
||||
err instanceof Error &&
|
||||
(err as Error & { isPermanent?: boolean }).isPermanent;
|
||||
|
||||
if (!isPermanentError && retryCount < MAX_RETRIES) {
|
||||
const retryDelay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return executeStreamInternal({
|
||||
...options,
|
||||
retryCount: retryCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
stream.status = "error";
|
||||
stream.error = err instanceof Error ? err : new Error("Stream failed");
|
||||
notifySubscribers(stream, {
|
||||
type: "error",
|
||||
message: stream.error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeStream(
|
||||
stream: ActiveStream,
|
||||
message: string,
|
||||
isUserMessage: boolean,
|
||||
context?: { url: string; content: string },
|
||||
retryCount: number = 0,
|
||||
): Promise<void> {
|
||||
return executeStreamInternal({
|
||||
stream,
|
||||
mode: "new",
|
||||
message,
|
||||
isUserMessage,
|
||||
context,
|
||||
retryCount,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeTaskReconnect(
|
||||
stream: ActiveStream,
|
||||
taskId: string,
|
||||
lastMessageId: string = INITIAL_STREAM_ID,
|
||||
retryCount: number = 0,
|
||||
): Promise<void> {
|
||||
return executeStreamInternal({
|
||||
stream,
|
||||
mode: "reconnect",
|
||||
taskId,
|
||||
lastMessageId,
|
||||
retryCount,
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { ToolArguments, ToolResult } from "@/types/chat";
|
||||
import type { StreamChunk, VercelStreamChunk } from "./chat-types";
|
||||
|
||||
const LEGACY_STREAM_TYPES = new Set<StreamChunk["type"]>([
|
||||
"text_chunk",
|
||||
"text_ended",
|
||||
"tool_call",
|
||||
"tool_call_start",
|
||||
"tool_response",
|
||||
"login_needed",
|
||||
"need_login",
|
||||
"credentials_needed",
|
||||
"error",
|
||||
"usage",
|
||||
"stream_end",
|
||||
]);
|
||||
|
||||
export function isLegacyStreamChunk(
|
||||
chunk: StreamChunk | VercelStreamChunk,
|
||||
): chunk is StreamChunk {
|
||||
return LEGACY_STREAM_TYPES.has(chunk.type as StreamChunk["type"]);
|
||||
}
|
||||
|
||||
export function normalizeStreamChunk(
|
||||
chunk: StreamChunk | VercelStreamChunk,
|
||||
): StreamChunk | null {
|
||||
if (isLegacyStreamChunk(chunk)) return chunk;
|
||||
|
||||
switch (chunk.type) {
|
||||
case "text-delta":
|
||||
// Vercel AI SDK sends "delta" for text content
|
||||
return { type: "text_chunk", content: chunk.delta };
|
||||
case "text-end":
|
||||
return { type: "text_ended" };
|
||||
case "tool-input-available":
|
||||
return {
|
||||
type: "tool_call_start",
|
||||
tool_id: chunk.toolCallId,
|
||||
tool_name: chunk.toolName,
|
||||
arguments: chunk.input as ToolArguments,
|
||||
};
|
||||
case "tool-output-available":
|
||||
return {
|
||||
type: "tool_response",
|
||||
tool_id: chunk.toolCallId,
|
||||
tool_name: chunk.toolName,
|
||||
result: chunk.output as ToolResult,
|
||||
success: chunk.success ?? true,
|
||||
};
|
||||
case "usage":
|
||||
return {
|
||||
type: "usage",
|
||||
promptTokens: chunk.promptTokens,
|
||||
completionTokens: chunk.completionTokens,
|
||||
totalTokens: chunk.totalTokens,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
type: "error",
|
||||
message: chunk.errorText,
|
||||
code: chunk.code,
|
||||
details: chunk.details,
|
||||
};
|
||||
case "finish":
|
||||
return { type: "stream_end" };
|
||||
case "start":
|
||||
// Start event with optional taskId for reconnection
|
||||
return chunk.taskId
|
||||
? { type: "stream_start", taskId: chunk.taskId }
|
||||
: null;
|
||||
case "text-start":
|
||||
return null;
|
||||
case "tool-input-start":
|
||||
return {
|
||||
type: "tool_call_start",
|
||||
tool_id: chunk.toolCallId,
|
||||
tool_name: chunk.toolName,
|
||||
arguments: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_RETRIES = 3;
|
||||
export const INITIAL_RETRY_DELAY = 1000;
|
||||
|
||||
export function parseSSELine(line: string): string | null {
|
||||
if (line.startsWith("data: ")) return line.slice(6);
|
||||
return null;
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useChatSession } from "./useChatSession";
|
||||
import { useChatStream } from "./useChatStream";
|
||||
|
||||
interface UseChatArgs {
|
||||
urlSessionId?: string | null;
|
||||
}
|
||||
|
||||
export function useChat({ urlSessionId }: UseChatArgs = {}) {
|
||||
const hasClaimedSessionRef = useRef(false);
|
||||
const { user } = useSupabase();
|
||||
const { sendMessage: sendStreamMessage } = useChatStream();
|
||||
const [showLoader, setShowLoader] = useState(false);
|
||||
const {
|
||||
session,
|
||||
sessionId: sessionIdFromHook,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
isSessionNotFound,
|
||||
createSession,
|
||||
claimSession,
|
||||
clearSession: clearSessionBase,
|
||||
loadSession,
|
||||
startPollingForOperation,
|
||||
} = useChatSession({
|
||||
urlSessionId,
|
||||
autoCreate: false,
|
||||
});
|
||||
|
||||
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 showLoaderWithDelay() {
|
||||
if (isLoading || isCreating) {
|
||||
const timer = setTimeout(() => setShowLoader(true), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setShowLoader(false);
|
||||
},
|
||||
[isLoading, isCreating],
|
||||
);
|
||||
|
||||
function clearSession() {
|
||||
clearSessionBase();
|
||||
hasClaimedSessionRef.current = false;
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
isSessionNotFound,
|
||||
createSession,
|
||||
clearSession,
|
||||
loadSession,
|
||||
sessionId: sessionIdFromHook,
|
||||
showLoader,
|
||||
startPollingForOperation,
|
||||
};
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
import {
|
||||
getGetV2GetSessionQueryKey,
|
||||
getGetV2GetSessionQueryOptions,
|
||||
getGetV2ListSessionsQueryKey,
|
||||
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 { isValidUUID } from "@/lib/utils";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
setSessionId(urlSessionId);
|
||||
} else if (autoCreate) {
|
||||
setSessionId(null);
|
||||
} else {
|
||||
setSessionId(null);
|
||||
}
|
||||
}, [urlSessionId, autoCreate]);
|
||||
|
||||
const { isPending: isCreating, error: createError } =
|
||||
usePostV2CreateSession();
|
||||
|
||||
const {
|
||||
data: sessionData,
|
||||
isLoading: isLoadingSession,
|
||||
error: loadError,
|
||||
refetch,
|
||||
} = useGetV2GetSession(sessionId || "", {
|
||||
query: {
|
||||
enabled: !!sessionId,
|
||||
select: okData,
|
||||
staleTime: 0,
|
||||
retry: shouldRetrySessionLoad,
|
||||
retryDelay: getSessionRetryDelay,
|
||||
},
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
// Track if we should be polling (set by external callers when they receive operation_started via SSE)
|
||||
const [forcePolling, setForcePolling] = useState(false);
|
||||
// Track if we've seen server acknowledge the pending operation (to avoid clearing forcePolling prematurely)
|
||||
const hasSeenServerPendingRef = useRef(false);
|
||||
|
||||
// Check if there are any pending operations in the messages
|
||||
// Must check all operation types: operation_pending, operation_started, operation_in_progress
|
||||
const hasPendingOperationsFromServer = useMemo(() => {
|
||||
if (!messages || messages.length === 0) return false;
|
||||
const pendingTypes = new Set([
|
||||
"operation_pending",
|
||||
"operation_in_progress",
|
||||
"operation_started",
|
||||
]);
|
||||
return messages.some((msg) => {
|
||||
if (msg.role !== "tool" || !msg.content) return false;
|
||||
try {
|
||||
const content =
|
||||
typeof msg.content === "string"
|
||||
? JSON.parse(msg.content)
|
||||
: msg.content;
|
||||
return pendingTypes.has(content?.type);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// Track when server has acknowledged the pending operation
|
||||
useEffect(() => {
|
||||
if (hasPendingOperationsFromServer) {
|
||||
hasSeenServerPendingRef.current = true;
|
||||
}
|
||||
}, [hasPendingOperationsFromServer]);
|
||||
|
||||
// Combined: poll if server has pending ops OR if we received operation_started via SSE
|
||||
const hasPendingOperations = hasPendingOperationsFromServer || forcePolling;
|
||||
|
||||
// Clear forcePolling only after server has acknowledged AND completed the operation
|
||||
useEffect(() => {
|
||||
if (
|
||||
forcePolling &&
|
||||
!hasPendingOperationsFromServer &&
|
||||
hasSeenServerPendingRef.current
|
||||
) {
|
||||
// Server acknowledged the operation and it's now complete
|
||||
setForcePolling(false);
|
||||
hasSeenServerPendingRef.current = false;
|
||||
}
|
||||
}, [forcePolling, hasPendingOperationsFromServer]);
|
||||
|
||||
// Function to trigger polling (called when operation_started is received via SSE)
|
||||
function startPollingForOperation() {
|
||||
setForcePolling(true);
|
||||
hasSeenServerPendingRef.current = false; // Reset for new operation
|
||||
}
|
||||
|
||||
// Refresh sessions list when a pending operation completes
|
||||
// (hasPendingOperations transitions from true to false)
|
||||
const prevHasPendingOperationsRef = useRef(hasPendingOperations);
|
||||
useEffect(
|
||||
function refreshSessionsListOnOperationComplete() {
|
||||
const wasHasPending = prevHasPendingOperationsRef.current;
|
||||
prevHasPendingOperationsRef.current = hasPendingOperations;
|
||||
|
||||
// Only invalidate when transitioning from pending to not pending
|
||||
if (wasHasPending && !hasPendingOperations && sessionId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[hasPendingOperations, sessionId, queryClient],
|
||||
);
|
||||
|
||||
// Poll for updates when there are pending operations
|
||||
// Backoff: 2s, 4s, 6s, 8s, 10s, ... up to 30s max
|
||||
const pollAttemptRef = useRef(0);
|
||||
const hasPendingOperationsRef = useRef(hasPendingOperations);
|
||||
hasPendingOperationsRef.current = hasPendingOperations;
|
||||
|
||||
useEffect(
|
||||
function pollForPendingOperations() {
|
||||
if (!sessionId || !hasPendingOperations) {
|
||||
pollAttemptRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function schedule() {
|
||||
// 2s, 4s, 6s, 8s, 10s, ... 30s (max)
|
||||
const delay = Math.min((pollAttemptRef.current + 1) * 2000, 30000);
|
||||
timeoutId = setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
pollAttemptRef.current += 1;
|
||||
try {
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
console.error("[useChatSession] Poll failed:", err);
|
||||
} finally {
|
||||
if (!cancelled && hasPendingOperationsRef.current) {
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
schedule();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
},
|
||||
[sessionId, hasPendingOperations, refetch],
|
||||
);
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Force fetch with fresh data (bypass cache)
|
||||
const queryOptions = getGetV2GetSessionQueryOptions(id, {
|
||||
query: {
|
||||
staleTime: 0, // Force fresh fetch
|
||||
retry: shouldRetrySessionLoad,
|
||||
retryDelay: getSessionRetryDelay,
|
||||
},
|
||||
});
|
||||
const result = await queryClient.fetchQuery(queryOptions);
|
||||
if (!result || ("status" in result && result.status !== 200)) {
|
||||
console.warn("Session not found on server");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSession() {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
setError(null);
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error("Failed to refresh session");
|
||||
setError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
setSessionId(null);
|
||||
setError(null);
|
||||
justCreatedSessionIdRef.current = null;
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionId,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
isSessionNotFound: isNotFoundError(loadError),
|
||||
hasPendingOperations,
|
||||
createSession,
|
||||
loadSession,
|
||||
refreshSession,
|
||||
claimSession,
|
||||
clearSession,
|
||||
startPollingForOperation,
|
||||
};
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
if ("status" in error && error.status === 404) return true;
|
||||
if (
|
||||
"response" in error &&
|
||||
typeof error.response === "object" &&
|
||||
error.response !== null &&
|
||||
"status" in error.response &&
|
||||
error.response.status === 404
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldRetrySessionLoad(failureCount: number, error: unknown): boolean {
|
||||
if (!isNotFoundError(error)) return false;
|
||||
return failureCount <= 2;
|
||||
}
|
||||
|
||||
function getSessionRetryDelay(attemptIndex: number): number {
|
||||
if (attemptIndex === 0) return 3000;
|
||||
if (attemptIndex === 1) return 5000;
|
||||
return 0;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useChatStore } from "./chat-store";
|
||||
import type { StreamChunk } from "./chat-types";
|
||||
|
||||
export type { StreamChunk } from "./chat-types";
|
||||
|
||||
export function useChatStream() {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
const onChunkCallbackRef = useRef<((chunk: StreamChunk) => void) | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const stopStream = useChatStore((s) => s.stopStream);
|
||||
const unregisterActiveSession = useChatStore(
|
||||
(s) => s.unregisterActiveSession,
|
||||
);
|
||||
const isSessionActive = useChatStore((s) => s.isSessionActive);
|
||||
const onStreamComplete = useChatStore((s) => s.onStreamComplete);
|
||||
const getCompletedStream = useChatStore((s) => s.getCompletedStream);
|
||||
const registerActiveSession = useChatStore((s) => s.registerActiveSession);
|
||||
const startStream = useChatStore((s) => s.startStream);
|
||||
const getStreamStatus = useChatStore((s) => s.getStreamStatus);
|
||||
|
||||
function stopStreaming(sessionId?: string) {
|
||||
const targetSession = sessionId || currentSessionIdRef.current;
|
||||
if (targetSession) {
|
||||
stopStream(targetSession);
|
||||
unregisterActiveSession(targetSession);
|
||||
}
|
||||
setIsStreaming(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return function cleanup() {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (sessionId && !isSessionActive(sessionId)) {
|
||||
stopStream(sessionId);
|
||||
}
|
||||
currentSessionIdRef.current = null;
|
||||
onChunkCallbackRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onStreamComplete(
|
||||
function handleStreamComplete(completedSessionId) {
|
||||
if (completedSessionId !== currentSessionIdRef.current) return;
|
||||
|
||||
setIsStreaming(false);
|
||||
const completed = getCompletedStream(completedSessionId);
|
||||
if (completed?.error) {
|
||||
setError(completed.error);
|
||||
}
|
||||
unregisterActiveSession(completedSessionId);
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
async function sendMessage(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
onChunk: (chunk: StreamChunk) => void,
|
||||
isUserMessage: boolean = true,
|
||||
context?: { url: string; content: string },
|
||||
) {
|
||||
const previousSessionId = currentSessionIdRef.current;
|
||||
if (previousSessionId && previousSessionId !== sessionId) {
|
||||
stopStreaming(previousSessionId);
|
||||
}
|
||||
|
||||
currentSessionIdRef.current = sessionId;
|
||||
onChunkCallbackRef.current = onChunk;
|
||||
setIsStreaming(true);
|
||||
setError(null);
|
||||
|
||||
registerActiveSession(sessionId);
|
||||
|
||||
try {
|
||||
await startStream(sessionId, message, isUserMessage, context, onChunk);
|
||||
|
||||
const status = getStreamStatus(sessionId);
|
||||
if (status === "error") {
|
||||
const completed = getCompletedStream(sessionId);
|
||||
if (completed?.error) {
|
||||
setError(completed.error);
|
||||
toast.error("Connection Failed", {
|
||||
description: "Unable to connect to chat service. Please try again.",
|
||||
});
|
||||
throw completed.error;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const streamError =
|
||||
err instanceof Error ? err : new Error("Failed to start stream");
|
||||
setError(streamError);
|
||||
throw streamError;
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isStreaming,
|
||||
error,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
};
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface PageContext {
|
||||
url: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const MAX_CONTENT_CHARS = 10000;
|
||||
|
||||
/**
|
||||
* Hook to capture the current page context (URL + full page content)
|
||||
* Privacy-hardened: removes sensitive inputs and enforces content size limits
|
||||
*/
|
||||
export function usePageContext() {
|
||||
const capturePageContext = useCallback((): PageContext => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return { url: "", content: "" };
|
||||
}
|
||||
|
||||
const url = window.location.href;
|
||||
|
||||
// Clone document to avoid modifying the original
|
||||
const clone = document.cloneNode(true) as Document;
|
||||
|
||||
// Remove script, style, and noscript elements
|
||||
const scripts = clone.querySelectorAll("script, style, noscript");
|
||||
scripts.forEach((el) => el.remove());
|
||||
|
||||
// Remove sensitive elements and their content
|
||||
const sensitiveSelectors = [
|
||||
"input",
|
||||
"textarea",
|
||||
"[contenteditable]",
|
||||
'input[type="password"]',
|
||||
'input[type="email"]',
|
||||
'input[type="tel"]',
|
||||
'input[type="search"]',
|
||||
'input[type="hidden"]',
|
||||
"form",
|
||||
"[data-sensitive]",
|
||||
"[data-sensitive='true']",
|
||||
];
|
||||
|
||||
sensitiveSelectors.forEach((selector) => {
|
||||
const elements = clone.querySelectorAll(selector);
|
||||
elements.forEach((el) => {
|
||||
// For form elements, remove the entire element
|
||||
if (el.tagName === "FORM") {
|
||||
el.remove();
|
||||
} else {
|
||||
// For inputs and textareas, clear their values but keep the element structure
|
||||
if (
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLTextAreaElement
|
||||
) {
|
||||
el.value = "";
|
||||
el.textContent = "";
|
||||
} else {
|
||||
// For other sensitive elements, remove them entirely
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Strip any remaining input values that might have been missed
|
||||
const allInputs = clone.querySelectorAll("input, textarea");
|
||||
allInputs.forEach((el) => {
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
el.value = "";
|
||||
el.textContent = "";
|
||||
}
|
||||
});
|
||||
|
||||
// Get text content from body
|
||||
const body = clone.body;
|
||||
const content = body?.textContent || body?.innerText || "";
|
||||
|
||||
// Clean up whitespace
|
||||
let cleanedContent = content
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\n\s*\n/g, "\n")
|
||||
.trim();
|
||||
|
||||
// Enforce maximum content size
|
||||
if (cleanedContent.length > MAX_CONTENT_CHARS) {
|
||||
cleanedContent =
|
||||
cleanedContent.substring(0, MAX_CONTENT_CHARS) + "... [truncated]";
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
content: cleanedContent,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { capturePageContext };
|
||||
}
|
||||
Reference in New Issue
Block a user