chore: re-arrange

This commit is contained in:
Lluis Agusti
2026-02-09 21:37:25 +08:00
parent 76bad41ca6
commit 3121911bd1
77 changed files with 360 additions and 7714 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import { parseAsString, useQueryState } from "nuqs";
export function useCopilotSessionId() {
const [urlSessionId, setUrlSessionId] = useQueryState(
"sessionId",
parseAsString,
);
return { urlSessionId, setUrlSessionId };
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? {})}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,119 +0,0 @@
import { Button } from "@/components/atoms/Button/Button";
import { Card } from "@/components/atoms/Card/Card";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { ArrowRight, List, Robot } from "@phosphor-icons/react";
import Image from "next/image";
export interface Agent {
id: string;
name: string;
description: string;
version?: number;
image_url?: string;
}
export interface AgentCarouselMessageProps {
agents: Agent[];
totalCount?: number;
onSelectAgent?: (agentId: string) => void;
className?: string;
}
export function AgentCarouselMessage({
agents,
totalCount,
onSelectAgent,
className,
}: AgentCarouselMessageProps) {
const displayCount = totalCount ?? agents.length;
return (
<div
className={cn(
"mx-4 my-2 flex flex-col gap-4 rounded-lg border border-purple-200 bg-purple-50 p-6",
className,
)}
>
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500">
<List size={24} weight="bold" className="text-white" />
</div>
<div>
<Text variant="h3" className="text-purple-900">
Found {displayCount} {displayCount === 1 ? "Agent" : "Agents"}
</Text>
<Text variant="small" className="text-purple-700">
Select an agent to view details or run it
</Text>
</div>
</div>
{/* Agent Cards */}
<div className="grid gap-3 sm:grid-cols-2">
{agents.map((agent) => (
<Card
key={agent.id}
className="border border-purple-200 bg-white p-4"
>
<div className="flex gap-3">
<div className="relative h-10 w-10 flex-shrink-0 overflow-hidden rounded-lg bg-purple-100">
{agent.image_url ? (
<Image
src={agent.image_url}
alt={`${agent.name} preview image`}
fill
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Robot
size={20}
weight="bold"
className="text-purple-600"
/>
</div>
)}
</div>
<div className="flex-1 space-y-2">
<div>
<Text
variant="body"
className="font-semibold text-purple-900"
>
{agent.name}
</Text>
{agent.version && (
<Text variant="small" className="text-purple-600">
v{agent.version}
</Text>
)}
</div>
<Text variant="small" className="line-clamp-2 text-purple-700">
{agent.description}
</Text>
{onSelectAgent && (
<Button
onClick={() => onSelectAgent(agent.id)}
variant="ghost"
className="mt-2 flex items-center gap-1 p-0 text-sm text-purple-600 hover:text-purple-800"
>
View details
<ArrowRight size={16} weight="bold" />
</Button>
)}
</div>
</div>
</Card>
))}
</div>
{totalCount && totalCount > agents.length && (
<Text variant="small" className="text-center text-purple-600">
Showing {agents.length} of {totalCount} results
</Text>
)}
</div>
);
}

View File

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

View File

