Add Deep Agents UI, pipeline fixes, and agent improvements.
Includes deep-agents-ui integration, rework detection via Gitea, tool-call sanitization fixes, and startup scripts.
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
FormEvent,
|
||||
Fragment,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Square,
|
||||
ArrowUp,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Circle,
|
||||
FileIcon,
|
||||
} from "lucide-react";
|
||||
import { ChatMessage } from "@/app/components/ChatMessage";
|
||||
import type {
|
||||
TodoItem,
|
||||
ToolCall,
|
||||
ActionRequest,
|
||||
ReviewConfig,
|
||||
} from "@/app/types/types";
|
||||
import { Assistant, Message } from "@langchain/langgraph-sdk";
|
||||
import { extractStringFromMessageContent } from "@/app/utils/utils";
|
||||
import { useChatContext } from "@/providers/ChatProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useStickToBottom } from "use-stick-to-bottom";
|
||||
import { FilesPopover } from "@/app/components/TasksFilesSidebar";
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
assistant: Assistant | null;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: TodoItem["status"], className?: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return (
|
||||
<CheckCircle
|
||||
size={16}
|
||||
className={cn("text-success/80", className)}
|
||||
/>
|
||||
);
|
||||
case "in_progress":
|
||||
return (
|
||||
<Clock
|
||||
size={16}
|
||||
className={cn("text-warning/80", className)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Circle
|
||||
size={16}
|
||||
className={cn("text-tertiary/70", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
|
||||
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const { scrollRef, contentRef } = useStickToBottom();
|
||||
|
||||
const {
|
||||
stream,
|
||||
messages,
|
||||
todos,
|
||||
files,
|
||||
ui,
|
||||
setFiles,
|
||||
isLoading,
|
||||
isThreadLoading,
|
||||
interrupt,
|
||||
sendMessage,
|
||||
stopStream,
|
||||
resumeInterrupt,
|
||||
} = useChatContext();
|
||||
|
||||
const submitDisabled = isLoading || !assistant;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: FormEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const messageText = input.trim();
|
||||
if (!messageText || isLoading || submitDisabled) return;
|
||||
sendMessage(messageText);
|
||||
setInput("");
|
||||
},
|
||||
[input, isLoading, sendMessage, setInput, submitDisabled]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (submitDisabled) return;
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, submitDisabled]
|
||||
);
|
||||
|
||||
// TODO: can we make this part of the hook?
|
||||
const processedMessages = useMemo(() => {
|
||||
/*
|
||||
1. Loop through all messages
|
||||
2. For each AI message, add the AI message, and any tool calls to the messageMap
|
||||
3. For each tool message, find the corresponding tool call in the messageMap and update the status and output
|
||||
*/
|
||||
const messageMap = new Map<
|
||||
string,
|
||||
{ message: Message; toolCalls: ToolCall[] }
|
||||
>();
|
||||
messages.forEach((message: Message) => {
|
||||
if (message.type === "ai") {
|
||||
const toolCallsInMessage: Array<{
|
||||
id?: string;
|
||||
function?: { name?: string; arguments?: unknown };
|
||||
name?: string;
|
||||
type?: string;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}> = [];
|
||||
if (
|
||||
message.additional_kwargs?.tool_calls &&
|
||||
Array.isArray(message.additional_kwargs.tool_calls)
|
||||
) {
|
||||
toolCallsInMessage.push(...message.additional_kwargs.tool_calls);
|
||||
} else if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
||||
toolCallsInMessage.push(
|
||||
...message.tool_calls.filter(
|
||||
(toolCall: { name?: string }) => toolCall.name !== ""
|
||||
)
|
||||
);
|
||||
} else if (Array.isArray(message.content)) {
|
||||
const toolUseBlocks = message.content.filter(
|
||||
(block: { type?: string }) => block.type === "tool_use"
|
||||
);
|
||||
toolCallsInMessage.push(...toolUseBlocks);
|
||||
}
|
||||
const toolCallsWithStatus = toolCallsInMessage.map(
|
||||
(toolCall: {
|
||||
id?: string;
|
||||
function?: { name?: string; arguments?: unknown };
|
||||
name?: string;
|
||||
type?: string;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}) => {
|
||||
const name =
|
||||
toolCall.function?.name ||
|
||||
toolCall.name ||
|
||||
toolCall.type ||
|
||||
"unknown";
|
||||
const args =
|
||||
toolCall.function?.arguments ||
|
||||
toolCall.args ||
|
||||
toolCall.input ||
|
||||
{};
|
||||
return {
|
||||
id: toolCall.id || `tool-${Math.random()}`,
|
||||
name,
|
||||
args,
|
||||
status: interrupt ? "interrupted" : ("pending" as const),
|
||||
} as ToolCall;
|
||||
}
|
||||
);
|
||||
messageMap.set(message.id!, {
|
||||
message,
|
||||
toolCalls: toolCallsWithStatus,
|
||||
});
|
||||
} else if (message.type === "tool") {
|
||||
const toolCallId = message.tool_call_id;
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
for (const [, data] of messageMap.entries()) {
|
||||
const toolCallIndex = data.toolCalls.findIndex(
|
||||
(tc: ToolCall) => tc.id === toolCallId
|
||||
);
|
||||
if (toolCallIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
data.toolCalls[toolCallIndex] = {
|
||||
...data.toolCalls[toolCallIndex],
|
||||
status: "completed" as const,
|
||||
result: extractStringFromMessageContent(message),
|
||||
};
|
||||
break;
|
||||
}
|
||||
} else if (message.type === "human") {
|
||||
messageMap.set(message.id!, {
|
||||
message,
|
||||
toolCalls: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
const processedArray = Array.from(messageMap.values());
|
||||
return processedArray.map((data, index) => {
|
||||
const prevMessage = index > 0 ? processedArray[index - 1].message : null;
|
||||
return {
|
||||
...data,
|
||||
showAvatar: data.message.type !== prevMessage?.type,
|
||||
};
|
||||
});
|
||||
}, [messages, interrupt]);
|
||||
|
||||
const groupedTodos = {
|
||||
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||
pending: todos.filter((t) => t.status === "pending"),
|
||||
completed: todos.filter((t) => t.status === "completed"),
|
||||
};
|
||||
|
||||
const hasTasks = todos.length > 0;
|
||||
const hasFiles = Object.keys(files).length > 0;
|
||||
|
||||
// Parse out any action requests or review configs from the interrupt
|
||||
const actionRequestsMap: Map<string, ActionRequest> | null = useMemo(() => {
|
||||
const actionRequests =
|
||||
interrupt?.value && (interrupt.value as any)["action_requests"];
|
||||
if (!actionRequests) return new Map<string, ActionRequest>();
|
||||
return new Map(actionRequests.map((ar: ActionRequest) => [ar.name, ar]));
|
||||
}, [interrupt]);
|
||||
|
||||
const reviewConfigsMap: Map<string, ReviewConfig> | null = useMemo(() => {
|
||||
const reviewConfigs =
|
||||
interrupt?.value && (interrupt.value as any)["review_configs"];
|
||||
if (!reviewConfigs) return new Map<string, ReviewConfig>();
|
||||
return new Map(
|
||||
reviewConfigs.map((rc: ReviewConfig) => [rc.actionName, rc])
|
||||
);
|
||||
}, [interrupt]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div
|
||||
className="mx-auto w-full max-w-[1024px] px-6 pb-6 pt-4"
|
||||
ref={contentRef}
|
||||
>
|
||||
{isThreadLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{processedMessages.map((data, index) => {
|
||||
const messageUi = ui?.filter(
|
||||
(u: any) => u.metadata?.message_id === data.message.id
|
||||
);
|
||||
const isLastMessage = index === processedMessages.length - 1;
|
||||
return (
|
||||
<ChatMessage
|
||||
key={data.message.id}
|
||||
message={data.message}
|
||||
toolCalls={data.toolCalls}
|
||||
isLoading={isLoading}
|
||||
actionRequestsMap={
|
||||
isLastMessage ? actionRequestsMap : undefined
|
||||
}
|
||||
reviewConfigsMap={
|
||||
isLastMessage ? reviewConfigsMap : undefined
|
||||
}
|
||||
ui={messageUi}
|
||||
stream={stream}
|
||||
onResumeInterrupt={resumeInterrupt}
|
||||
graphId={assistant?.graph_id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 bg-background">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mb-6 flex flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-background",
|
||||
"mx-auto w-[calc(100%-32px)] max-w-[1024px] transition-colors duration-200 ease-in-out"
|
||||
)}
|
||||
>
|
||||
{(hasTasks || hasFiles) && (
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto border-b border-border bg-sidebar empty:hidden">
|
||||
{!metaOpen && (
|
||||
<>
|
||||
{(() => {
|
||||
const activeTask = todos.find(
|
||||
(t) => t.status === "in_progress"
|
||||
);
|
||||
|
||||
const totalTasks = todos.length;
|
||||
const remainingTasks =
|
||||
totalTasks - groupedTodos.pending.length;
|
||||
const isCompleted = totalTasks === remainingTasks;
|
||||
|
||||
const tasksTrigger = (() => {
|
||||
if (!hasTasks) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "tasks" ? null : "tasks"
|
||||
)
|
||||
}
|
||||
className="grid w-full cursor-pointer grid-cols-[auto_auto_1fr] items-center gap-3 px-[18px] py-3 text-left"
|
||||
aria-expanded={metaOpen === "tasks"}
|
||||
>
|
||||
{(() => {
|
||||
if (isCompleted) {
|
||||
return [
|
||||
<CheckCircle
|
||||
key="icon"
|
||||
size={16}
|
||||
className="text-success/80"
|
||||
/>,
|
||||
<span
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
All tasks completed
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
if (activeTask != null) {
|
||||
return [
|
||||
<div key="icon">
|
||||
{getStatusIcon(activeTask.status)}
|
||||
</div>,
|
||||
<span
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
Task{" "}
|
||||
{totalTasks - groupedTodos.pending.length} of{" "}
|
||||
{totalTasks}
|
||||
</span>,
|
||||
<span
|
||||
key="content"
|
||||
className="min-w-0 gap-2 truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{activeTask.content}
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
<Circle
|
||||
key="icon"
|
||||
size={16}
|
||||
className="text-tertiary/70"
|
||||
/>,
|
||||
<span
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
Task {totalTasks - groupedTodos.pending.length}{" "}
|
||||
of {totalTasks}
|
||||
</span>,
|
||||
];
|
||||
})()}
|
||||
</button>
|
||||
);
|
||||
})();
|
||||
|
||||
const filesTrigger = (() => {
|
||||
if (!hasFiles) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "files" ? null : "files"
|
||||
)
|
||||
}
|
||||
className="flex flex-shrink-0 cursor-pointer items-center gap-2 px-[18px] py-3 text-left text-sm"
|
||||
aria-expanded={metaOpen === "files"}
|
||||
>
|
||||
<FileIcon size={16} />
|
||||
Files (State)
|
||||
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||
{Object.keys(files).length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_auto] items-center">
|
||||
{tasksTrigger}
|
||||
{filesTrigger}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{metaOpen && (
|
||||
<>
|
||||
<div className="sticky top-0 flex items-stretch bg-sidebar text-sm">
|
||||
{hasTasks && (
|
||||
<button
|
||||
type="button"
|
||||
className="py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "tasks" ? null : "tasks"
|
||||
)
|
||||
}
|
||||
aria-expanded={metaOpen === "tasks"}
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
)}
|
||||
{hasFiles && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "files" ? null : "files"
|
||||
)
|
||||
}
|
||||
aria-expanded={metaOpen === "files"}
|
||||
>
|
||||
Files (State)
|
||||
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||
{Object.keys(files).length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="flex-1"
|
||||
onClick={() => setMetaOpen(null)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={tasksContainerRef}
|
||||
className="px-[18px]"
|
||||
>
|
||||
{metaOpen === "tasks" &&
|
||||
Object.entries(groupedTodos)
|
||||
.filter(([_, todos]) => todos.length > 0)
|
||||
.map(([status, todos]) => (
|
||||
<div
|
||||
key={status}
|
||||
className="mb-4"
|
||||
>
|
||||
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||
{
|
||||
{
|
||||
pending: "Pending",
|
||||
in_progress: "In Progress",
|
||||
completed: "Completed",
|
||||
}[status]
|
||||
}
|
||||
</h3>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-3 rounded-sm p-1 pl-0 text-sm">
|
||||
{todos.map((todo, index) => (
|
||||
<Fragment key={`${status}_${todo.id}_${index}`}>
|
||||
{getStatusIcon(todo.status, "mt-0.5")}
|
||||
<span className="break-words text-inherit">
|
||||
{todo.content}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{metaOpen === "files" && (
|
||||
<div className="mb-6">
|
||||
<FilesPopover
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
editDisabled={
|
||||
isLoading === true || interrupt !== undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isLoading ? "Running..." : "Write your message..."}
|
||||
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-[18px] pb-[13px] pt-[14px] text-sm leading-7 text-primary outline-none placeholder:text-tertiary"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="flex justify-between gap-2 p-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type={isLoading ? "button" : "submit"}
|
||||
variant={isLoading ? "destructive" : "default"}
|
||||
onClick={isLoading ? stopStream : handleSubmit}
|
||||
disabled={!isLoading && (submitDisabled || !input.trim())}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Square size={14} />
|
||||
<span>Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUp size={18} />
|
||||
<span>Send</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChatInterface.displayName = "ChatInterface";
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { SubAgentIndicator } from "@/app/components/SubAgentIndicator";
|
||||
import { ToolCallBox } from "@/app/components/ToolCallBox";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import type {
|
||||
SubAgent,
|
||||
ToolCall,
|
||||
ActionRequest,
|
||||
ReviewConfig,
|
||||
} from "@/app/types/types";
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
extractSubAgentContent,
|
||||
extractStringFromMessageContent,
|
||||
} from "@/app/utils/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
toolCalls: ToolCall[];
|
||||
isLoading?: boolean;
|
||||
actionRequestsMap?: Map<string, ActionRequest>;
|
||||
reviewConfigsMap?: Map<string, ReviewConfig>;
|
||||
ui?: any[];
|
||||
stream?: any;
|
||||
onResumeInterrupt?: (value: any) => void;
|
||||
graphId?: string;
|
||||
}
|
||||
|
||||
export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
({
|
||||
message,
|
||||
toolCalls,
|
||||
isLoading,
|
||||
actionRequestsMap,
|
||||
reviewConfigsMap,
|
||||
ui,
|
||||
stream,
|
||||
onResumeInterrupt,
|
||||
graphId,
|
||||
}) => {
|
||||
const isUser = message.type === "human";
|
||||
const messageContent = extractStringFromMessageContent(message);
|
||||
const hasContent = messageContent && messageContent.trim() !== "";
|
||||
const hasToolCalls = toolCalls.length > 0;
|
||||
const subAgents = useMemo(() => {
|
||||
return toolCalls
|
||||
.filter((toolCall: ToolCall) => {
|
||||
return (
|
||||
toolCall.name === "task" &&
|
||||
toolCall.args["subagent_type"] &&
|
||||
toolCall.args["subagent_type"] !== "" &&
|
||||
toolCall.args["subagent_type"] !== null
|
||||
);
|
||||
})
|
||||
.map((toolCall: ToolCall) => {
|
||||
const subagentType = (toolCall.args as Record<string, unknown>)[
|
||||
"subagent_type"
|
||||
] as string;
|
||||
return {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
subAgentName: subagentType,
|
||||
input: toolCall.args,
|
||||
output: toolCall.result ? { result: toolCall.result } : undefined,
|
||||
status: toolCall.status,
|
||||
} as SubAgent;
|
||||
});
|
||||
}, [toolCalls]);
|
||||
|
||||
const [expandedSubAgents, setExpandedSubAgents] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const isSubAgentExpanded = useCallback(
|
||||
(id: string) => expandedSubAgents[id] ?? true,
|
||||
[expandedSubAgents]
|
||||
);
|
||||
const toggleSubAgent = useCallback((id: string) => {
|
||||
setExpandedSubAgents((prev) => ({
|
||||
...prev,
|
||||
[id]: prev[id] === undefined ? false : !prev[id],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full max-w-full overflow-x-hidden",
|
||||
isUser && "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full",
|
||||
isUser ? "max-w-[70%]" : "w-full"
|
||||
)}
|
||||
>
|
||||
{hasContent && (
|
||||
<div className={cn("relative flex items-end gap-0")}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 overflow-hidden break-words text-sm font-normal leading-[150%]",
|
||||
isUser
|
||||
? "rounded-xl rounded-br-none border border-border px-3 py-2 text-foreground"
|
||||
: "text-primary"
|
||||
)}
|
||||
style={
|
||||
isUser
|
||||
? { backgroundColor: "var(--color-user-message-bg)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||
{messageContent}
|
||||
</p>
|
||||
) : hasContent ? (
|
||||
<MarkdownContent content={messageContent} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasToolCalls && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
{toolCalls.map((toolCall: ToolCall) => {
|
||||
if (toolCall.name === "task") return null;
|
||||
const toolCallGenUiComponent = ui?.find(
|
||||
(u) => u.metadata?.tool_call_id === toolCall.id
|
||||
);
|
||||
const actionRequest = actionRequestsMap?.get(toolCall.name);
|
||||
const reviewConfig = reviewConfigsMap?.get(toolCall.name);
|
||||
return (
|
||||
<ToolCallBox
|
||||
key={toolCall.id}
|
||||
toolCall={toolCall}
|
||||
uiComponent={toolCallGenUiComponent}
|
||||
stream={stream}
|
||||
graphId={graphId}
|
||||
actionRequest={actionRequest}
|
||||
reviewConfig={reviewConfig}
|
||||
onResume={onResumeInterrupt}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isUser && subAgents.length > 0 && (
|
||||
<div className="flex w-fit max-w-full flex-col gap-4">
|
||||
{subAgents.map((subAgent) => (
|
||||
<div
|
||||
key={subAgent.id}
|
||||
className="flex w-full flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="w-[calc(100%-100px)]">
|
||||
<SubAgentIndicator
|
||||
subAgent={subAgent}
|
||||
onClick={() => toggleSubAgent(subAgent.id)}
|
||||
isExpanded={isSubAgentExpanded(subAgent.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isSubAgentExpanded(subAgent.id) && (
|
||||
<div className="w-full max-w-full">
|
||||
<div className="bg-surface border-border-light rounded-md border p-4">
|
||||
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||
Input
|
||||
</h4>
|
||||
<div className="mb-4">
|
||||
<MarkdownContent
|
||||
content={extractSubAgentContent(subAgent.input)}
|
||||
/>
|
||||
</div>
|
||||
{subAgent.output && (
|
||||
<>
|
||||
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||
Output
|
||||
</h4>
|
||||
<MarkdownContent
|
||||
content={extractSubAgentContent(subAgent.output)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChatMessage.displayName = "ChatMessage";
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StandaloneConfig } from "@/lib/config";
|
||||
|
||||
interface ConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (config: StandaloneConfig) => void;
|
||||
initialConfig?: StandaloneConfig;
|
||||
}
|
||||
|
||||
export function ConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
initialConfig,
|
||||
}: ConfigDialogProps) {
|
||||
const [deploymentUrl, setDeploymentUrl] = useState(
|
||||
initialConfig?.deploymentUrl ||
|
||||
process.env.NEXT_PUBLIC_DEPLOYMENT_URL ||
|
||||
""
|
||||
);
|
||||
const [assistantId, setAssistantId] = useState(
|
||||
initialConfig?.assistantId || process.env.NEXT_PUBLIC_ASSISTANT_ID || ""
|
||||
);
|
||||
const [langsmithApiKey, setLangsmithApiKey] = useState(
|
||||
initialConfig?.langsmithApiKey ||
|
||||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
|
||||
""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && initialConfig) {
|
||||
setDeploymentUrl(initialConfig.deploymentUrl);
|
||||
setAssistantId(initialConfig.assistantId);
|
||||
setLangsmithApiKey(initialConfig.langsmithApiKey || "");
|
||||
}
|
||||
}, [open, initialConfig]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!deploymentUrl || !assistantId) {
|
||||
alert("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
deploymentUrl,
|
||||
assistantId,
|
||||
langsmithApiKey: langsmithApiKey || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your LangGraph deployment settings. These settings are
|
||||
saved in your browser's local storage.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="deploymentUrl">Deployment URL</Label>
|
||||
<Input
|
||||
id="deploymentUrl"
|
||||
placeholder="https://<deployment-url>"
|
||||
value={deploymentUrl}
|
||||
onChange={(e) => setDeploymentUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="assistantId">Assistant ID</Label>
|
||||
<Input
|
||||
id="assistantId"
|
||||
placeholder="<assistant-id>"
|
||||
value={assistantId}
|
||||
onChange={(e) => setAssistantId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="langsmithApiKey">
|
||||
LangSmith API Key{" "}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="langsmithApiKey"
|
||||
type="password"
|
||||
placeholder="lsv2_pt_..."
|
||||
value={langsmithApiKey}
|
||||
onChange={(e) => setLangsmithApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useCallback, useState, useEffect } from "react";
|
||||
import { FileText, Copy, Download, Edit, Save, X, Loader2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { toast } from "sonner";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import type { FileItem } from "@/app/types/types";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
java: "java",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
scala: "scala",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sass: "sass",
|
||||
less: "less",
|
||||
sql: "sql",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
ini: "ini",
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "makefile",
|
||||
};
|
||||
|
||||
export const FileViewDialog = React.memo<{
|
||||
file: FileItem | null;
|
||||
onSaveFile: (fileName: string, content: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
editDisabled: boolean;
|
||||
}>(({ file, onSaveFile, onClose, editDisabled }) => {
|
||||
const [isEditingMode, setIsEditingMode] = useState(file === null);
|
||||
const [fileName, setFileName] = useState(String(file?.path || ""));
|
||||
const [fileContent, setFileContent] = useState(String(file?.content || ""));
|
||||
|
||||
const fileUpdate = useSWRMutation(
|
||||
{ kind: "files-update", fileName, fileContent },
|
||||
async ({ fileName, fileContent }) => {
|
||||
if (!fileName || !fileContent) return;
|
||||
return await onSaveFile(fileName, fileContent);
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsEditingMode(false),
|
||||
onError: (error) => toast.error(`Failed to save file: ${error}`),
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFileName(String(file?.path || ""));
|
||||
setFileContent(String(file?.content || ""));
|
||||
setIsEditingMode(file === null);
|
||||
}, [file]);
|
||||
|
||||
const fileExtension = useMemo(() => {
|
||||
const fileNameStr = String(fileName || "");
|
||||
return fileNameStr.split(".").pop()?.toLowerCase() || "";
|
||||
}, [fileName]);
|
||||
|
||||
const isMarkdown = useMemo(() => {
|
||||
return fileExtension === "md" || fileExtension === "markdown";
|
||||
}, [fileExtension]);
|
||||
|
||||
const language = useMemo(() => {
|
||||
return LANGUAGE_MAP[fileExtension] || "text";
|
||||
}, [fileExtension]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (fileContent) {
|
||||
navigator.clipboard.writeText(fileContent);
|
||||
}
|
||||
}, [fileContent]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (fileContent && fileName) {
|
||||
const blob = new Blob([fileContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [fileContent, fileName]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditingMode(true);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (file === null) {
|
||||
onClose();
|
||||
} else {
|
||||
setFileName(String(file.path));
|
||||
setFileContent(String(file.content));
|
||||
setIsEditingMode(false);
|
||||
}
|
||||
}, [file, onClose]);
|
||||
|
||||
const fileNameIsValid = useMemo(() => {
|
||||
return (
|
||||
fileName.trim() !== "" &&
|
||||
!fileName.includes("/") &&
|
||||
!fileName.includes(" ")
|
||||
);
|
||||
}, [fileName]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={onClose}
|
||||
>
|
||||
<DialogContent className="flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6">
|
||||
<DialogTitle className="sr-only">
|
||||
{file?.path || "New File"}
|
||||
</DialogTitle>
|
||||
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
|
||||
{isEditingMode && file === null ? (
|
||||
<Input
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
placeholder="Enter filename..."
|
||||
className="text-base font-medium"
|
||||
aria-invalid={!fileNameIsValid}
|
||||
/>
|
||||
) : (
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
|
||||
{file?.path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!isEditingMode && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
disabled={editDisabled}
|
||||
>
|
||||
<Edit
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Copy
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Download
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{isEditingMode ? (
|
||||
<Textarea
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
placeholder="Enter file content..."
|
||||
className="h-full min-h-[400px] resize-none font-mono text-sm"
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea className="bg-surface h-full rounded-md">
|
||||
<div className="p-4">
|
||||
{fileContent ? (
|
||||
isMarkdown ? (
|
||||
<div className="rounded-md p-6">
|
||||
<MarkdownContent content={fileContent} />
|
||||
</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
showLineNumbers
|
||||
wrapLines={true}
|
||||
lineProps={{
|
||||
style: {
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{fileContent}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File is empty
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
{isEditingMode && (
|
||||
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<X
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => fileUpdate.trigger()}
|
||||
size="sm"
|
||||
disabled={
|
||||
fileUpdate.isMutating ||
|
||||
!fileName.trim() ||
|
||||
!fileContent.trim() ||
|
||||
!fileNameIsValid
|
||||
}
|
||||
>
|
||||
{fileUpdate.isMutating ? (
|
||||
<Loader2
|
||||
size={16}
|
||||
className="mr-1 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<Save
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
FileViewDialog.displayName = "FileViewDialog";
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MarkdownContent = React.memo<MarkdownContentProps>(
|
||||
({ content, className = "" }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed text-inherit [&_h1:first-child]:mt-0 [&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:font-semibold [&_h2:first-child]:mt-0 [&_h2]:mb-4 [&_h2]:mt-6 [&_h2]:font-semibold [&_h3:first-child]:mt-0 [&_h3]:mb-4 [&_h3]:mt-6 [&_h3]:font-semibold [&_h4:first-child]:mt-0 [&_h4]:mb-4 [&_h4]:mt-6 [&_h4]:font-semibold [&_h5:first-child]:mt-0 [&_h5]:mb-4 [&_h5]:mt-6 [&_h5]:font-semibold [&_h6:first-child]:mt-0 [&_h6]:mb-4 [&_h6]:mt-6 [&_h6]:font-semibold [&_p:last-child]:mb-0 [&_p]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="max-w-full rounded-md text-sm"
|
||||
wrapLines={true}
|
||||
wrapLongLines={true}
|
||||
lineProps={{
|
||||
style: {
|
||||
wordBreak: "break-all",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "break-word",
|
||||
},
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
maxWidth: "100%",
|
||||
overflowX: "auto",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className="bg-surface rounded-sm px-1 py-0.5 font-mono text-[0.9em]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-4 max-w-full overflow-hidden last:mb-0">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
a({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary no-underline hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
blockquote({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<blockquote className="text-primary/50 my-4 border-l-4 border-border pl-4 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
ul({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ul className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ol className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
table({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="[&_th]:bg-surface w-full border-collapse [&_td]:border [&_td]:border-border [&_td]:p-2 [&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:text-left [&_th]:font-semibold">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MarkdownContent.displayName = "MarkdownContent";
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { SubAgent } from "@/app/types/types";
|
||||
|
||||
interface SubAgentIndicatorProps {
|
||||
subAgent: SubAgent;
|
||||
onClick: () => void;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
|
||||
({ subAgent, onClick, isExpanded = true }) => {
|
||||
return (
|
||||
<div className="w-fit max-w-[70vw] overflow-hidden rounded-lg border-none bg-card shadow-none outline-none">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between gap-2 border-none px-4 py-2 text-left shadow-none outline-none transition-colors duration-200"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-sans text-[15px] font-bold leading-[140%] tracking-[-0.6px] text-[#3F3F46]">
|
||||
{subAgent.subAgentName}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className="shrink-0 text-[#70707B]"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="shrink-0 text-[#70707B]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SubAgentIndicator.displayName = "SubAgentIndicator";
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { TodoItem, FileItem } from "@/app/types/types";
|
||||
import { useChatContext } from "@/providers/ChatProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FileViewDialog } from "@/app/components/FileViewDialog";
|
||||
|
||||
export function FilesPopover({
|
||||
files,
|
||||
setFiles,
|
||||
editDisabled,
|
||||
}: {
|
||||
files: Record<string, string>;
|
||||
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||
editDisabled: boolean;
|
||||
}) {
|
||||
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
|
||||
|
||||
const handleSaveFile = useCallback(
|
||||
async (fileName: string, content: string) => {
|
||||
await setFiles({ ...files, [fileName]: content });
|
||||
setSelectedFile({ path: fileName, content: content });
|
||||
},
|
||||
[files, setFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(files).length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">No files created yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(256px,1fr))] gap-2">
|
||||
{Object.keys(files).map((file) => {
|
||||
const filePath = String(file);
|
||||
const rawContent = files[file];
|
||||
let fileContent: string;
|
||||
if (
|
||||
typeof rawContent === "object" &&
|
||||
rawContent !== null &&
|
||||
"content" in rawContent
|
||||
) {
|
||||
const contentArray = (rawContent as { content: unknown }).content;
|
||||
if (Array.isArray(contentArray)) {
|
||||
fileContent = contentArray.join("\n");
|
||||
} else {
|
||||
fileContent = String(contentArray || "");
|
||||
}
|
||||
} else {
|
||||
fileContent = String(rawContent || "");
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedFile({ path: filePath, content: fileContent })
|
||||
}
|
||||
className="cursor-pointer space-y-1 truncate rounded-md border border-border px-2 py-3 shadow-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--color-file-button)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--color-file-button-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--color-file-button)";
|
||||
}}
|
||||
>
|
||||
<FileText
|
||||
size={24}
|
||||
className="mx-auto text-muted-foreground"
|
||||
/>
|
||||
<span className="mx-auto block w-full truncate break-words text-center text-sm leading-relaxed text-foreground">
|
||||
{filePath}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFile && (
|
||||
<FileViewDialog
|
||||
file={selectedFile}
|
||||
onSaveFile={handleSaveFile}
|
||||
onClose={() => setSelectedFile(null)}
|
||||
editDisabled={editDisabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const TasksFilesSidebar = React.memo<{
|
||||
todos: TodoItem[];
|
||||
files: Record<string, string>;
|
||||
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||
}>(({ todos, files, setFiles }) => {
|
||||
const { isLoading, interrupt } = useChatContext();
|
||||
const [tasksOpen, setTasksOpen] = useState(false);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
|
||||
// Track previous counts to detect when content goes from empty to having items
|
||||
const prevTodosCount = useRef(todos.length);
|
||||
const prevFilesCount = useRef(Object.keys(files).length);
|
||||
|
||||
// Auto-expand when todos go from empty to having content
|
||||
useEffect(() => {
|
||||
if (prevTodosCount.current === 0 && todos.length > 0) {
|
||||
setTasksOpen(true);
|
||||
}
|
||||
prevTodosCount.current = todos.length;
|
||||
}, [todos.length]);
|
||||
|
||||
// Auto-expand when files go from empty to having content
|
||||
const filesCount = Object.keys(files).length;
|
||||
useEffect(() => {
|
||||
if (prevFilesCount.current === 0 && filesCount > 0) {
|
||||
setFilesOpen(true);
|
||||
}
|
||||
prevFilesCount.current = filesCount;
|
||||
}, [filesCount]);
|
||||
|
||||
const getStatusIcon = useCallback((status: TodoItem["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return (
|
||||
<CheckCircle
|
||||
size={12}
|
||||
className="text-success/80"
|
||||
/>
|
||||
);
|
||||
case "in_progress":
|
||||
return (
|
||||
<Clock
|
||||
size={12}
|
||||
className="text-warning/80"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Circle
|
||||
size={10}
|
||||
className="text-tertiary/70"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const groupedTodos = useMemo(() => {
|
||||
return {
|
||||
pending: todos.filter((t) => t.status === "pending"),
|
||||
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||
completed: todos.filter((t) => t.status === "completed"),
|
||||
};
|
||||
}, [todos]);
|
||||
|
||||
const groupedLabels = {
|
||||
pending: "Pending",
|
||||
in_progress: "In Progress",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-0 w-full flex-1">
|
||||
<div className="font-inter flex h-full w-full flex-col p-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||
AGENT TASKS
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTasksOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||
tasksOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
aria-label="Toggle tasks panel"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{tasksOpen && (
|
||||
<div className="bg-muted-secondary rounded-xl px-3 pb-2">
|
||||
<ScrollArea className="h-full">
|
||||
{todos.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tasks created yet
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-1 p-0.5">
|
||||
{Object.entries(groupedTodos).map(([status, todos]) => (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||
{groupedLabels[status as keyof typeof groupedLabels]}
|
||||
</h3>
|
||||
{todos.map((todo, index) => (
|
||||
<div
|
||||
key={`${status}_${todo.id}_${index}`}
|
||||
className="mb-1.5 flex items-start gap-2 rounded-sm p-1 text-sm"
|
||||
>
|
||||
{getStatusIcon(todo.status)}
|
||||
<span className="flex-1 break-words leading-relaxed text-inherit">
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||
FILE SYSTEM
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||
filesOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
aria-label="Toggle files panel"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{filesOpen && (
|
||||
<FilesPopover
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
editDisabled={isLoading === true || interrupt !== undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TasksFilesSidebar.displayName = "TasksFilesSidebar";
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Loader2, MessageSquare, X } from "lucide-react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ThreadItem } from "@/app/hooks/useThreads";
|
||||
import { useThreads } from "@/app/hooks/useThreads";
|
||||
|
||||
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
|
||||
|
||||
const GROUP_LABELS = {
|
||||
interrupted: "Requiring Attention",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
week: "This Week",
|
||||
older: "Older",
|
||||
} as const;
|
||||
|
||||
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
|
||||
idle: "bg-green-500",
|
||||
busy: "bg-blue-500",
|
||||
interrupted: "bg-orange-500",
|
||||
error: "bg-red-600",
|
||||
};
|
||||
|
||||
function getThreadColor(status: ThreadItem["status"]): string {
|
||||
return STATUS_COLORS[status] ?? "bg-gray-400";
|
||||
}
|
||||
|
||||
function formatTime(date: Date, now = new Date()): string {
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return format(date, "HH:mm");
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return format(date, "EEEE");
|
||||
return format(date, "MM/dd");
|
||||
}
|
||||
|
||||
function StatusFilterItem({
|
||||
status,
|
||||
label,
|
||||
badge,
|
||||
}: {
|
||||
status: ThreadItem["status"];
|
||||
label: string;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
getThreadColor(status)
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-bold leading-none text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-red-600">Failed to load threads</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="space-y-2 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-16 w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-muted-foreground">No threads found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListProps {
|
||||
onThreadSelect: (id: string) => void;
|
||||
onMutateReady?: (mutate: () => void) => void;
|
||||
onClose?: () => void;
|
||||
onInterruptCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function ThreadList({
|
||||
onThreadSelect,
|
||||
onMutateReady,
|
||||
onClose,
|
||||
onInterruptCountChange,
|
||||
}: ThreadListProps) {
|
||||
const [currentThreadId] = useQueryState("threadId");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
|
||||
const threads = useThreads({
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const flattened = useMemo(() => {
|
||||
return threads.data?.flat() ?? [];
|
||||
}, [threads.data]);
|
||||
|
||||
const isLoadingMore =
|
||||
threads.size > 0 && threads.data?.[threads.size - 1] == null;
|
||||
const isEmpty = threads.data?.at(0)?.length === 0;
|
||||
const isReachingEnd = isEmpty || (threads.data?.at(-1)?.length ?? 0) < 20;
|
||||
|
||||
// Group threads by time and status
|
||||
const grouped = useMemo(() => {
|
||||
const now = new Date();
|
||||
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
|
||||
interrupted: [],
|
||||
today: [],
|
||||
yesterday: [],
|
||||
week: [],
|
||||
older: [],
|
||||
};
|
||||
|
||||
flattened.forEach((thread) => {
|
||||
if (thread.status === "interrupted") {
|
||||
groups.interrupted.push(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = now.getTime() - thread.updatedAt.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
groups.today.push(thread);
|
||||
} else if (days === 1) {
|
||||
groups.yesterday.push(thread);
|
||||
} else if (days < 7) {
|
||||
groups.week.push(thread);
|
||||
} else {
|
||||
groups.older.push(thread);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [flattened]);
|
||||
|
||||
const interruptedCount = useMemo(() => {
|
||||
return flattened.filter((t) => t.status === "interrupted").length;
|
||||
}, [flattened]);
|
||||
|
||||
// Expose thread list revalidation to parent component
|
||||
// Use refs to create a stable callback that always calls the latest mutate function
|
||||
const onMutateReadyRef = useRef(onMutateReady);
|
||||
const mutateRef = useRef(threads.mutate);
|
||||
|
||||
useEffect(() => {
|
||||
onMutateReadyRef.current = onMutateReady;
|
||||
}, [onMutateReady]);
|
||||
|
||||
useEffect(() => {
|
||||
mutateRef.current = threads.mutate;
|
||||
}, [threads.mutate]);
|
||||
|
||||
const mutateFn = useCallback(() => {
|
||||
mutateRef.current();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onMutateReadyRef.current?.(mutateFn);
|
||||
// Only run once on mount to avoid infinite loops
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Notify parent of interrupt count changes
|
||||
useEffect(() => {
|
||||
onInterruptCountChange?.(interruptedCount);
|
||||
}, [interruptedCount, onInterruptCountChange]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
{/* Header with title, filter, and close button */}
|
||||
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Threads</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Active</SelectLabel>
|
||||
<SelectItem value="idle">
|
||||
<StatusFilterItem
|
||||
status="idle"
|
||||
label="Idle"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="busy">
|
||||
<StatusFilterItem
|
||||
status="busy"
|
||||
label="Busy"
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Attention</SelectLabel>
|
||||
<SelectItem value="interrupted">
|
||||
<StatusFilterItem
|
||||
status="interrupted"
|
||||
label="Interrupted"
|
||||
badge={interruptedCount}
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="error">
|
||||
<StatusFilterItem
|
||||
status="error"
|
||||
label="Error"
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
aria-label="Close threads sidebar"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-0 flex-1">
|
||||
{threads.error && <ErrorState message={threads.error.message} />}
|
||||
|
||||
{!threads.error && !threads.data && threads.isLoading && (
|
||||
<LoadingState />
|
||||
)}
|
||||
|
||||
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
|
||||
|
||||
{!threads.error && !isEmpty && (
|
||||
<div className="box-border w-full max-w-full overflow-hidden p-2">
|
||||
{(
|
||||
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
|
||||
).map((group) => {
|
||||
const groupThreads = grouped[group];
|
||||
if (groupThreads.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group}
|
||||
className="mb-4"
|
||||
>
|
||||
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{GROUP_LABELS[group]}
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{groupThreads.map((thread) => (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
onClick={() => onThreadSelect(thread.id)}
|
||||
className={cn(
|
||||
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
|
||||
"hover:bg-accent",
|
||||
currentThreadId === thread.id
|
||||
? "border border-primary bg-accent hover:bg-accent"
|
||||
: "border border-transparent bg-transparent"
|
||||
)}
|
||||
aria-current={currentThreadId === thread.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Title + Timestamp Row */}
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h3 className="truncate text-sm font-semibold">
|
||||
{thread.title}
|
||||
</h3>
|
||||
<span className="ml-2 flex-shrink-0 text-xs text-muted-foreground">
|
||||
{formatTime(thread.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Description + Status Row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex-1 truncate text-sm text-muted-foreground">
|
||||
{thread.description}
|
||||
</p>
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
getThreadColor(thread.status)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isReachingEnd && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => threads.setSize(threads.size + 1)}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Load More"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertCircle, Check, X, Pencil } from "lucide-react";
|
||||
import type { ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ToolApprovalInterruptProps {
|
||||
actionRequest: ActionRequest;
|
||||
reviewConfig?: ReviewConfig;
|
||||
onResume: (value: any) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ToolApprovalInterrupt({
|
||||
actionRequest,
|
||||
reviewConfig,
|
||||
onResume,
|
||||
isLoading,
|
||||
}: ToolApprovalInterruptProps) {
|
||||
const [rejectionMessage, setRejectionMessage] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>({});
|
||||
const [showRejectionInput, setShowRejectionInput] = useState(false);
|
||||
|
||||
const allowedDecisions = reviewConfig?.allowedDecisions ?? [
|
||||
"approve",
|
||||
"reject",
|
||||
"edit",
|
||||
];
|
||||
|
||||
const handleApprove = () => {
|
||||
onResume({
|
||||
decisions: [{ type: "approve" }],
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
if (showRejectionInput) {
|
||||
onResume({
|
||||
decisions: [
|
||||
{
|
||||
type: "reject",
|
||||
message: rejectionMessage.trim(),
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
setShowRejectionInput(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectConfirm = () => {
|
||||
onResume({
|
||||
decisions: [
|
||||
{
|
||||
type: "reject",
|
||||
message: rejectionMessage.trim(),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (isEditing) {
|
||||
onResume({
|
||||
decisions: [
|
||||
{
|
||||
type: "edit",
|
||||
edited_action: {
|
||||
name: actionRequest.name,
|
||||
args: editedArgs,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
setIsEditing(false);
|
||||
setEditedArgs({});
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setIsEditing(true);
|
||||
setEditedArgs(JSON.parse(JSON.stringify(actionRequest.args)));
|
||||
setShowRejectionInput(false);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setIsEditing(false);
|
||||
setEditedArgs({});
|
||||
};
|
||||
|
||||
const updateEditedArg = (key: string, value: string) => {
|
||||
try {
|
||||
const parsedValue =
|
||||
value.trim().startsWith("{") || value.trim().startsWith("[")
|
||||
? JSON.parse(value)
|
||||
: value;
|
||||
setEditedArgs((prev) => ({ ...prev, [key]: parsedValue }));
|
||||
} catch {
|
||||
setEditedArgs((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-md border border-border bg-muted/30 p-4">
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-yellow-600 dark:text-yellow-400"
|
||||
/>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
Approval Required
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{actionRequest.description && (
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{actionRequest.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tool Info Card */}
|
||||
<div className="mb-4 rounded-sm border border-border bg-background p-3">
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Tool
|
||||
</span>
|
||||
<p className="mt-1 font-mono text-sm font-medium text-foreground">
|
||||
{actionRequest.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Edit Arguments
|
||||
</span>
|
||||
<div className="mt-2 space-y-3">
|
||||
{Object.entries(actionRequest.args).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<label className="mb-1 block text-xs font-medium text-foreground">
|
||||
{key}
|
||||
</label>
|
||||
<Textarea
|
||||
value={
|
||||
editedArgs[key] !== undefined
|
||||
? typeof editedArgs[key] === "string"
|
||||
? (editedArgs[key] as string)
|
||||
: JSON.stringify(editedArgs[key], null, 2)
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2)
|
||||
}
|
||||
onChange={(e) => updateEditedArg(key, e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
rows={
|
||||
typeof value === "string" && value.length < 100 ? 2 : 4
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Arguments
|
||||
</span>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs text-foreground">
|
||||
{JSON.stringify(actionRequest.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rejection Message Input */}
|
||||
{showRejectionInput && !isEditing && (
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-medium text-foreground">
|
||||
Rejection Message (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
value={rejectionMessage}
|
||||
onChange={(e) => setRejectionMessage(e.target.value)}
|
||||
placeholder="Explain why you're rejecting this action..."
|
||||
className="text-sm"
|
||||
rows={2}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={cancelEditing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
<Check size={14} />
|
||||
{isLoading ? "Saving..." : "Save & Approve"}
|
||||
</Button>
|
||||
</>
|
||||
) : showRejectionInput ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowRejectionInput(false);
|
||||
setRejectionMessage("");
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRejectConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Rejecting..." : "Confirm Reject"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{allowedDecisions.includes("reject") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReject}
|
||||
disabled={isLoading}
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X size={14} />
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("edit") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={startEditing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("approve") && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"bg-green-600 text-white hover:bg-green-700",
|
||||
"dark:bg-green-600 dark:hover:bg-green-700"
|
||||
)}
|
||||
>
|
||||
<Check size={14} />
|
||||
{isLoading ? "Approving..." : "Approve"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Terminal,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
CircleCheckBigIcon,
|
||||
StopCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
|
||||
|
||||
interface ToolCallBoxProps {
|
||||
toolCall: ToolCall;
|
||||
uiComponent?: any;
|
||||
stream?: any;
|
||||
graphId?: string;
|
||||
actionRequest?: ActionRequest;
|
||||
reviewConfig?: ReviewConfig;
|
||||
onResume?: (value: any) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
({
|
||||
toolCall,
|
||||
uiComponent,
|
||||
stream,
|
||||
graphId,
|
||||
actionRequest,
|
||||
reviewConfig,
|
||||
onResume,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
() => !!uiComponent || !!actionRequest
|
||||
);
|
||||
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const { name, args, result, status } = useMemo(() => {
|
||||
return {
|
||||
name: toolCall.name || "Unknown Tool",
|
||||
args: toolCall.args || {},
|
||||
result: toolCall.result,
|
||||
status: toolCall.status || "completed",
|
||||
};
|
||||
}, [toolCall]);
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CircleCheckBigIcon />;
|
||||
case "error":
|
||||
return (
|
||||
<AlertCircle
|
||||
size={14}
|
||||
className="text-destructive"
|
||||
/>
|
||||
);
|
||||
case "pending":
|
||||
return (
|
||||
<Loader2
|
||||
size={14}
|
||||
className="animate-spin"
|
||||
/>
|
||||
);
|
||||
case "interrupted":
|
||||
return (
|
||||
<StopCircle
|
||||
size={14}
|
||||
className="text-orange-500"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Terminal
|
||||
size={14}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const toggleArgExpanded = useCallback((argKey: string) => {
|
||||
setExpandedArgs((prev) => ({
|
||||
...prev,
|
||||
[argKey]: !prev[argKey],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const hasContent = result || Object.keys(args).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-lg border-none shadow-none outline-none transition-colors duration-200 hover:bg-accent",
|
||||
isExpanded && hasContent && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 border-none px-2 py-2 text-left shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-default"
|
||||
)}
|
||||
disabled={!hasContent}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcon}
|
||||
<span className="text-[15px] font-medium tracking-[-0.6px] text-foreground">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
{hasContent &&
|
||||
(isExpanded ? (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{isExpanded && hasContent && (
|
||||
<div className="px-4 pb-4">
|
||||
{uiComponent && stream && graphId ? (
|
||||
<div className="mt-4">
|
||||
<LoadExternalComponent
|
||||
key={uiComponent.id}
|
||||
stream={stream}
|
||||
message={uiComponent}
|
||||
namespace={graphId}
|
||||
meta={{ status, args, result: result ?? "No Result Yet" }}
|
||||
/>
|
||||
</div>
|
||||
) : actionRequest && onResume ? (
|
||||
// Show tool approval UI when there's an action request but no GenUI
|
||||
<div className="mt-4">
|
||||
<ToolApprovalInterrupt
|
||||
actionRequest={actionRequest}
|
||||
reviewConfig={reviewConfig}
|
||||
onResume={onResume}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(args).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Arguments
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(args).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-sm border border-border"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleArgExpanded(key)}
|
||||
className="flex w-full items-center justify-between bg-muted/30 p-2 text-left text-xs font-medium transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-mono">{key}</span>
|
||||
{expandedArgs[key] ? (
|
||||
<ChevronUp
|
||||
size={12}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{expandedArgs[key] && (
|
||||
<div className="border-t border-border bg-muted/20 p-2">
|
||||
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs leading-6 text-foreground">
|
||||
{typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Result
|
||||
</h4>
|
||||
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs leading-7 text-foreground">
|
||||
{typeof result === "string"
|
||||
? result
|
||||
: JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ToolCallBox.displayName = "ToolCallBox";
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,395 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* Remove default focus box-shadows */
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Set default outline color to match brand instead of browser blue */
|
||||
* {
|
||||
outline-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:root {
|
||||
/* App-specific color variables */
|
||||
--color-primary: #1c3c3c;
|
||||
--color-user-message: #076699;
|
||||
--color-user-message-bg: #e8f4f8;
|
||||
--color-avatar-bg: #e8ebeb;
|
||||
--color-secondary: #1c3c3c;
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-background: #f9f9f9;
|
||||
--color-subagent-hover: #bbc4c4;
|
||||
--color-surface: #f9fafb;
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-light: #f3f4f6;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
--color-file-button: #ffffff;
|
||||
--color-file-button-hover: #e5e7eb;
|
||||
|
||||
/* Dark theme colors */
|
||||
--color-primary-dark: #2dd4bf;
|
||||
--color-user-message-dark: #076699;
|
||||
--color-avatar-bg-dark: #bcb2fd;
|
||||
--color-secondary-dark: #2dd4bf;
|
||||
--color-success-dark: #34d399;
|
||||
--color-warning-dark: #fbbf24;
|
||||
--color-error-dark: #f87171;
|
||||
--color-background-dark: #0f0f0f;
|
||||
--color-subagent-hover-dark: #d0c9fe;
|
||||
--color-surface-dark: #1a1a1a;
|
||||
--color-border-dark: #2d2d2d;
|
||||
--color-border-light-dark: #232323;
|
||||
--color-text-primary-dark: #f3f4f6;
|
||||
--color-text-secondary-dark: #9ca3af;
|
||||
--color-text-tertiary-dark: #6b7280;
|
||||
|
||||
/* Spacing variables */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Font family variables */
|
||||
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono",
|
||||
Consolas, "Courier New", monospace;
|
||||
|
||||
/* Font size variables */
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-3xl: 1.875rem;
|
||||
|
||||
/* Font weight variables */
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
|
||||
/* Line height variables */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
/* Border radius variables */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadow variables */
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Transition variables */
|
||||
--transition-base: 200ms ease;
|
||||
|
||||
/* Layout variables */
|
||||
--sidebar-width: 320px;
|
||||
--sidebar-collapsed-width: 60px;
|
||||
--header-height: 64px;
|
||||
--panel-width: 40vw;
|
||||
--chat-max-width: 900px;
|
||||
|
||||
/* Tailwind/Radix UI component variables */
|
||||
--radius: 0.5rem;
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 220 13% 13%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 13% 13%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 13% 13%;
|
||||
--primary: 180 35% 17%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 13% 91%;
|
||||
--secondary-foreground: 220 13% 13%;
|
||||
--muted: 220 13% 95%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
--accent: 220 13% 95%;
|
||||
--accent-foreground: 220 13% 13%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 180 35% 17%;
|
||||
--sidebar: 220 13% 95%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* App-specific color variables */
|
||||
--color-primary: #1c3c3c;
|
||||
--color-user-message: #065a8a;
|
||||
--color-user-message-bg: #2d2d2d;
|
||||
--color-avatar-bg: #1c3c3c;
|
||||
--color-secondary: #bbc4c4;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #f87171;
|
||||
--color-background: #202020;
|
||||
--color-subagent-hover: #1e3f3f;
|
||||
--color-surface: #2a2a2a;
|
||||
--color-border: #404040;
|
||||
--color-border-light: #353535;
|
||||
--color-text-primary: #f3f4f6;
|
||||
--color-text-secondary: #9ca3af;
|
||||
--color-text-tertiary: #6b7280;
|
||||
--color-file-button: #2a2a2a;
|
||||
--color-file-button-hover: #353535;
|
||||
|
||||
/* Tailwind/Radix UI component variables for dark mode */
|
||||
--radius: 0.5rem;
|
||||
--background: 0 0% 13%;
|
||||
--foreground: 220 13% 95%;
|
||||
--card: 0 0% 18%;
|
||||
--card-foreground: 220 13% 95%;
|
||||
--popover: 0 0% 18%;
|
||||
--popover-foreground: 220 13% 95%;
|
||||
--primary: 174 72% 56%;
|
||||
--primary-foreground: 0 0% 13%;
|
||||
--secondary: 0 0% 25%;
|
||||
--secondary-foreground: 220 13% 95%;
|
||||
--muted: 0 0% 22%;
|
||||
--muted-foreground: 220 9% 70%;
|
||||
--accent: 0 0% 22%;
|
||||
--accent-foreground: 220 13% 95%;
|
||||
--destructive: 0 63% 71%;
|
||||
--destructive-foreground: 0 0% 13%;
|
||||
--border: 0 0% 28%;
|
||||
--input: 0 0% 28%;
|
||||
--ring: 174 72% 56%;
|
||||
--sidebar: 0 0% 18%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h6 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: opacity 200ms ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||
"Courier New", monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.125em 0.25em;
|
||||
background-color: var(--color-surface);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||
"Courier New", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.75;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-surface);
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Optimization Window animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
.scrollbar-pretty {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Global diff highlighting styles */
|
||||
.word-added {
|
||||
background-color: rgba(46, 160, 67, 0.4);
|
||||
color: #ffffff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.word-removed {
|
||||
background-color: rgba(248, 81, 73, 0.4);
|
||||
color: #ffffff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #f85149;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import {
|
||||
type Message,
|
||||
type Assistant,
|
||||
type Checkpoint,
|
||||
} from "@langchain/langgraph-sdk";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||
import type { TodoItem } from "@/app/types/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import { useQueryState } from "nuqs";
|
||||
|
||||
export type StateType = {
|
||||
messages: Message[];
|
||||
todos: TodoItem[];
|
||||
files: Record<string, string>;
|
||||
email?: {
|
||||
id?: string;
|
||||
subject?: string;
|
||||
page_content?: string;
|
||||
};
|
||||
ui?: any;
|
||||
};
|
||||
|
||||
export function useChat({
|
||||
activeAssistant,
|
||||
onHistoryRevalidate,
|
||||
thread,
|
||||
}: {
|
||||
activeAssistant: Assistant | null;
|
||||
onHistoryRevalidate?: () => void;
|
||||
thread?: UseStreamThread<StateType>;
|
||||
}) {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const client = useClient();
|
||||
|
||||
const stream = useStream<StateType>({
|
||||
assistantId: activeAssistant?.assistant_id || "",
|
||||
client: client ?? undefined,
|
||||
reconnectOnMount: true,
|
||||
threadId: threadId ?? null,
|
||||
onThreadId: setThreadId,
|
||||
defaultHeaders: { "x-auth-scheme": "langsmith" },
|
||||
// Enable fetching state history when switching to existing threads
|
||||
fetchStateHistory: true,
|
||||
// Revalidate thread list when stream finishes, errors, or creates new thread
|
||||
onFinish: onHistoryRevalidate,
|
||||
onError: onHistoryRevalidate,
|
||||
onCreated: onHistoryRevalidate,
|
||||
experimental_thread: thread,
|
||||
});
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
const newMessage: Message = { id: uuidv4(), type: "human", content };
|
||||
stream.submit(
|
||||
{ messages: [newMessage] },
|
||||
{
|
||||
optimisticValues: (prev) => ({
|
||||
messages: [...(prev.messages ?? []), newMessage],
|
||||
}),
|
||||
config: { ...(activeAssistant?.config ?? {}), recursion_limit: 100 },
|
||||
}
|
||||
);
|
||||
// Update thread list immediately when sending a message
|
||||
onHistoryRevalidate?.();
|
||||
},
|
||||
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||
);
|
||||
|
||||
const runSingleStep = useCallback(
|
||||
(
|
||||
messages: Message[],
|
||||
checkpoint?: Checkpoint,
|
||||
isRerunningSubagent?: boolean,
|
||||
optimisticMessages?: Message[]
|
||||
) => {
|
||||
if (checkpoint) {
|
||||
stream.submit(undefined, {
|
||||
...(optimisticMessages
|
||||
? { optimisticValues: { messages: optimisticMessages } }
|
||||
: {}),
|
||||
config: activeAssistant?.config,
|
||||
checkpoint: checkpoint,
|
||||
...(isRerunningSubagent
|
||||
? { interruptAfter: ["tools"] }
|
||||
: { interruptBefore: ["tools"] }),
|
||||
});
|
||||
} else {
|
||||
stream.submit(
|
||||
{ messages },
|
||||
{ config: activeAssistant?.config, interruptBefore: ["tools"] }
|
||||
);
|
||||
}
|
||||
},
|
||||
[stream, activeAssistant?.config]
|
||||
);
|
||||
|
||||
const setFiles = useCallback(
|
||||
async (files: Record<string, string>) => {
|
||||
if (!threadId) return;
|
||||
// TODO: missing a way how to revalidate the internal state
|
||||
// I think we do want to have the ability to externally manage the state
|
||||
await client.threads.updateState(threadId, { values: { files } });
|
||||
},
|
||||
[client, threadId]
|
||||
);
|
||||
|
||||
const continueStream = useCallback(
|
||||
(hasTaskToolCall?: boolean) => {
|
||||
stream.submit(undefined, {
|
||||
config: {
|
||||
...(activeAssistant?.config || {}),
|
||||
recursion_limit: 100,
|
||||
},
|
||||
...(hasTaskToolCall
|
||||
? { interruptAfter: ["tools"] }
|
||||
: { interruptBefore: ["tools"] }),
|
||||
});
|
||||
// Update thread list when continuing stream
|
||||
onHistoryRevalidate?.();
|
||||
},
|
||||
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||
);
|
||||
|
||||
const markCurrentThreadAsResolved = useCallback(() => {
|
||||
stream.submit(null, { command: { goto: "__end__", update: null } });
|
||||
// Update thread list when marking thread as resolved
|
||||
onHistoryRevalidate?.();
|
||||
}, [stream, onHistoryRevalidate]);
|
||||
|
||||
const resumeInterrupt = useCallback(
|
||||
(value: any) => {
|
||||
stream.submit(null, { command: { resume: value } });
|
||||
// Update thread list when resuming from interrupt
|
||||
onHistoryRevalidate?.();
|
||||
},
|
||||
[stream, onHistoryRevalidate]
|
||||
);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
stream.stop();
|
||||
}, [stream]);
|
||||
|
||||
return {
|
||||
stream,
|
||||
todos: stream.values.todos ?? [],
|
||||
files: stream.values.files ?? {},
|
||||
email: stream.values.email,
|
||||
ui: stream.values.ui,
|
||||
setFiles,
|
||||
messages: stream.messages,
|
||||
isLoading: stream.isLoading,
|
||||
isThreadLoading: stream.isThreadLoading,
|
||||
interrupt: stream.interrupt,
|
||||
getMessagesMetadata: stream.getMessagesMetadata,
|
||||
sendMessage,
|
||||
runSingleStep,
|
||||
continueStream,
|
||||
stopStream,
|
||||
markCurrentThreadAsResolved,
|
||||
resumeInterrupt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import type { Thread } from "@langchain/langgraph-sdk";
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
export interface ThreadItem {
|
||||
id: string;
|
||||
updatedAt: Date;
|
||||
status: Thread["status"];
|
||||
title: string;
|
||||
description: string;
|
||||
assistantId?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export function useThreads(props: {
|
||||
status?: Thread["status"];
|
||||
limit?: number;
|
||||
}) {
|
||||
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
|
||||
|
||||
return useSWRInfinite(
|
||||
(pageIndex: number, previousPageData: ThreadItem[] | null) => {
|
||||
const config = getConfig();
|
||||
const apiKey =
|
||||
config?.langsmithApiKey ||
|
||||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
|
||||
"";
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the previous page returned no items, we've reached the end
|
||||
if (previousPageData && previousPageData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "threads" as const,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
deploymentUrl: config.deploymentUrl,
|
||||
assistantId: config.assistantId,
|
||||
apiKey,
|
||||
status: props?.status,
|
||||
};
|
||||
},
|
||||
async ({
|
||||
deploymentUrl,
|
||||
assistantId,
|
||||
apiKey,
|
||||
status,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}: {
|
||||
kind: "threads";
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
deploymentUrl: string;
|
||||
assistantId: string;
|
||||
apiKey: string;
|
||||
status?: Thread["status"];
|
||||
}) => {
|
||||
const client = new Client({
|
||||
apiUrl: deploymentUrl,
|
||||
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
|
||||
});
|
||||
|
||||
// Check if assistantId is a UUID (deployed) or graph name (local)
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
assistantId
|
||||
);
|
||||
|
||||
const threads = await client.threads.search({
|
||||
limit: pageSize,
|
||||
offset: pageIndex * pageSize,
|
||||
sortBy: "updated_at" as const,
|
||||
sortOrder: "desc" as const,
|
||||
status,
|
||||
// Only filter by assistant_id metadata for deployed graphs (UUIDs)
|
||||
// Local dev graphs don't set this metadata
|
||||
...(isUUID ? { metadata: { assistant_id: assistantId } } : {}),
|
||||
});
|
||||
|
||||
return threads.map((thread): ThreadItem => {
|
||||
let title = "Untitled Thread";
|
||||
let description = "";
|
||||
|
||||
try {
|
||||
if (thread.values && typeof thread.values === "object") {
|
||||
const values = thread.values as any;
|
||||
const firstHumanMessage = values.messages.find(
|
||||
(m: any) => m.type === "human"
|
||||
);
|
||||
if (firstHumanMessage?.content) {
|
||||
const content =
|
||||
typeof firstHumanMessage.content === "string"
|
||||
? firstHumanMessage.content
|
||||
: firstHumanMessage.content[0]?.text || "";
|
||||
title = content.slice(0, 50) + (content.length > 50 ? "..." : "");
|
||||
}
|
||||
const firstAiMessage = values.messages.find(
|
||||
(m: any) => m.type === "ai"
|
||||
);
|
||||
if (firstAiMessage?.content) {
|
||||
const content =
|
||||
typeof firstAiMessage.content === "string"
|
||||
? firstAiMessage.content
|
||||
: firstAiMessage.content[0]?.text || "";
|
||||
description = content.slice(0, 100);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback to thread ID
|
||||
title = `Thread ${thread.thread_id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: thread.thread_id,
|
||||
updatedAt: new Date(thread.updated_at),
|
||||
status: thread.status,
|
||||
title,
|
||||
description,
|
||||
assistantId,
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
revalidateFirstPage: true,
|
||||
revalidateOnFocus: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={inter.className}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<NuqsAdapter>{children}</NuqsAdapter>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { getConfig, saveConfig, StandaloneConfig } from "@/lib/config";
|
||||
import { ConfigDialog } from "@/app/components/ConfigDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Assistant } from "@langchain/langgraph-sdk";
|
||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import { Settings, MessagesSquare, SquarePen } from "lucide-react";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ThreadList } from "@/app/components/ThreadList";
|
||||
import { ChatProvider } from "@/providers/ChatProvider";
|
||||
import { ChatInterface } from "@/app/components/ChatInterface";
|
||||
|
||||
interface HomePageInnerProps {
|
||||
config: StandaloneConfig;
|
||||
configDialogOpen: boolean;
|
||||
setConfigDialogOpen: (open: boolean) => void;
|
||||
handleSaveConfig: (config: StandaloneConfig) => void;
|
||||
}
|
||||
|
||||
function HomePageInner({
|
||||
config,
|
||||
configDialogOpen,
|
||||
setConfigDialogOpen,
|
||||
handleSaveConfig,
|
||||
}: HomePageInnerProps) {
|
||||
const client = useClient();
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const [sidebar, setSidebar] = useQueryState("sidebar");
|
||||
|
||||
const [mutateThreads, setMutateThreads] = useState<(() => void) | null>(null);
|
||||
const [interruptCount, setInterruptCount] = useState(0);
|
||||
const [assistant, setAssistant] = useState<Assistant | null>(null);
|
||||
|
||||
const fetchAssistant = useCallback(async () => {
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
config.assistantId
|
||||
);
|
||||
|
||||
if (isUUID) {
|
||||
// We should try to fetch the assistant directly with this UUID
|
||||
try {
|
||||
const data = await client.assistants.get(config.assistantId);
|
||||
setAssistant(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch assistant:", error);
|
||||
setAssistant({
|
||||
assistant_id: config.assistantId,
|
||||
graph_id: config.assistantId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
config: {},
|
||||
metadata: {},
|
||||
version: 1,
|
||||
name: "Assistant",
|
||||
context: {},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// We should try to list out the assistants for this graph, and then use the default one.
|
||||
// TODO: Paginate this search, but 100 should be enough for graph name
|
||||
const assistants = await client.assistants.search({
|
||||
graphId: config.assistantId,
|
||||
limit: 100,
|
||||
});
|
||||
const defaultAssistant = assistants.find(
|
||||
(assistant) => assistant.metadata?.["created_by"] === "system"
|
||||
);
|
||||
if (defaultAssistant === undefined) {
|
||||
throw new Error("No default assistant found");
|
||||
}
|
||||
setAssistant(defaultAssistant);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to find default assistant from graph_id: try setting the assistant_id directly:",
|
||||
error
|
||||
);
|
||||
setAssistant({
|
||||
assistant_id: config.assistantId,
|
||||
graph_id: config.assistantId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
config: {},
|
||||
metadata: {},
|
||||
version: 1,
|
||||
name: config.assistantId,
|
||||
context: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [client, config.assistantId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssistant();
|
||||
}, [fetchAssistant]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigDialog
|
||||
open={configDialogOpen}
|
||||
onOpenChange={setConfigDialogOpen}
|
||||
onSave={handleSaveConfig}
|
||||
initialConfig={config}
|
||||
/>
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex h-16 items-center justify-between border-b border-border px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">BroJS Agent</h1>
|
||||
{!sidebar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebar("1")}
|
||||
className="rounded-md border border-border bg-card p-3 text-foreground hover:bg-accent"
|
||||
>
|
||||
<MessagesSquare className="mr-2 h-4 w-4" />
|
||||
Threads
|
||||
{interruptCount > 0 && (
|
||||
<span className="ml-2 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
|
||||
{interruptCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Assistant:</span>{" "}
|
||||
{config.assistantId}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfigDialogOpen(true)}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setThreadId(null)}
|
||||
disabled={!threadId}
|
||||
className="border-[#2F6868] bg-[#2F6868] text-white hover:bg-[#2F6868]/80"
|
||||
>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
New Thread
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
autoSaveId="standalone-chat"
|
||||
>
|
||||
{sidebar && (
|
||||
<>
|
||||
<ResizablePanel
|
||||
id="thread-history"
|
||||
order={1}
|
||||
defaultSize={25}
|
||||
minSize={20}
|
||||
className="relative min-w-[380px]"
|
||||
>
|
||||
<ThreadList
|
||||
onThreadSelect={async (id) => {
|
||||
await setThreadId(id);
|
||||
}}
|
||||
onMutateReady={(fn) => setMutateThreads(() => fn)}
|
||||
onClose={() => setSidebar(null)}
|
||||
onInterruptCountChange={setInterruptCount}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ResizablePanel
|
||||
id="chat"
|
||||
className="relative flex flex-col"
|
||||
order={2}
|
||||
>
|
||||
<ChatProvider
|
||||
activeAssistant={assistant}
|
||||
onHistoryRevalidate={() => mutateThreads?.()}
|
||||
>
|
||||
<ChatInterface assistant={assistant} />
|
||||
</ChatProvider>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePageContent() {
|
||||
const [config, setConfig] = useState<StandaloneConfig | null>(null);
|
||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||
const [assistantId, setAssistantId] = useQueryState("assistantId");
|
||||
|
||||
// On mount, check for saved config, otherwise show config dialog
|
||||
useEffect(() => {
|
||||
const savedConfig = getConfig();
|
||||
if (savedConfig) {
|
||||
setConfig(savedConfig);
|
||||
if (!assistantId) {
|
||||
setAssistantId(savedConfig.assistantId);
|
||||
}
|
||||
} else {
|
||||
setConfigDialogOpen(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// If config changes, update the assistantId
|
||||
useEffect(() => {
|
||||
if (config && !assistantId) {
|
||||
setAssistantId(config.assistantId);
|
||||
}
|
||||
}, [config, assistantId, setAssistantId]);
|
||||
|
||||
const handleSaveConfig = useCallback((newConfig: StandaloneConfig) => {
|
||||
saveConfig(newConfig);
|
||||
setConfig(newConfig);
|
||||
}, []);
|
||||
|
||||
const langsmithApiKey =
|
||||
config?.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<>
|
||||
<ConfigDialog
|
||||
open={configDialogOpen}
|
||||
onOpenChange={setConfigDialogOpen}
|
||||
onSave={handleSaveConfig}
|
||||
/>
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Welcome to Standalone Chat</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Configure your deployment to get started
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setConfigDialogOpen(true)}
|
||||
className="mt-4"
|
||||
>
|
||||
Open Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientProvider
|
||||
deploymentUrl={config.deploymentUrl}
|
||||
apiKey={langsmithApiKey}
|
||||
>
|
||||
<HomePageInner
|
||||
config={config}
|
||||
configDialogOpen={configDialogOpen}
|
||||
setConfigDialogOpen={setConfigDialogOpen}
|
||||
handleSaveConfig={handleSaveConfig}
|
||||
/>
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HomePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
status: "pending" | "completed" | "error" | "interrupted";
|
||||
}
|
||||
|
||||
export interface SubAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
subAgentName: string;
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
status: "pending" | "active" | "completed" | "error";
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface InterruptData {
|
||||
value: any;
|
||||
ns?: string[];
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface ActionRequest {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ReviewConfig {
|
||||
actionName: string;
|
||||
allowedDecisions?: string[];
|
||||
}
|
||||
|
||||
export interface ToolApprovalInterruptData {
|
||||
action_requests: ActionRequest[];
|
||||
review_configs?: ReviewConfig[];
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function extractStringFromMessageContent(message: Message): string {
|
||||
return typeof message.content === "string"
|
||||
? message.content
|
||||
: Array.isArray(message.content)
|
||||
? message.content
|
||||
.filter(
|
||||
(c: unknown) =>
|
||||
(typeof c === "object" &&
|
||||
c !== null &&
|
||||
"type" in c &&
|
||||
(c as { type: string }).type === "text") ||
|
||||
typeof c === "string"
|
||||
)
|
||||
.map((c: unknown) =>
|
||||
typeof c === "string"
|
||||
? c
|
||||
: typeof c === "object" && c !== null && "text" in c
|
||||
? (c as { text?: string }).text || ""
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
: "";
|
||||
}
|
||||
|
||||
export function extractSubAgentContent(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const dataObj = data as Record<string, unknown>;
|
||||
|
||||
// Try to extract description first
|
||||
if (dataObj.description && typeof dataObj.description === "string") {
|
||||
return dataObj.description;
|
||||
}
|
||||
|
||||
// Then try prompt
|
||||
if (dataObj.prompt && typeof dataObj.prompt === "string") {
|
||||
return dataObj.prompt;
|
||||
}
|
||||
|
||||
// For output objects, try result
|
||||
if (dataObj.result && typeof dataObj.result === "string") {
|
||||
return dataObj.result;
|
||||
}
|
||||
|
||||
// Fallback to JSON stringification
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
// Fallback for any other type
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function isPreparingToCallTaskTool(messages: Message[]): boolean {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
return (
|
||||
(lastMessage.type === "ai" &&
|
||||
lastMessage.tool_calls?.some(
|
||||
(call: { name?: string }) => call.name === "task"
|
||||
)) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMessageForLLM(message: Message): string {
|
||||
let role: string;
|
||||
if (message.type === "human") {
|
||||
role = "Human";
|
||||
} else if (message.type === "ai") {
|
||||
role = "Assistant";
|
||||
} else if (message.type === "tool") {
|
||||
role = `Tool Result`;
|
||||
} else {
|
||||
role = message.type || "Unknown";
|
||||
}
|
||||
|
||||
const timestamp = message.id ? ` (${message.id.slice(0, 8)})` : "";
|
||||
|
||||
let contentText = "";
|
||||
|
||||
// Extract content text
|
||||
if (typeof message.content === "string") {
|
||||
contentText = message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
const textParts: string[] = [];
|
||||
|
||||
message.content.forEach((part: any) => {
|
||||
if (typeof part === "string") {
|
||||
textParts.push(part);
|
||||
} else if (part && typeof part === "object" && part.type === "text") {
|
||||
textParts.push(part.text || "");
|
||||
}
|
||||
// Ignore other types like tool_use in content - we handle tool calls separately
|
||||
});
|
||||
|
||||
contentText = textParts.join("\n\n").trim();
|
||||
}
|
||||
|
||||
// For tool messages, include additional tool metadata
|
||||
if (message.type === "tool") {
|
||||
const toolName = (message as any).name || "unknown_tool";
|
||||
const toolCallId = (message as any).tool_call_id || "";
|
||||
role = `Tool Result [${toolName}]`;
|
||||
if (toolCallId) {
|
||||
role += ` (call_id: ${toolCallId.slice(0, 8)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls from .tool_calls property (for AI messages)
|
||||
const toolCallsText: string[] = [];
|
||||
if (
|
||||
message.type === "ai" &&
|
||||
message.tool_calls &&
|
||||
Array.isArray(message.tool_calls) &&
|
||||
message.tool_calls.length > 0
|
||||
) {
|
||||
message.tool_calls.forEach((call: any) => {
|
||||
const toolName = call.name || "unknown_tool";
|
||||
const toolArgs = call.args ? JSON.stringify(call.args, null, 2) : "{}";
|
||||
toolCallsText.push(`[Tool Call: ${toolName}]\nArguments: ${toolArgs}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Combine content and tool calls
|
||||
const parts: string[] = [];
|
||||
if (contentText) {
|
||||
parts.push(contentText);
|
||||
}
|
||||
if (toolCallsText.length > 0) {
|
||||
parts.push(...toolCallsText);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return `${role}${timestamp}: [Empty message]`;
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return `${role}${timestamp}: ${parts[0]}`;
|
||||
}
|
||||
|
||||
return `${role}${timestamp}:\n${parts.join("\n\n")}`;
|
||||
}
|
||||
|
||||
export function formatConversationForLLM(messages: Message[]): string {
|
||||
const formattedMessages = messages.map(formatMessageForLLM);
|
||||
return formattedMessages.join("\n\n---\n\n");
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return (
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return (
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="focus:outline-hidden absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-blue-200 selection:text-gray-900 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:selection:bg-blue-600 dark:selection:text-white md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none p-px transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
height: "20px",
|
||||
width: "36px",
|
||||
alignItems: "center",
|
||||
borderRadius: "9999px",
|
||||
border: "1px solid #d1d5db",
|
||||
backgroundColor: "var(--color-border)",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s",
|
||||
}}
|
||||
data-state-styles={{
|
||||
checked: {
|
||||
backgroundColor: "var(--color-primary)",
|
||||
},
|
||||
}}
|
||||
className={cn(
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:!bg-[var(--color-primary)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: "white",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
|
||||
transition: "transform 0.2s",
|
||||
transform: "translateX(1px)",
|
||||
}}
|
||||
className="data-[state=checked]:!translate-x-[17px]"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./tooltip";
|
||||
|
||||
interface TooltipIconButtonProps {
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function TooltipIconButton({
|
||||
icon,
|
||||
onClick,
|
||||
tooltip,
|
||||
disabled,
|
||||
}: TooltipIconButtonProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root
|
||||
data-slot="tooltip"
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
data-slot="tooltip-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"text-primary-foreground origin-(--radix-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md bg-primary px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface StandaloneConfig {
|
||||
deploymentUrl: string;
|
||||
assistantId: string;
|
||||
langsmithApiKey?: string;
|
||||
}
|
||||
|
||||
const CONFIG_KEY = "deep-agent-config";
|
||||
|
||||
export function getEnvConfig(): StandaloneConfig | null {
|
||||
const deploymentUrl = process.env.NEXT_PUBLIC_DEPLOYMENT_URL;
|
||||
const assistantId = process.env.NEXT_PUBLIC_ASSISTANT_ID;
|
||||
if (!deploymentUrl || !assistantId) return null;
|
||||
|
||||
const langsmithApiKey = process.env.NEXT_PUBLIC_LANGSMITH_API_KEY;
|
||||
return {
|
||||
deploymentUrl,
|
||||
assistantId,
|
||||
langsmithApiKey: langsmithApiKey || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfig(): StandaloneConfig | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const stored = localStorage.getItem(CONFIG_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return getEnvConfig();
|
||||
}
|
||||
|
||||
export function saveConfig(config: StandaloneConfig): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, createContext, useContext } from "react";
|
||||
import { Assistant } from "@langchain/langgraph-sdk";
|
||||
import { type StateType, useChat } from "@/app/hooks/useChat";
|
||||
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
interface ChatProviderProps {
|
||||
children: ReactNode;
|
||||
activeAssistant: Assistant | null;
|
||||
onHistoryRevalidate?: () => void;
|
||||
thread?: UseStreamThread<StateType>;
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
activeAssistant,
|
||||
onHistoryRevalidate,
|
||||
thread,
|
||||
}: ChatProviderProps) {
|
||||
const chat = useChat({ activeAssistant, onHistoryRevalidate, thread });
|
||||
return <ChatContext.Provider value={chat}>{children}</ChatContext.Provider>;
|
||||
}
|
||||
|
||||
export type ChatContextType = ReturnType<typeof useChat>;
|
||||
|
||||
export const ChatContext = createContext<ChatContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export function useChatContext() {
|
||||
const context = useContext(ChatContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useChatContext must be used within a ChatProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, ReactNode } from "react";
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
|
||||
interface ClientContextValue {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
const ClientContext = createContext<ClientContextValue | null>(null);
|
||||
|
||||
interface ClientProviderProps {
|
||||
children: ReactNode;
|
||||
deploymentUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export function ClientProvider({
|
||||
children,
|
||||
deploymentUrl,
|
||||
apiKey,
|
||||
}: ClientProviderProps) {
|
||||
const client = useMemo(() => {
|
||||
return new Client({
|
||||
apiUrl: deploymentUrl,
|
||||
defaultHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
});
|
||||
}, [deploymentUrl, apiKey]);
|
||||
|
||||
const value = useMemo(() => ({ client }), [client]);
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={value}>{children}</ClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useClient(): Client {
|
||||
const context = useContext(ClientContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useClient must be used within a ClientProvider");
|
||||
}
|
||||
return context.client;
|
||||
}
|
||||
Reference in New Issue
Block a user