@@ -1,38 +0,0 @@
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useState } from "react";
export function useAgentInputsSetup() {
const [inputValues, setInputValues] = useState<Record<string, any>>({});
const [credentialsValues, setCredentialsValues] = useState<
Record<string, CredentialsMetaInput>
>({});
function setInputValue(key: string, value: any) {
setInputValues((prev) => ({
...prev,
[key]: value,
}));
}
function setCredentialsValue(key: string, value?: CredentialsMetaInput) {
if (value) {
setCredentialsValues((prev) => ({
...prev,
[key]: value,
}));
} else {
setCredentialsValues((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
}
return {
inputValues,
setInputValue,
credentialsValues,
setCredentialsValue,
};
}

View File

@@ -1,120 +0,0 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { cn } from "@/lib/utils";
import { ShieldIcon, SignInIcon, UserPlusIcon } from "@phosphor-icons/react";
import { useRouter } from "next/navigation";
export interface AuthPromptWidgetProps {
message: string;
sessionId: string;
agentInfo?: {
graph_id: string;
name: string;
trigger_type: string;
};
returnUrl?: string;
className?: string;
}
export function AuthPromptWidget({
message,
sessionId,
agentInfo,
returnUrl = "/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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
import { useState, useMemo } from "react";
import type { CredentialInfo } from "./ChatCredentialsSetup";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api";
export function useChatCredentialsSetup(credentials: CredentialInfo[]) {
const [selectedCredentials, setSelectedCredentials] = useState<
Record<string, CredentialsMetaInput>
>({});
// Check if all credentials are configured
const isAllComplete = useMemo(
function checkAllComplete() {
if (credentials.length === 0) return false;
return credentials.every((cred) => selectedCredentials[cred.provider]);
},
[credentials, selectedCredentials],
);
function handleCredentialSelect(
provider: string,
credential?: CredentialsMetaInput,
) {
if (credential) {
setSelectedCredentials((prev) => ({
...prev,
[provider]: credential,
}));
}
}
return {
selectedCredentials,
isAllComplete,
handleCredentialSelect,
};
}

View File

@@ -1,30 +0,0 @@
import React from "react";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
export interface ChatErrorStateProps {
error: Error;
onRetry?: () => void;
className?: string;
}
export function ChatErrorState({
error,
onRetry,
className,
}: ChatErrorStateProps) {
return (
<div
className={cn("flex flex-1 items-center justify-center p-6", className)}
>
<ErrorCard
responseError={{
message: error.message,
}}
context="chat session"
onRetry={onRetry}
className="max-w-md"
/>
</div>
);
}

View File

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

View File

@@ -1,19 +0,0 @@
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { cn } from "@/lib/utils";
export interface ChatLoadingStateProps {
message?: string;
className?: string;
}
export function ChatLoadingState({ className }: ChatLoadingStateProps) {
return (
<div
className={cn("flex flex-1 items-center justify-center p-6", className)}
>
<div className="flex flex-col items-center gap-4 text-center">
<LoadingSpinner />
</div>
</div>
);
}

View File

@@ -1,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&apos;re now signed in and ready to continue
</p>
</div>
</div>
</div>
</div>
</div>
);
}
// Show auth prompt if not logged in
return (
<div className={cn("px-4 py-2", className)}>
<AuthPromptWidget
message={message.message}
sessionId={message.sessionId}
agentInfo={message.agentInfo}
/>
</div>
);
}
// Render tool call messages
if (isToolCall && message.type === "tool_call") {
// 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;
}

View File

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

View File

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

View File

@@ -1,90 +0,0 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { ArrowSquareOut, CheckCircle, Play } from "@phosphor-icons/react";
export interface ExecutionStartedMessageProps {
executionId: string;
agentName?: string;
message?: string;
onViewExecution?: () => void;
className?: string;
}
export function ExecutionStartedMessage({
executionId,
agentName,
message = "Agent execution started successfully",
onViewExecution,
className,
}: ExecutionStartedMessageProps) {
return (
<div
className={cn(
"mx-4 my-2 flex flex-col gap-4 rounded-lg border border-green-200 bg-green-50 p-6",
className,
)}
>
{/* Icon & Header */}
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-green-500">
<CheckCircle size={24} weight="bold" className="text-white" />
</div>
<div className="flex-1">
<Text variant="h3" className="mb-1 text-green-900">
Execution Started
</Text>
<Text variant="body" className="text-green-700">
{message}
</Text>
</div>
</div>
{/* Details */}
<div className="rounded-md bg-green-100 p-4">
<div className="space-y-2">
{agentName && (
<div className="flex items-center justify-between">
<Text variant="small" className="font-semibold text-green-900">
Agent:
</Text>
<Text variant="body" className="text-green-800">
{agentName}
</Text>
</div>
)}
<div className="flex items-center justify-between">
<Text variant="small" className="font-semibold text-green-900">
Execution ID:
</Text>
<Text variant="small" className="font-mono text-green-800">
{executionId.slice(0, 16)}...
</Text>
</div>
</div>
</div>
{/* Action Buttons */}
{onViewExecution && (
<div className="flex gap-3">
<Button
onClick={onViewExecution}
variant="primary"
className="flex flex-1 items-center justify-center gap-2"
>
<ArrowSquareOut size={20} weight="bold" />
View Execution
</Button>
</div>
)}
<div className="flex items-center gap-2 text-green-600">
<Play size={16} weight="fill" />
<Text variant="small">
Your agent is now running. You can monitor its progress in the monitor
page.
</Text>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
import { useEffect, useRef, useCallback } from "react";
interface UseMessageListArgs {
messageCount: number;
isStreaming: boolean;
}
export function useMessageList({
messageCount,
isStreaming,
}: UseMessageListArgs) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messageCount, isStreaming, scrollToBottom]);
return {
messagesEndRef,
messagesContainerRef,
scrollToBottom,
};
}

View File

@@ -1,64 +0,0 @@
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { MagnifyingGlass, X } from "@phosphor-icons/react";
export interface NoResultsMessageProps {
message: string;
suggestions?: string[];
className?: string;
}
export function NoResultsMessage({
message,
suggestions = [],
className,
}: NoResultsMessageProps) {
return (
<div
className={cn(
"mx-4 my-2 flex flex-col items-center gap-4 rounded-lg border border-gray-200 bg-gray-50 p-6",
className,
)}
>
{/* Icon */}
<div className="relative flex h-16 w-16 items-center justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
<MagnifyingGlass size={32} weight="bold" className="text-gray-500" />
</div>
<div className="absolute -right-1 -top-1 flex h-8 w-8 items-center justify-center rounded-full bg-gray-400">
<X size={20} weight="bold" className="text-white" />
</div>
</div>
{/* Content */}
<div className="text-center">
<Text variant="h3" className="mb-2 text-gray-900">
No Results Found
</Text>
<Text variant="body" className="text-gray-700">
{message}
</Text>
</div>
{/* Suggestions */}
{suggestions.length > 0 && (
<div className="w-full space-y-2">
<Text variant="small" className="font-semibold text-gray-900">
Try these suggestions:
</Text>
<ul className="space-y-1 rounded-md bg-gray-100 p-4">
{suggestions.map((suggestion, index) => (
<li
key={index}
className="flex items-start gap-2 text-sm text-gray-700"
>
<span className="mt-1 text-gray-500"></span>
<span>{suggestion}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

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

View File

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

View File

@@ -1,136 +0,0 @@
"use client";
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
import { Text } from "@/components/atoms/Text/Text";
import { scrollbarStyles } from "@/components/styles/scrollbars";
import { cn } from "@/lib/utils";
import { X } from "@phosphor-icons/react";
import { formatDistanceToNow } from "date-fns";
import { Drawer } from "vaul";
interface SessionsDrawerProps {
isOpen: boolean;
onClose: () => void;
onSelectSession: (sessionId: string) => void;
currentSessionId?: string | null;
}
export function SessionsDrawer({
isOpen,
onClose,
onSelectSession,
currentSessionId,
}: SessionsDrawerProps) {
const { data, isLoading } = useGetV2ListSessions(
{ limit: 100 },
{
query: {
enabled: isOpen,
},
},
);
const sessions =
data?.status === 200
? data.data.sessions.filter((session) => {
// Filter out sessions without messages (sessions that were never updated)
// If updated_at equals created_at, the session was created but never had messages
return session.updated_at !== session.created_at;
})
: [];
function handleSelectSession(sessionId: string) {
onSelectSession(sessionId);
onClose();
}
return (
<Drawer.Root
open={isOpen}
onOpenChange={(open) => !open && onClose()}
direction="right"
>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
<Drawer.Content
className={cn(
"fixed right-0 top-0 z-[70] flex h-full w-96 flex-col border-l border-zinc-200 bg-white",
scrollbarStyles,
)}
>
<div className="shrink-0 p-4">
<div className="flex items-center justify-between">
<Drawer.Title className="text-lg font-semibold">
Chat Sessions
</Drawer.Title>
<button
aria-label="Close"
onClick={onClose}
className="flex size-8 items-center justify-center rounded hover:bg-zinc-100"
>
<X width="1.25rem" height="1.25rem" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Text variant="body" className="text-zinc-500">
Loading sessions...
</Text>
</div>
) : sessions.length === 0 ? (
<div className="flex h-full items-center justify-center">
<Text variant="body" className="text-zinc-500">
You don&apos;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>
);
}

View File

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

View File

@@ -1,25 +0,0 @@
import { useEffect, useState } from "react";
interface UseStreamingMessageArgs {
chunks: string[];
onComplete?: () => void;
}
export function useStreamingMessage({
chunks,
onComplete,
}: UseStreamingMessageArgs) {
const [isComplete, _setIsComplete] = useState(false);
const displayText = chunks.join("");
useEffect(() => {
if (isComplete && onComplete) {
onComplete();
}
}, [isComplete, onComplete]);
return {
displayText,
isComplete,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">
&quot;{agentName}&quot; 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>
);
}

View File

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

View File

@@ -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 `![video:${outputKey || "Video"}](${value})`;
}
if (category === "image") {
return `![${outputKey || "Generated image"}](${value})`;
}
// 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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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