= ({
+ code,
+ language,
+ theme = { dark: "kanagawa-wave", light: "kanagawa-lotus" },
+ className,
+ addDefaultStyles = false, // assistant-ui requires custom base styles
+ showLanguage = false, // assistant-ui/react-markdown handles language labels
+ node: _node,
+ components: _components,
+ ...props
+}) => {
+ return (
+
+ {code.trim()}
+
+ );
+};
+
+SyntaxHighlighter.displayName = "SyntaxHighlighter";
diff --git a/resources/js/components/assistant-ui/thread-list.tsx b/resources/js/components/assistant-ui/thread-list.tsx
new file mode 100644
index 0000000..c37aad8
--- /dev/null
+++ b/resources/js/components/assistant-ui/thread-list.tsx
@@ -0,0 +1,95 @@
+import type { FC } from "react";
+import {
+ ThreadListItemPrimitive,
+ ThreadListPrimitive,
+ useAssistantState,
+} from "@assistant-ui/react";
+import { ArchiveIcon, PlusIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export const ThreadList: FC = () => {
+ return (
+
+
+
+
+ );
+};
+
+const ThreadListNew: FC = () => {
+ return (
+
+
+
+ New Thread
+
+
+ );
+};
+
+const ThreadListItems: FC = () => {
+ const isLoading = useAssistantState(({ threads }) => threads.isLoading);
+
+ if (isLoading) {
+ return ;
+ }
+
+ return ;
+};
+
+const ThreadListSkeleton: FC = () => {
+ return (
+ <>
+ {Array.from({ length: 5 }, (_, i) => (
+
+
+
+ ))}
+ >
+ );
+};
+
+const ThreadListItem: FC = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+const ThreadListItemTitle: FC = () => {
+ return (
+
+
+
+ );
+};
+
+const ThreadListItemArchive: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/resources/js/components/assistant-ui/thread.tsx b/resources/js/components/assistant-ui/thread.tsx
new file mode 100644
index 0000000..d439209
--- /dev/null
+++ b/resources/js/components/assistant-ui/thread.tsx
@@ -0,0 +1,399 @@
+import {
+ ArrowDownIcon,
+ ArrowUpIcon,
+ CheckIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ CopyIcon,
+ PencilIcon,
+ RefreshCwIcon,
+ Square,
+} from "lucide-react";
+
+import {
+ ActionBarPrimitive,
+ BranchPickerPrimitive,
+ ComposerPrimitive,
+ ErrorPrimitive,
+ MessagePrimitive,
+ ThreadPrimitive,
+} from "@assistant-ui/react";
+
+import type { FC } from "react";
+import { LazyMotion, MotionConfig, domAnimation } from "motion/react";
+import * as m from "motion/react-m";
+
+import { Button } from "@/components/ui/button";
+import { MarkdownText } from "@/components/assistant-ui/markdown-text";
+import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
+import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
+import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
+import {
+ ComposerAddAttachment,
+ ComposerAttachments,
+ UserMessageAttachments,
+} from "@/components/assistant-ui/attachment";
+
+import { cn } from "@/lib/utils";
+
+export const Thread: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const ThreadScrollToBottom: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const ThreadWelcome: FC = () => {
+ return (
+
+
+
+
+ Hello there!
+
+
+ How can I help you today?
+
+
+
+
+
+ );
+};
+
+const ThreadSuggestions: FC = () => {
+ return (
+
+ {[
+ {
+ title: "Plan my day",
+ label: "in San Francisco",
+ action: "Plan my day in San Francisco",
+ },
+ {
+ title: "Explain React hooks",
+ label: "like useState and useEffect",
+ action: "Explain React hooks like useState and useEffect",
+ },
+ {
+ title: "Tell me a joke",
+ label: "about dogs",
+ action: "Tell me a joke about dogs",
+ },
+ {
+ title: "Create a meal plan",
+ label: "for healthy weight loss",
+ action: "Create a meal plan for healthy weight loss",
+ },
+ ].map((suggestedAction, index) => (
+
+
+
+
+ {suggestedAction.title}
+
+
+ {suggestedAction.label}
+
+
+
+
+ ))}
+
+ );
+};
+
+const Composer: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const ComposerAction: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const MessageError: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const AssistantMessage: FC = () => {
+ return (
+
+
+
+ );
+};
+
+const AssistantActionBar: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const UserMessage: FC = () => {
+ return (
+
+
+
+ );
+};
+
+const UserActionBar: FC = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+const EditComposer: FC = () => {
+ return (
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ Update
+
+
+
+
+
+ );
+};
+
+const BranchPicker: FC = ({
+ className,
+ ...rest
+}) => {
+ return (
+
+
+
+
+
+
+
+ /{" "}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/resources/js/components/assistant-ui/threadlist-sidebar.tsx b/resources/js/components/assistant-ui/threadlist-sidebar.tsx
new file mode 100644
index 0000000..3cfecc5
--- /dev/null
+++ b/resources/js/components/assistant-ui/threadlist-sidebar.tsx
@@ -0,0 +1,73 @@
+import * as React from "react";
+import { Github, MessagesSquare } from "lucide-react";
+import { Link } from "@inertiajs/react";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarRail,
+} from "@/components/ui/sidebar";
+import { ThreadList } from "@/components/assistant-ui/thread-list";
+
+export function ThreadListSidebar({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ assistant-ui
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GitHub
+
+ View Source
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/components/assistant-ui/tool-fallback.tsx b/resources/js/components/assistant-ui/tool-fallback.tsx
new file mode 100644
index 0000000..7be6726
--- /dev/null
+++ b/resources/js/components/assistant-ui/tool-fallback.tsx
@@ -0,0 +1,46 @@
+import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+
+export const ToolFallback: ToolCallMessagePartComponent = ({
+ toolName,
+ argsText,
+ result,
+}) => {
+ const [isCollapsed, setIsCollapsed] = useState(true);
+ return (
+
+
+
+
+ Used tool: {toolName}
+
+
setIsCollapsed(!isCollapsed)}>
+ {isCollapsed ? : }
+
+
+ {!isCollapsed && (
+
+
+ {result !== undefined && (
+
+
+ Result:
+
+
+ {typeof result === "string"
+ ? result
+ : JSON.stringify(result, null, 2)}
+
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/resources/js/components/assistant-ui/tooltip-icon-button.tsx b/resources/js/components/assistant-ui/tooltip-icon-button.tsx
new file mode 100644
index 0000000..1d74e7c
--- /dev/null
+++ b/resources/js/components/assistant-ui/tooltip-icon-button.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { ComponentPropsWithRef, forwardRef } from "react";
+import { Slottable } from "@radix-ui/react-slot";
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export type TooltipIconButtonProps = ComponentPropsWithRef & {
+ tooltip: string;
+ side?: "top" | "bottom" | "left" | "right";
+};
+
+export const TooltipIconButton = forwardRef<
+ HTMLButtonElement,
+ TooltipIconButtonProps
+>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
+ return (
+
+
+
+ {children}
+ {tooltip}
+
+
+ {tooltip}
+
+ );
+});
+
+TooltipIconButton.displayName = "TooltipIconButton";
diff --git a/resources/js/components/assistant.tsx b/resources/js/components/assistant.tsx
new file mode 100644
index 0000000..b96fc37
--- /dev/null
+++ b/resources/js/components/assistant.tsx
@@ -0,0 +1,43 @@
+import { AssistantRuntimeProvider } from "@assistant-ui/react";
+import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk";
+import { Thread } from "@/components/assistant-ui/thread";
+import { ThreadList } from "@/components/assistant-ui/thread-list";
+import { WeatherToolUI } from "@/components/tools/weather-tool";
+import api from "@/routes/cortex/api";
+// import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
+
+export const Assistant = ({ agent }: { agent: string }) => {
+ const runtime = useChatRuntime({
+ id: crypto.randomUUID(),
+ transport: new AssistantChatTransport({
+ api: api.agents.stream.url(
+ { agent },
+ { mergeQuery: { protocol: "vercel" } },
+ ),
+ }),
+ });
+
+ return (
+
+
+
+ {/*
+
+
*/}
+
+
+
+ {/*
+
+
+ Agent Metadata
+
+
+ {JSON.stringify(agent, null, 2)}
+
+
+
*/}
+
+
+ );
+};
diff --git a/resources/js/components/breadcrumbs.tsx b/resources/js/components/breadcrumbs.tsx
new file mode 100644
index 0000000..e9bc006
--- /dev/null
+++ b/resources/js/components/breadcrumbs.tsx
@@ -0,0 +1,49 @@
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import { type BreadcrumbItem as BreadcrumbItemType } from "@/types";
+import { Link } from "@inertiajs/react";
+import { Fragment } from "react";
+
+export function Breadcrumbs({
+ breadcrumbs,
+}: {
+ breadcrumbs: BreadcrumbItemType[];
+}) {
+ return (
+ <>
+ {breadcrumbs.length > 0 && (
+
+
+ {breadcrumbs.map((item, index) => {
+ const isLast = index === breadcrumbs.length - 1;
+ return (
+
+
+ {isLast ? (
+
+ {item.title}
+
+ ) : (
+
+
+ {item.title}
+
+
+ )}
+
+ {!isLast && }
+
+ );
+ })}
+
+
+ )}
+ >
+ );
+}
diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx
new file mode 100644
index 0000000..44bff9b
--- /dev/null
+++ b/resources/js/components/delete-user.tsx
@@ -0,0 +1,120 @@
+import ProfileController from "@/actions/App/Http/Controllers/Settings/ProfileController";
+import HeadingSmall from "@/components/heading-small";
+import InputError from "@/components/input-error";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Form } from "@inertiajs/react";
+import { useRef } from "react";
+
+export default function DeleteUser() {
+ const passwordInput = useRef(null);
+
+ return (
+
+
+
+
+
Warning
+
+ Please proceed with caution, this cannot be undone.
+
+
+
+
+
+
+ Delete account
+
+
+
+
+ Are you sure you want to delete your account?
+
+
+ Once your account is deleted, all of its resources
+ and data will also be permanently deleted. Please
+ enter your password to confirm you would like to
+ permanently delete your account.
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/components/heading-small.tsx b/resources/js/components/heading-small.tsx
new file mode 100644
index 0000000..a545f1a
--- /dev/null
+++ b/resources/js/components/heading-small.tsx
@@ -0,0 +1,16 @@
+export default function HeadingSmall({
+ title,
+ description,
+}: {
+ title: string;
+ description?: string;
+}) {
+ return (
+
+ {title}
+ {description && (
+ {description}
+ )}
+
+ );
+}
diff --git a/resources/js/components/heading.tsx b/resources/js/components/heading.tsx
new file mode 100644
index 0000000..a4a6e23
--- /dev/null
+++ b/resources/js/components/heading.tsx
@@ -0,0 +1,16 @@
+export default function Heading({
+ title,
+ description,
+}: {
+ title: string;
+ description?: string;
+}) {
+ return (
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ );
+}
diff --git a/resources/js/components/icon.tsx b/resources/js/components/icon.tsx
new file mode 100644
index 0000000..ef5c258
--- /dev/null
+++ b/resources/js/components/icon.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils";
+import { type LucideProps } from "lucide-react";
+import { type ComponentType } from "react";
+
+interface IconProps extends Omit {
+ iconNode: ComponentType;
+}
+
+export function Icon({
+ iconNode: IconComponent,
+ className,
+ ...props
+}: IconProps) {
+ return ;
+}
diff --git a/resources/js/components/input-error.tsx b/resources/js/components/input-error.tsx
new file mode 100644
index 0000000..1fd93e4
--- /dev/null
+++ b/resources/js/components/input-error.tsx
@@ -0,0 +1,17 @@
+import { cn } from "@/lib/utils";
+import { type HTMLAttributes } from "react";
+
+export default function InputError({
+ message,
+ className = "",
+ ...props
+}: HTMLAttributes & { message?: string }) {
+ return message ? (
+
+ {message}
+
+ ) : null;
+}
diff --git a/resources/js/components/nav-footer.tsx b/resources/js/components/nav-footer.tsx
new file mode 100644
index 0000000..5739485
--- /dev/null
+++ b/resources/js/components/nav-footer.tsx
@@ -0,0 +1,53 @@
+import { Icon } from "@/components/icon";
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar";
+import { toUrl } from "@/lib/utils";
+import { type NavItem } from "@/types";
+import { type ComponentPropsWithoutRef } from "react";
+
+export function NavFooter({
+ items,
+ className,
+ ...props
+}: ComponentPropsWithoutRef & {
+ items: NavItem[];
+}) {
+ return (
+
+
+
+ {items.map((item) => (
+
+
+
+ {item.icon && (
+
+ )}
+ {item.title}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx
new file mode 100644
index 0000000..2cc0398
--- /dev/null
+++ b/resources/js/components/nav-main.tsx
@@ -0,0 +1,36 @@
+import {
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar";
+import { useActiveUrl } from "@/hooks/use-active-url";
+import { type NavItem } from "@/types";
+import { Link } from "@inertiajs/react";
+
+export function NavMain({ items = [] }: { items: NavItem[] }) {
+ const { urlIsActive } = useActiveUrl();
+
+ return (
+
+ Platform
+
+ {items.map((item) => (
+
+
+
+ {item.icon && }
+ {item.title}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/resources/js/components/nav-user.tsx b/resources/js/components/nav-user.tsx
new file mode 100644
index 0000000..2a17626
--- /dev/null
+++ b/resources/js/components/nav-user.tsx
@@ -0,0 +1,55 @@
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import { UserInfo } from "@/components/user-info";
+// import { UserMenuContent } from '@/components/user-menu-content';
+import { useIsMobile } from "@/hooks/use-mobile";
+import { type SharedData } from "@/types";
+import { usePage } from "@inertiajs/react";
+import { ChevronsUpDown } from "lucide-react";
+
+export function NavUser() {
+ const { auth } = usePage().props;
+ const { state } = useSidebar();
+ const isMobile = useIsMobile();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {/* */}
+
+
+
+
+ );
+}
diff --git a/resources/js/components/text-link.tsx b/resources/js/components/text-link.tsx
new file mode 100644
index 0000000..1488e04
--- /dev/null
+++ b/resources/js/components/text-link.tsx
@@ -0,0 +1,23 @@
+import { cn } from "@/lib/utils";
+import { Link } from "@inertiajs/react";
+import { ComponentProps } from "react";
+
+type LinkProps = ComponentProps;
+
+export default function TextLink({
+ className = "",
+ children,
+ ...props
+}: LinkProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/resources/js/components/tools/weather-card.tsx b/resources/js/components/tools/weather-card.tsx
new file mode 100644
index 0000000..3f85f7c
--- /dev/null
+++ b/resources/js/components/tools/weather-card.tsx
@@ -0,0 +1,211 @@
+import {
+ Cloud,
+ Droplets,
+ Wind,
+ Thermometer,
+ MapPin,
+ Gauge,
+ Clock,
+} from "lucide-react";
+
+interface WeatherCardProps {
+ temperature?: number;
+ feelsLike?: number;
+ humidity?: number;
+ windSpeed?: number;
+ windGusts?: number;
+ conditions?: string;
+ location?: string;
+ temperatureUnit?: string;
+ windSpeedUnit?: string;
+ conditionsCode?: number;
+ time?: string;
+}
+
+const formatTime = (timeString?: string): string => {
+ if (!timeString) return "";
+
+ try {
+ const date = new Date(timeString);
+ const options: Intl.DateTimeFormatOptions = {
+ hour: "numeric",
+ minute: "2-digit",
+ };
+ return date.toLocaleTimeString("en-US", options);
+ } catch {
+ return timeString;
+ }
+};
+
+const getWeatherGradient = (code?: number): string => {
+ if (code === undefined) return "from-blue-500 to-blue-700";
+
+ // Clear/Sunny
+ if (code === 0 || code === 1) {
+ return "from-sky-400 to-blue-500";
+ }
+
+ // Partly cloudy
+ if (code === 2) {
+ return "from-slate-400 to-blue-500";
+ }
+
+ // Overcast
+ if (code === 3) {
+ return "from-gray-500 to-gray-600";
+ }
+
+ // Fog
+ if (code === 45 || code === 48) {
+ return "from-gray-400 to-gray-500";
+ }
+
+ // Drizzle
+ if (code >= 51 && code <= 57) {
+ return "from-slate-500 to-blue-600";
+ }
+
+ // Rain
+ if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) {
+ return "from-blue-600 to-slate-700";
+ }
+
+ // Snow
+ if ((code >= 71 && code <= 77) || code === 85 || code === 86) {
+ return "from-slate-300 to-blue-400";
+ }
+
+ // Thunderstorm
+ if (code >= 95 && code <= 99) {
+ return "from-slate-700 to-purple-900";
+ }
+
+ // Default
+ return "from-blue-500 to-blue-700";
+};
+
+export const WeatherCard = ({
+ temperature,
+ feelsLike,
+ humidity,
+ windSpeed,
+ windGusts,
+ conditions,
+ location,
+ temperatureUnit = "celsius",
+ windSpeedUnit = "mph",
+ conditionsCode,
+ time,
+}: WeatherCardProps) => {
+ const gradientColors = getWeatherGradient(conditionsCode);
+ const formattedTime = formatTime(time);
+
+ return (
+
+ {/* Header with Location and Time */}
+
+ {location && (
+
+
+
{location}
+
+ )}
+ {formattedTime && (
+
+
+ {formattedTime}
+
+ )}
+
+
+ {/* Main Temperature Display */}
+
+
+ {temperature !== undefined && (
+
+ {Math.round(temperature)}°
+
+ )}
+ {conditions && (
+
+
+ {conditions}
+
+ )}
+
+ {feelsLike !== undefined && (
+
+
Feels like
+
+ {Math.round(feelsLike)}°
+
+
+ )}
+
+
+ {/* Weather Details Grid */}
+
+ {humidity !== undefined && (
+
+
+
+
+
+
Humidity
+
+ {Math.round(humidity)}%
+
+
+
+ )}
+
+ {windSpeed !== undefined && (
+
+
+
+
+
+
Wind Speed
+
+ {Math.round(windSpeed)} {windSpeedUnit}
+
+
+
+ )}
+
+ {windGusts !== undefined && (
+
+
+
+
+
+
Wind Gusts
+
+ {Math.round(windGusts)} {windSpeedUnit}
+
+
+
+ )}
+
+ {feelsLike !== undefined && temperature !== undefined && (
+
+
+
+
+
+
+ Temperature
+
+
+ {Math.round(temperature)}°
+ {temperatureUnit === "celsius" ? "C" : "F"}
+
+
+
+ )}
+
+
+ );
+};
diff --git a/resources/js/components/tools/weather-tool.tsx b/resources/js/components/tools/weather-tool.tsx
new file mode 100644
index 0000000..4eb0e0b
--- /dev/null
+++ b/resources/js/components/tools/weather-tool.tsx
@@ -0,0 +1,91 @@
+import { makeAssistantTool } from "@assistant-ui/react";
+import { WeatherCard } from "./weather-card";
+
+export const WeatherToolUI = makeAssistantTool({
+ toolName: "get_weather",
+ render: ({ args, result, status }) => {
+ if (status.type === "running") {
+ return (
+
+
+ Checking weather in {args.location as string}...
+
+
+ );
+ }
+
+ if (status.type === "incomplete" && status.reason === "error") {
+ return (
+
+ Failed to get weather for {args.location as string}
+
+ );
+ }
+
+ let weatherResult: Record | undefined = undefined;
+
+ if (typeof result === "string") {
+ weatherResult = JSON.parse(result) as Record;
+ }
+
+ return (
+
+ );
+ },
+});
diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx
new file mode 100644
index 0000000..aeda486
--- /dev/null
+++ b/resources/js/components/two-factor-recovery-codes.tsx
@@ -0,0 +1,164 @@
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { regenerateRecoveryCodes } from "@/routes/two-factor";
+import { Form } from "@inertiajs/react";
+import { Eye, EyeOff, LockKeyhole, RefreshCw } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import AlertError from "./alert-error";
+
+interface TwoFactorRecoveryCodesProps {
+ recoveryCodesList: string[];
+ fetchRecoveryCodes: () => Promise;
+ errors: string[];
+}
+
+export default function TwoFactorRecoveryCodes({
+ recoveryCodesList,
+ fetchRecoveryCodes,
+ errors,
+}: TwoFactorRecoveryCodesProps) {
+ const [codesAreVisible, setCodesAreVisible] = useState(false);
+ const codesSectionRef = useRef(null);
+ const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible;
+
+ const toggleCodesVisibility = useCallback(async () => {
+ if (!codesAreVisible && !recoveryCodesList.length) {
+ await fetchRecoveryCodes();
+ }
+
+ setCodesAreVisible(!codesAreVisible);
+
+ if (!codesAreVisible) {
+ setTimeout(() => {
+ codesSectionRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "nearest",
+ });
+ });
+ }
+ }, [codesAreVisible, recoveryCodesList.length, fetchRecoveryCodes]);
+
+ useEffect(() => {
+ if (!recoveryCodesList.length) {
+ fetchRecoveryCodes();
+ }
+ }, [recoveryCodesList.length, fetchRecoveryCodes]);
+
+ const RecoveryCodeIconComponent = codesAreVisible ? EyeOff : Eye;
+
+ return (
+
+
+
+
+ 2FA Recovery Codes
+
+
+ Recovery codes let you regain access if you lose your 2FA
+ device. Store them in a secure password manager.
+
+
+
+
+
+
+ {codesAreVisible ? "Hide" : "View"} Recovery Codes
+
+
+ {canRegenerateCodes && (
+
+ )}
+
+
+
+ {errors?.length ? (
+
+ ) : (
+ <>
+
+ {recoveryCodesList.length ? (
+ recoveryCodesList.map((code, index) => (
+
+ {code}
+
+ ))
+ ) : (
+
+ {Array.from(
+ { length: 8 },
+ (_, index) => (
+
+ ),
+ )}
+
+ )}
+
+
+
+
+ Each recovery code can be used once to
+ access your account and will be removed
+ after use. If you need more, click{" "}
+
+ Regenerate Codes
+ {" "}
+ above.
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx
new file mode 100644
index 0000000..a463a9e
--- /dev/null
+++ b/resources/js/components/two-factor-setup-modal.tsx
@@ -0,0 +1,347 @@
+import InputError from "@/components/input-error";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp";
+import { useAppearance } from "@/hooks/use-appearance";
+import { useClipboard } from "@/hooks/use-clipboard";
+import { OTP_MAX_LENGTH } from "@/hooks/use-two-factor-auth";
+import { confirm } from "@/routes/two-factor";
+import { Form } from "@inertiajs/react";
+import { REGEXP_ONLY_DIGITS } from "input-otp";
+import { Check, Copy, ScanLine } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import AlertError from "./alert-error";
+import { Spinner } from "./ui/spinner";
+
+function GridScanIcon() {
+ return (
+
+
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+ ))}
+
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function TwoFactorSetupStep({
+ qrCodeSvg,
+ manualSetupKey,
+ buttonText,
+ onNextStep,
+ errors,
+}: {
+ qrCodeSvg: string | null;
+ manualSetupKey: string | null;
+ buttonText: string;
+ onNextStep: () => void;
+ errors: string[];
+}) {
+ const { resolvedAppearance } = useAppearance();
+ const [copiedText, copy] = useClipboard();
+ const IconComponent = copiedText === manualSetupKey ? Check : Copy;
+
+ return (
+ <>
+ {errors?.length ? (
+
+ ) : (
+ <>
+
+
+
+ {qrCodeSvg ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {buttonText}
+
+
+
+
+
+
+ or, enter the code manually
+
+
+
+
+
+ {!manualSetupKey ? (
+
+
+
+ ) : (
+ <>
+
+
copy(manualSetupKey)}
+ className="border-l border-border px-3 hover:bg-muted"
+ >
+
+
+ >
+ )}
+
+
+ >
+ )}
+ >
+ );
+}
+
+function TwoFactorVerificationStep({
+ onClose,
+ onBack,
+}: {
+ onClose: () => void;
+ onBack: () => void;
+}) {
+ const [code, setCode] = useState("");
+ const pinInputContainerRef = useRef(null);
+
+ useEffect(() => {
+ setTimeout(() => {
+ pinInputContainerRef.current?.querySelector("input")?.focus();
+ }, 0);
+ }, []);
+
+ return (
+
+ );
+}
+
+interface TwoFactorSetupModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ requiresConfirmation: boolean;
+ twoFactorEnabled: boolean;
+ qrCodeSvg: string | null;
+ manualSetupKey: string | null;
+ clearSetupData: () => void;
+ fetchSetupData: () => Promise;
+ errors: string[];
+}
+
+export default function TwoFactorSetupModal({
+ isOpen,
+ onClose,
+ requiresConfirmation,
+ twoFactorEnabled,
+ qrCodeSvg,
+ manualSetupKey,
+ clearSetupData,
+ fetchSetupData,
+ errors,
+}: TwoFactorSetupModalProps) {
+ const [showVerificationStep, setShowVerificationStep] =
+ useState(false);
+
+ const modalConfig = useMemo<{
+ title: string;
+ description: string;
+ buttonText: string;
+ }>(() => {
+ if (twoFactorEnabled) {
+ return {
+ title: "Two-Factor Authentication Enabled",
+ description:
+ "Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.",
+ buttonText: "Close",
+ };
+ }
+
+ if (showVerificationStep) {
+ return {
+ title: "Verify Authentication Code",
+ description:
+ "Enter the 6-digit code from your authenticator app",
+ buttonText: "Continue",
+ };
+ }
+
+ return {
+ title: "Enable Two-Factor Authentication",
+ description:
+ "To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app",
+ buttonText: "Continue",
+ };
+ }, [twoFactorEnabled, showVerificationStep]);
+
+ const handleModalNextStep = useCallback(() => {
+ if (requiresConfirmation) {
+ setShowVerificationStep(true);
+ return;
+ }
+
+ clearSetupData();
+ onClose();
+ }, [requiresConfirmation, clearSetupData, onClose]);
+
+ const resetModalState = useCallback(() => {
+ setShowVerificationStep(false);
+
+ if (twoFactorEnabled) {
+ clearSetupData();
+ }
+ }, [twoFactorEnabled, clearSetupData]);
+
+ useEffect(() => {
+ if (isOpen && !qrCodeSvg) {
+ fetchSetupData();
+ }
+ }, [isOpen, qrCodeSvg, fetchSetupData]);
+
+ const handleClose = useCallback(() => {
+ resetModalState();
+ onClose();
+ }, [onClose, resetModalState]);
+
+ return (
+ !open && handleClose()}>
+
+
+
+ {modalConfig.title}
+
+ {modalConfig.description}
+
+
+
+
+ {showVerificationStep ? (
+ setShowVerificationStep(false)}
+ />
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/resources/js/components/ui/alert.tsx b/resources/js/components/ui/alert.tsx
new file mode 100644
index 0000000..fd9def6
--- /dev/null
+++ b/resources/js/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/resources/js/components/ui/avatar.tsx b/resources/js/components/ui/avatar.tsx
new file mode 100644
index 0000000..dfeb84b
--- /dev/null
+++ b/resources/js/components/ui/avatar.tsx
@@ -0,0 +1,51 @@
+import * as React from "react";
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/resources/js/components/ui/badge.tsx b/resources/js/components/ui/badge.tsx
new file mode 100644
index 0000000..b2f5006
--- /dev/null
+++ b/resources/js/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+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 badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/resources/js/components/ui/breadcrumb.tsx b/resources/js/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..dc80665
--- /dev/null
+++ b/resources/js/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/resources/js/components/ui/button.tsx b/resources/js/components/ui/button.tsx
new file mode 100644
index 0000000..d484b9b
--- /dev/null
+++ b/resources/js/components/ui/button.tsx
@@ -0,0 +1,58 @@
+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-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_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",
+ outline:
+ "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ 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 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 & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/resources/js/components/ui/card.tsx b/resources/js/components/ui/card.tsx
new file mode 100644
index 0000000..87ed036
--- /dev/null
+++ b/resources/js/components/ui/card.tsx
@@ -0,0 +1,75 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
+};
diff --git a/resources/js/components/ui/checkbox.tsx b/resources/js/components/ui/checkbox.tsx
new file mode 100644
index 0000000..fc8aa03
--- /dev/null
+++ b/resources/js/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+import * as React from "react";
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
+import { CheckIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { Checkbox };
diff --git a/resources/js/components/ui/collapsible.tsx b/resources/js/components/ui/collapsible.tsx
new file mode 100644
index 0000000..0196844
--- /dev/null
+++ b/resources/js/components/ui/collapsible.tsx
@@ -0,0 +1,31 @@
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/resources/js/components/ui/dialog.tsx b/resources/js/components/ui/dialog.tsx
new file mode 100644
index 0000000..c4ce023
--- /dev/null
+++ b/resources/js/components/ui/dialog.tsx
@@ -0,0 +1,136 @@
+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) {
+ return ;
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/resources/js/components/ui/dropdown-menu.tsx b/resources/js/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..0e555c3
--- /dev/null
+++ b/resources/js/components/ui/dropdown-menu.tsx
@@ -0,0 +1,263 @@
+import * as React from "react";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/resources/js/components/ui/icon.tsx b/resources/js/components/ui/icon.tsx
new file mode 100644
index 0000000..a5a5d48
--- /dev/null
+++ b/resources/js/components/ui/icon.tsx
@@ -0,0 +1,14 @@
+import { LucideIcon } from "lucide-react";
+
+interface IconProps {
+ iconNode?: LucideIcon | null;
+ className?: string;
+}
+
+export function Icon({ iconNode: IconComponent, className }: IconProps) {
+ if (!IconComponent) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/resources/js/components/ui/input-otp.tsx b/resources/js/components/ui/input-otp.tsx
new file mode 100644
index 0000000..8d4cf67
--- /dev/null
+++ b/resources/js/components/ui/input-otp.tsx
@@ -0,0 +1,69 @@
+import * as React from "react";
+import { OTPInput, OTPInputContext } from "input-otp";
+import { Minus } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const InputOTP = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, containerClassName, ...props }, ref) => (
+
+));
+InputOTP.displayName = "InputOTP";
+
+const InputOTPGroup = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div">
+>(({ className, ...props }, ref) => (
+
+));
+InputOTPGroup.displayName = "InputOTPGroup";
+
+const InputOTPSlot = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div"> & { index: number }
+>(({ index, className, ...props }, ref) => {
+ const inputOTPContext = React.useContext(OTPInputContext);
+ const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
+
+ return (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ );
+});
+InputOTPSlot.displayName = "InputOTPSlot";
+
+const InputOTPSeparator = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div">
+>(({ ...props }, ref) => (
+
+
+
+));
+InputOTPSeparator.displayName = "InputOTPSeparator";
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
diff --git a/resources/js/components/ui/input.tsx b/resources/js/components/ui/input.tsx
new file mode 100644
index 0000000..89145d1
--- /dev/null
+++ b/resources/js/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/resources/js/components/ui/label.tsx b/resources/js/components/ui/label.tsx
new file mode 100644
index 0000000..cb71028
--- /dev/null
+++ b/resources/js/components/ui/label.tsx
@@ -0,0 +1,22 @@
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+
+import { cn } from "@/lib/utils";
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Label };
diff --git a/resources/js/components/ui/navigation-menu.tsx b/resources/js/components/ui/navigation-menu.tsx
new file mode 100644
index 0000000..0244a94
--- /dev/null
+++ b/resources/js/components/ui/navigation-menu.tsx
@@ -0,0 +1,168 @@
+import * as React from "react";
+import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
+import { cva } from "class-variance-authority";
+import { ChevronDownIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function NavigationMenu({
+ className,
+ children,
+ viewport = true,
+ ...props
+}: React.ComponentProps & {
+ viewport?: boolean;
+}) {
+ return (
+
+ {children}
+ {viewport && }
+
+ );
+}
+
+function NavigationMenuList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function NavigationMenuItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+const navigationMenuTriggerStyle = cva(
+ "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-accent/50 data-[state=open]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1",
+);
+
+function NavigationMenuTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}{" "}
+
+
+ );
+}
+
+function NavigationMenuContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function NavigationMenuViewport({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function NavigationMenuLink({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function NavigationMenuIndicator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ NavigationMenu,
+ NavigationMenuList,
+ NavigationMenuItem,
+ NavigationMenuContent,
+ NavigationMenuTrigger,
+ NavigationMenuLink,
+ NavigationMenuIndicator,
+ NavigationMenuViewport,
+ navigationMenuTriggerStyle,
+};
diff --git a/resources/js/components/ui/placeholder-pattern.tsx b/resources/js/components/ui/placeholder-pattern.tsx
new file mode 100644
index 0000000..e182a40
--- /dev/null
+++ b/resources/js/components/ui/placeholder-pattern.tsx
@@ -0,0 +1,32 @@
+import { useId } from "react";
+
+interface PlaceholderPatternProps {
+ className?: string;
+}
+
+export function PlaceholderPattern({ className }: PlaceholderPatternProps) {
+ const patternId = useId();
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/components/ui/select.tsx b/resources/js/components/ui/select.tsx
new file mode 100644
index 0000000..5ddc2f7
--- /dev/null
+++ b/resources/js/components/ui/select.tsx
@@ -0,0 +1,182 @@
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+ span]:line-clamp-1",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/resources/js/components/ui/separator.tsx b/resources/js/components/ui/separator.tsx
new file mode 100644
index 0000000..d8d9938
--- /dev/null
+++ b/resources/js/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+import * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/resources/js/components/ui/sheet.tsx b/resources/js/components/ui/sheet.tsx
new file mode 100644
index 0000000..d4e3b7a
--- /dev/null
+++ b/resources/js/components/ui/sheet.tsx
@@ -0,0 +1,137 @@
+import * as React from "react";
+import * as SheetPrimitive from "@radix-ui/react-dialog";
+import { XIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ ...props
+}: React.ComponentProps & {
+ side?: "top" | "right" | "bottom" | "left";
+}) {
+ return (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+ );
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/resources/js/components/ui/sidebar.tsx b/resources/js/components/ui/sidebar.tsx
new file mode 100644
index 0000000..b3c75c2
--- /dev/null
+++ b/resources/js/components/ui/sidebar.tsx
@@ -0,0 +1,749 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { VariantProps, cva } from "class-variance-authority";
+import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
+
+import { useIsMobile } from "@/hooks/use-mobile";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Separator } from "@/components/ui/separator";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state";
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+const SIDEBAR_WIDTH = "16rem";
+const SIDEBAR_WIDTH_MOBILE = "18rem";
+const SIDEBAR_WIDTH_ICON = "3rem";
+const SIDEBAR_KEYBOARD_SHORTCUT = "b";
+
+type SidebarContext = {
+ state: "expanded" | "collapsed";
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ openMobile: boolean;
+ setOpenMobile: (open: boolean) => void;
+ isMobile: boolean;
+ toggleSidebar: () => void;
+};
+
+const SidebarContext = React.createContext(null);
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext);
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.");
+ }
+
+ return context;
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ defaultOpen?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const isMobile = useIsMobile();
+ const [openMobile, setOpenMobile] = React.useState(false);
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen);
+ const open = openProp ?? _open;
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value;
+ if (setOpenProp) {
+ setOpenProp(openState);
+ } else {
+ _setOpen(openState);
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ },
+ [setOpenProp, open],
+ );
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile
+ ? setOpenMobile((open) => !open)
+ : setOpen((open) => !open);
+ }, [isMobile, setOpen, setOpenMobile]);
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault();
+ toggleSidebar();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [toggleSidebar]);
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed";
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ ],
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ side?: "left" | "right";
+ variant?: "sidebar" | "floating" | "inset";
+ collapsible?: "offcanvas" | "icon" | "none";
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
+
+ Sidebar
+
+ Displays the mobile sidebar.
+
+
+
+
+ {children}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ );
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar, isMobile, state } = useSidebar();
+
+ return (
+ {
+ onClick?.(event);
+ toggleSidebar();
+ }}
+ {...props}
+ >
+ {isMobile || state === "collapsed" ? (
+
+ ) : (
+
+ )}
+ Toggle Sidebar
+
+ );
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ );
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarGroupLabel({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "div";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 group-data-[collapsible=icon]:select-none group-data-[collapsible=icon]:pointer-events-none",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarGroupAction({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function SidebarMenuButton({
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ isActive?: boolean;
+ tooltip?: string | React.ComponentProps;
+} & VariantProps) {
+ const Comp = asChild ? Slot : "button";
+ const { isMobile, state } = useSidebar();
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ };
+ }
+
+ return (
+
+ {button}
+
+
+ );
+}
+
+function SidebarMenuAction({
+ className,
+ asChild = false,
+ showOnHover = false,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ showOnHover?: boolean;
+}) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "peer-data-[size=sm]/menu-button:top-1",
+ "peer-data-[size=default]/menu-button:top-1.5",
+ "peer-data-[size=lg]/menu-button:top-2.5",
+ "group-data-[collapsible=icon]:hidden",
+ showOnHover &&
+ "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean;
+}) {
+ // wrapping in useState to ensure the width is stable across renders
+ // also ensures we have a stable reference to the style object
+ const [skeletonStyle] = React.useState(
+ () =>
+ ({
+ "--skeleton-width": `${Math.floor(Math.random() * 40) + 50}%`, // Random width between 50 to 90%.
+ }) as React.CSSProperties,
+ );
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ );
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSubButton({
+ asChild = false,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+ size?: "sm" | "md";
+ isActive?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+ svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
+ size === "sm" && "text-xs",
+ size === "md" && "text-sm",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+};
diff --git a/resources/js/components/ui/skeleton.tsx b/resources/js/components/ui/skeleton.tsx
new file mode 100644
index 0000000..4c3fa08
--- /dev/null
+++ b/resources/js/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/resources/js/components/ui/spinner.tsx b/resources/js/components/ui/spinner.tsx
new file mode 100644
index 0000000..59b5707
--- /dev/null
+++ b/resources/js/components/ui/spinner.tsx
@@ -0,0 +1,16 @@
+import { Loader2Icon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+
+ );
+}
+
+export { Spinner };
diff --git a/resources/js/components/ui/toggle-group.tsx b/resources/js/components/ui/toggle-group.tsx
new file mode 100644
index 0000000..d8553e8
--- /dev/null
+++ b/resources/js/components/ui/toggle-group.tsx
@@ -0,0 +1,71 @@
+import * as React from "react";
+import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
+import { type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+import { toggleVariants } from "@/components/ui/toggle";
+
+const ToggleGroupContext = React.createContext<
+ VariantProps
+>({
+ size: "default",
+ variant: "default",
+});
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ children,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ const context = React.useContext(ToggleGroupContext);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export { ToggleGroup, ToggleGroupItem };
diff --git a/resources/js/components/ui/toggle.tsx b/resources/js/components/ui/toggle.tsx
new file mode 100644
index 0000000..d0a011c
--- /dev/null
+++ b/resources/js/components/ui/toggle.tsx
@@ -0,0 +1,45 @@
+import * as React from "react";
+import * as TogglePrimitive from "@radix-ui/react-toggle";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-9 px-2 min-w-9",
+ sm: "h-8 px-1.5 min-w-8",
+ lg: "h-10 px-2.5 min-w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Toggle({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ );
+}
+
+export { Toggle, toggleVariants };
diff --git a/resources/js/components/ui/tooltip.tsx b/resources/js/components/ui/tooltip.tsx
new file mode 100644
index 0000000..dd7b61e
--- /dev/null
+++ b/resources/js/components/ui/tooltip.tsx
@@ -0,0 +1,59 @@
+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) {
+ return (
+
+ );
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 4,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/resources/js/components/user-info.tsx b/resources/js/components/user-info.tsx
new file mode 100644
index 0000000..3e00e27
--- /dev/null
+++ b/resources/js/components/user-info.tsx
@@ -0,0 +1,32 @@
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { useInitials } from "@/hooks/use-initials";
+import { type User } from "@/types";
+
+export function UserInfo({
+ user,
+ showEmail = false,
+}: {
+ user: User;
+ showEmail?: boolean;
+}) {
+ const getInitials = useInitials();
+
+ return (
+ <>
+
+
+
+ {getInitials(user.name)}
+
+
+
+ {user.name}
+ {showEmail && (
+
+ {user.email}
+
+ )}
+
+ >
+ );
+}
diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx
new file mode 100644
index 0000000..1625780
--- /dev/null
+++ b/resources/js/components/user-menu-content.tsx
@@ -0,0 +1,63 @@
+import {
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu";
+import { UserInfo } from "@/components/user-info";
+import { useMobileNavigation } from "@/hooks/use-mobile-navigation";
+import { logout } from "@/routes/cortex";
+import { edit } from "@/routes/profile";
+import { type User } from "@/types";
+import { Link, router } from "@inertiajs/react";
+import { LogOut, Settings } from "lucide-react";
+
+interface UserMenuContentProps {
+ user: User;
+}
+
+export function UserMenuContent({ user }: UserMenuContentProps) {
+ const cleanup = useMobileNavigation();
+
+ const handleLogout = () => {
+ cleanup();
+ router.flushAll();
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+ Log out
+
+
+ >
+ );
+}
diff --git a/resources/js/hooks/use-active-url.ts b/resources/js/hooks/use-active-url.ts
new file mode 100644
index 0000000..b393745
--- /dev/null
+++ b/resources/js/hooks/use-active-url.ts
@@ -0,0 +1,21 @@
+import { toUrl } from "@/lib/utils";
+import type { InertiaLinkProps } from "@inertiajs/react";
+import { usePage } from "@inertiajs/react";
+
+export function useActiveUrl() {
+ const page = usePage();
+ const currentUrlPath = new URL(page.url, window?.location.origin).pathname;
+
+ function urlIsActive(
+ urlToCheck: NonNullable,
+ currentUrl?: string,
+ ) {
+ const urlToCompare = currentUrl ?? currentUrlPath;
+ return toUrl(urlToCheck) === urlToCompare;
+ }
+
+ return {
+ currentUrl: currentUrlPath,
+ urlIsActive,
+ };
+}
diff --git a/resources/js/hooks/use-appearance.tsx b/resources/js/hooks/use-appearance.tsx
new file mode 100644
index 0000000..82e20c7
--- /dev/null
+++ b/resources/js/hooks/use-appearance.tsx
@@ -0,0 +1,100 @@
+import { useCallback, useMemo, useSyncExternalStore } from "react";
+
+export type ResolvedAppearance = "light" | "dark";
+export type Appearance = ResolvedAppearance | "system";
+
+const listeners = new Set<() => void>();
+let currentAppearance: Appearance = "system";
+
+const prefersDark = (): boolean => {
+ if (typeof window === "undefined") return false;
+
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
+};
+
+const setCookie = (name: string, value: string, days = 365): void => {
+ if (typeof document === "undefined") return;
+ const maxAge = days * 24 * 60 * 60;
+ document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
+};
+
+const getStoredAppearance = (): Appearance => {
+ if (typeof window === "undefined") return "system";
+
+ return (localStorage.getItem("appearance") as Appearance) || "system";
+};
+
+const isDarkMode = (appearance: Appearance): boolean => {
+ return appearance === "dark" || (appearance === "system" && prefersDark());
+};
+
+const applyTheme = (appearance: Appearance): void => {
+ if (typeof document === "undefined") return;
+
+ const isDark = isDarkMode(appearance);
+
+ document.documentElement.classList.toggle("dark", isDark);
+ document.documentElement.style.colorScheme = isDark ? "dark" : "light";
+};
+
+const subscribe = (callback: () => void) => {
+ listeners.add(callback);
+
+ return () => listeners.delete(callback);
+};
+
+const notify = (): void => listeners.forEach((listener) => listener());
+
+const mediaQuery = (): MediaQueryList | null => {
+ if (typeof window === "undefined") return null;
+
+ return window.matchMedia("(prefers-color-scheme: dark)");
+};
+
+const handleSystemThemeChange = (): void => {
+ applyTheme(currentAppearance);
+ notify();
+};
+
+export function initializeTheme(): void {
+ if (typeof window === "undefined") return;
+
+ if (!localStorage.getItem("appearance")) {
+ localStorage.setItem("appearance", "system");
+ setCookie("appearance", "system");
+ }
+
+ currentAppearance = getStoredAppearance();
+ applyTheme(currentAppearance);
+
+ // Set up system theme change listener
+ mediaQuery()?.addEventListener("change", handleSystemThemeChange);
+}
+
+export function useAppearance() {
+ const appearance: Appearance = useSyncExternalStore(
+ subscribe,
+ () => currentAppearance,
+ () => "system",
+ );
+
+ const resolvedAppearance: ResolvedAppearance = useMemo(
+ () => (isDarkMode(appearance) ? "dark" : "light"),
+ [appearance],
+ );
+
+ const updateAppearance = useCallback((mode: Appearance): void => {
+ currentAppearance = mode;
+
+ // Store in localStorage for client-side persistence...
+ localStorage.setItem("appearance", mode);
+
+ // Store in cookie for SSR...
+ setCookie("appearance", mode);
+
+ applyTheme(mode);
+ notify();
+ }, []);
+
+ return { appearance, resolvedAppearance, updateAppearance } as const;
+}
diff --git a/resources/js/hooks/use-clipboard.ts b/resources/js/hooks/use-clipboard.ts
new file mode 100644
index 0000000..be59fab
--- /dev/null
+++ b/resources/js/hooks/use-clipboard.ts
@@ -0,0 +1,32 @@
+// Credit: https://usehooks-ts.com/
+import { useCallback, useState } from "react";
+
+type CopiedValue = string | null;
+
+type CopyFn = (text: string) => Promise;
+
+export function useClipboard(): [CopiedValue, CopyFn] {
+ const [copiedText, setCopiedText] = useState(null);
+
+ const copy: CopyFn = useCallback(async (text) => {
+ if (!navigator?.clipboard) {
+ console.warn("Clipboard not supported");
+
+ return false;
+ }
+
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopiedText(text);
+
+ return true;
+ } catch (error) {
+ console.warn("Copy failed", error);
+ setCopiedText(null);
+
+ return false;
+ }
+ }, []);
+
+ return [copiedText, copy];
+}
diff --git a/resources/js/hooks/use-initials.tsx b/resources/js/hooks/use-initials.tsx
new file mode 100644
index 0000000..3750141
--- /dev/null
+++ b/resources/js/hooks/use-initials.tsx
@@ -0,0 +1,15 @@
+import { useCallback } from "react";
+
+export function useInitials() {
+ return useCallback((fullName: string): string => {
+ const names = fullName.trim().split(" ");
+
+ if (names.length === 0) return "";
+ if (names.length === 1) return names[0].charAt(0).toUpperCase();
+
+ const firstInitial = names[0].charAt(0);
+ const lastInitial = names[names.length - 1].charAt(0);
+
+ return `${firstInitial}${lastInitial}`.toUpperCase();
+ }, []);
+}
diff --git a/resources/js/hooks/use-mobile-navigation.ts b/resources/js/hooks/use-mobile-navigation.ts
new file mode 100644
index 0000000..76dd982
--- /dev/null
+++ b/resources/js/hooks/use-mobile-navigation.ts
@@ -0,0 +1,8 @@
+import { useCallback } from "react";
+
+export function useMobileNavigation() {
+ return useCallback(() => {
+ // Remove pointer-events style from body...
+ document.body.style.removeProperty("pointer-events");
+ }, []);
+}
diff --git a/resources/js/hooks/use-mobile.tsx b/resources/js/hooks/use-mobile.tsx
new file mode 100644
index 0000000..91c192b
--- /dev/null
+++ b/resources/js/hooks/use-mobile.tsx
@@ -0,0 +1,36 @@
+import { useSyncExternalStore } from "react";
+
+const MOBILE_BREAKPOINT = 768;
+
+const mql =
+ typeof window === "undefined"
+ ? undefined
+ : window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+
+function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) {
+ if (!mql) {
+ return () => {};
+ }
+
+ mql.addEventListener("change", callback);
+
+ return () => {
+ mql.removeEventListener("change", callback);
+ };
+}
+
+function isSmallerThanBreakpoint(): boolean {
+ return mql?.matches ?? false;
+}
+
+function getServerSnapshot(): boolean {
+ return false;
+}
+
+export function useIsMobile(): boolean {
+ return useSyncExternalStore(
+ mediaQueryListener,
+ isSmallerThanBreakpoint,
+ getServerSnapshot,
+ );
+}
diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts
new file mode 100644
index 0000000..0b9d1d2
--- /dev/null
+++ b/resources/js/hooks/use-two-factor-auth.ts
@@ -0,0 +1,104 @@
+import { qrCode, recoveryCodes, secretKey } from "@/routes/two-factor";
+import { useCallback, useMemo, useState } from "react";
+
+interface TwoFactorSetupData {
+ svg: string;
+ url: string;
+}
+
+interface TwoFactorSecretKey {
+ secretKey: string;
+}
+
+export const OTP_MAX_LENGTH = 6;
+
+const fetchJson = async (url: string): Promise => {
+ const response = await fetch(url, {
+ headers: { Accept: "application/json" },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ return response.json();
+};
+
+export const useTwoFactorAuth = () => {
+ const [qrCodeSvg, setQrCodeSvg] = useState(null);
+ const [manualSetupKey, setManualSetupKey] = useState(null);
+ const [recoveryCodesList, setRecoveryCodesList] = useState([]);
+ const [errors, setErrors] = useState([]);
+
+ const hasSetupData = useMemo(
+ () => qrCodeSvg !== null && manualSetupKey !== null,
+ [qrCodeSvg, manualSetupKey],
+ );
+
+ const fetchQrCode = useCallback(async (): Promise => {
+ try {
+ const { svg } = await fetchJson(qrCode.url());
+ setQrCodeSvg(svg);
+ } catch {
+ setErrors((prev) => [...prev, "Failed to fetch QR code"]);
+ setQrCodeSvg(null);
+ }
+ }, []);
+
+ const fetchSetupKey = useCallback(async (): Promise => {
+ try {
+ const { secretKey: key } = await fetchJson(
+ secretKey.url(),
+ );
+ setManualSetupKey(key);
+ } catch {
+ setErrors((prev) => [...prev, "Failed to fetch a setup key"]);
+ setManualSetupKey(null);
+ }
+ }, []);
+
+ const clearErrors = useCallback((): void => {
+ setErrors([]);
+ }, []);
+
+ const clearSetupData = useCallback((): void => {
+ setManualSetupKey(null);
+ setQrCodeSvg(null);
+ clearErrors();
+ }, [clearErrors]);
+
+ const fetchRecoveryCodes = useCallback(async (): Promise => {
+ try {
+ clearErrors();
+ const codes = await fetchJson(recoveryCodes.url());
+ setRecoveryCodesList(codes);
+ } catch {
+ setErrors((prev) => [...prev, "Failed to fetch recovery codes"]);
+ setRecoveryCodesList([]);
+ }
+ }, [clearErrors]);
+
+ const fetchSetupData = useCallback(async (): Promise => {
+ try {
+ clearErrors();
+ await Promise.all([fetchQrCode(), fetchSetupKey()]);
+ } catch {
+ setQrCodeSvg(null);
+ setManualSetupKey(null);
+ }
+ }, [clearErrors, fetchQrCode, fetchSetupKey]);
+
+ return {
+ qrCodeSvg,
+ manualSetupKey,
+ recoveryCodesList,
+ hasSetupData,
+ errors,
+ clearErrors,
+ clearSetupData,
+ fetchQrCode,
+ fetchSetupKey,
+ fetchSetupData,
+ fetchRecoveryCodes,
+ };
+};
diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx
new file mode 100644
index 0000000..82612f3
--- /dev/null
+++ b/resources/js/layouts/app-layout.tsx
@@ -0,0 +1,14 @@
+import AppLayoutTemplate from "@/layouts/app/app-sidebar-layout";
+import { type BreadcrumbItem } from "@/types";
+import { type ReactNode } from "react";
+
+interface AppLayoutProps {
+ children: ReactNode;
+ breadcrumbs?: BreadcrumbItem[];
+}
+
+export default ({ children, breadcrumbs, ...props }: AppLayoutProps) => (
+
+ {children}
+
+);
diff --git a/resources/js/layouts/app/app-header-layout.tsx b/resources/js/layouts/app/app-header-layout.tsx
new file mode 100644
index 0000000..3fcd3b2
--- /dev/null
+++ b/resources/js/layouts/app/app-header-layout.tsx
@@ -0,0 +1,17 @@
+import { AppContent } from "@/components/app-content";
+import { AppHeader } from "@/components/app-header";
+import { AppShell } from "@/components/app-shell";
+import { type BreadcrumbItem } from "@/types";
+import type { PropsWithChildren } from "react";
+
+export default function AppHeaderLayout({
+ children,
+ breadcrumbs,
+}: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/resources/js/layouts/app/app-sidebar-layout.tsx b/resources/js/layouts/app/app-sidebar-layout.tsx
new file mode 100644
index 0000000..4d94be6
--- /dev/null
+++ b/resources/js/layouts/app/app-sidebar-layout.tsx
@@ -0,0 +1,24 @@
+import { AppContent } from "@/components/app-content";
+import { AppShell } from "@/components/app-shell";
+import { AppSidebar } from "@/components/app-sidebar";
+import { AppSidebarHeader } from "@/components/app-sidebar-header";
+import { type BreadcrumbItem } from "@/types";
+import { type PropsWithChildren } from "react";
+
+export default function AppSidebarLayout({
+ children,
+ breadcrumbs = [],
+}: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) {
+ return (
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/resources/js/layouts/auth-layout.tsx b/resources/js/layouts/auth-layout.tsx
new file mode 100644
index 0000000..3326d30
--- /dev/null
+++ b/resources/js/layouts/auth-layout.tsx
@@ -0,0 +1,18 @@
+import AuthLayoutTemplate from "@/layouts/auth/auth-simple-layout";
+
+export default function AuthLayout({
+ children,
+ title,
+ description,
+ ...props
+}: {
+ children: React.ReactNode;
+ title: string;
+ description: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/resources/js/layouts/auth/auth-card-layout.tsx b/resources/js/layouts/auth/auth-card-layout.tsx
new file mode 100644
index 0000000..39ef563
--- /dev/null
+++ b/resources/js/layouts/auth/auth-card-layout.tsx
@@ -0,0 +1,48 @@
+import AppLogoIcon from "@/components/app-logo-icon";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { home } from "@/routes";
+import { Link } from "@inertiajs/react";
+import { type PropsWithChildren } from "react";
+
+export default function AuthCardLayout({
+ children,
+ title,
+ description,
+}: PropsWithChildren<{
+ name?: string;
+ title?: string;
+ description?: string;
+}>) {
+ return (
+
+
+
+
+
+
+
+
+
+ {title}
+ {description}
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/resources/js/layouts/auth/auth-simple-layout.tsx b/resources/js/layouts/auth/auth-simple-layout.tsx
new file mode 100644
index 0000000..ef4867f
--- /dev/null
+++ b/resources/js/layouts/auth/auth-simple-layout.tsx
@@ -0,0 +1,44 @@
+import AppLogoIcon from "@/components/app-logo-icon";
+import { home } from "@/routes";
+import { Link } from "@inertiajs/react";
+import { type PropsWithChildren } from "react";
+
+interface AuthLayoutProps {
+ name?: string;
+ title?: string;
+ description?: string;
+}
+
+export default function AuthSimpleLayout({
+ children,
+ title,
+ description,
+}: PropsWithChildren) {
+ return (
+
+
+
+
+
+
+
{title}
+
+
+
+
{title}
+
+ {description}
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/resources/js/layouts/auth/auth-split-layout.tsx b/resources/js/layouts/auth/auth-split-layout.tsx
new file mode 100644
index 0000000..0795e6b
--- /dev/null
+++ b/resources/js/layouts/auth/auth-split-layout.tsx
@@ -0,0 +1,50 @@
+import AppLogoIcon from "@/components/app-logo-icon";
+import { home } from "@/routes";
+import { type SharedData } from "@/types";
+import { Link, usePage } from "@inertiajs/react";
+import { type PropsWithChildren } from "react";
+
+interface AuthLayoutProps {
+ title?: string;
+ description?: string;
+}
+
+export default function AuthSplitLayout({
+ children,
+ title,
+ description,
+}: PropsWithChildren) {
+ const { name } = usePage().props;
+
+ return (
+
+
+
+
+
+
+
+
+
{title}
+
+ {description}
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx
new file mode 100644
index 0000000..969af5f
--- /dev/null
+++ b/resources/js/layouts/settings/layout.tsx
@@ -0,0 +1,89 @@
+import Heading from "@/components/heading";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { cn, toUrl } from "@/lib/utils";
+import { useActiveUrl } from "@/hooks/use-active-url";
+import { edit as editAppearance } from "@/routes/appearance";
+import { edit } from "@/routes/profile";
+import { show } from "@/routes/two-factor";
+import { edit as editPassword } from "@/routes/user-password";
+import { type NavItem } from "@/types";
+import { Link } from "@inertiajs/react";
+import { type PropsWithChildren } from "react";
+
+const sidebarNavItems: NavItem[] = [
+ {
+ title: "Profile",
+ href: edit(),
+ icon: null,
+ },
+ {
+ title: "Password",
+ href: editPassword(),
+ icon: null,
+ },
+ {
+ title: "Two-Factor Auth",
+ href: show(),
+ icon: null,
+ },
+ {
+ title: "Appearance",
+ href: editAppearance(),
+ icon: null,
+ },
+];
+
+export default function SettingsLayout({ children }: PropsWithChildren) {
+ const { urlIsActive } = useActiveUrl();
+
+ // When server-side rendering, we only render the layout on the client...
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ {sidebarNavItems.map((item, index) => (
+
+
+ {item.icon && (
+
+ )}
+ {item.title}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts
new file mode 100644
index 0000000..ac8ebb0
--- /dev/null
+++ b/resources/js/lib/utils.ts
@@ -0,0 +1,11 @@
+import { InertiaLinkProps } from "@inertiajs/react";
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function toUrl(url: NonNullable): string {
+ return typeof url === "string" ? url : url.url;
+}
diff --git a/resources/js/pages/agents/index.tsx b/resources/js/pages/agents/index.tsx
new file mode 100644
index 0000000..02beb35
--- /dev/null
+++ b/resources/js/pages/agents/index.tsx
@@ -0,0 +1,85 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import AppLayout from "@/layouts/app-layout";
+import cortex from "@/routes/cortex";
+import { type BreadcrumbItem } from "@/types";
+import { Head, Link } from "@inertiajs/react";
+import { Bot } from "lucide-react";
+import { type Agent } from "@/types/agents";
+
+const breadcrumbs: BreadcrumbItem[] = [
+ {
+ title: "Agents",
+ href: cortex.agents.index.url(),
+ },
+];
+
+export default function AgentsIndex({ agents }: { agents: Agent[] }) {
+ return (
+
+
+
+ {agents.length === 0 ? (
+
+
+
+
+
+ No agents found
+
+
+ Register agents in your application to see
+ them here.
+
+
+
+
+ ) : (
+
+ {agents.map((agent) => (
+
+
+
+
+
+
+
+
+
+
+ {agent.name || agent.id}
+
+
+ {agent.description ||
+ "No description available"}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/resources/js/pages/agents/show.tsx b/resources/js/pages/agents/show.tsx
new file mode 100644
index 0000000..488b0c2
--- /dev/null
+++ b/resources/js/pages/agents/show.tsx
@@ -0,0 +1,28 @@
+import { Head } from "@inertiajs/react";
+import { Assistant } from "@/components/assistant";
+import AppLayout from "@/layouts/app-layout";
+import agents from "@/routes/cortex/agents";
+import { type BreadcrumbItem } from "@/types";
+import { type Agent } from "@/types/agents";
+
+export default function AgentsShow({ agent }: { agent: Agent }) {
+ const breadcrumbs: BreadcrumbItem[] = [
+ {
+ title: "Agents",
+ href: agents.index.url(),
+ },
+ {
+ title: agent.name || agent.id,
+ href: agents.show.url({ agent: agent.id }),
+ },
+ ];
+
+ return (
+
+
+
+
+ );
+}
diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx
new file mode 100644
index 0000000..bd263b2
--- /dev/null
+++ b/resources/js/pages/dashboard.tsx
@@ -0,0 +1,36 @@
+import { PlaceholderPattern } from "@/components/ui/placeholder-pattern";
+import AppLayout from "@/layouts/app-layout";
+import { dashboard } from "@/routes/cortex";
+import { type BreadcrumbItem } from "@/types";
+import { Head } from "@inertiajs/react";
+
+const breadcrumbs: BreadcrumbItem[] = [
+ {
+ title: "Dashboard",
+ href: dashboard().url,
+ },
+];
+
+export default function Dashboard() {
+ return (
+
+
+
+
+ );
+}
diff --git a/resources/js/routes/cortex/agents/index.ts b/resources/js/routes/cortex/agents/index.ts
new file mode 100644
index 0000000..4bdf608
--- /dev/null
+++ b/resources/js/routes/cortex/agents/index.ts
@@ -0,0 +1,111 @@
+import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../wayfinder'
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:13
+* @route '/cortex/agents'
+*/
+export const index = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: index.url(options),
+ method: 'get',
+})
+
+index.definition = {
+ methods: ["get","head"],
+ url: '/cortex/agents',
+} satisfies RouteDefinition<["get","head"]>
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:13
+* @route '/cortex/agents'
+*/
+index.url = (options?: RouteQueryOptions) => {
+ return index.definition.url + queryParams(options)
+}
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:13
+* @route '/cortex/agents'
+*/
+index.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: index.url(options),
+ method: 'get',
+})
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:13
+* @route '/cortex/agents'
+*/
+index.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: index.url(options),
+ method: 'head',
+})
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:20
+* @route '/cortex/agents/{agent}'
+*/
+export const show = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: show.url(args, options),
+ method: 'get',
+})
+
+show.definition = {
+ methods: ["get","head"],
+ url: '/cortex/agents/{agent}',
+} satisfies RouteDefinition<["get","head"]>
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:20
+* @route '/cortex/agents/{agent}'
+*/
+show.url = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions) => {
+ if (typeof args === 'string' || typeof args === 'number') {
+ args = { agent: args }
+ }
+
+ if (typeof args === 'object' && !Array.isArray(args) && 'string' in args) {
+ args = { agent: args.string }
+ }
+
+ if (Array.isArray(args)) {
+ args = {
+ agent: args[0],
+ }
+ }
+
+ args = applyUrlDefaults(args)
+
+ const parsedArgs = {
+ agent: typeof args.agent === 'object'
+ ? args.agent.string
+ : args.agent,
+ }
+
+ return show.definition.url
+ .replace('{agent}', parsedArgs.agent.toString())
+ .replace(/\/+$/, '') + queryParams(options)
+}
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:20
+* @route '/cortex/agents/{agent}'
+*/
+show.get = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: show.url(args, options),
+ method: 'get',
+})
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:20
+* @route '/cortex/agents/{agent}'
+*/
+show.head = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: show.url(args, options),
+ method: 'head',
+})
+
+const agents = {
+ index: Object.assign(index, index),
+ show: Object.assign(show, show),
+}
+
+export default agents
\ No newline at end of file
diff --git a/resources/js/routes/cortex/api/agents/index.ts b/resources/js/routes/cortex/api/agents/index.ts
new file mode 100644
index 0000000..780b8a8
--- /dev/null
+++ b/resources/js/routes/cortex/api/agents/index.ts
@@ -0,0 +1,163 @@
+import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../../wayfinder'
+/**
+* @see \Cortex\Http\Controllers\AgentsController::invoke
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26
+* @route '/api/agents/{agent}/invoke'
+*/
+export const invoke = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: invoke.url(args, options),
+ method: 'get',
+})
+
+invoke.definition = {
+ methods: ["get","post","head"],
+ url: '/api/agents/{agent}/invoke',
+} satisfies RouteDefinition<["get","post","head"]>
+
+/**
+* @see \Cortex\Http\Controllers\AgentsController::invoke
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26
+* @route '/api/agents/{agent}/invoke'
+*/
+invoke.url = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions) => {
+ if (typeof args === 'string' || typeof args === 'number') {
+ args = { agent: args }
+ }
+
+ if (typeof args === 'object' && !Array.isArray(args) && 'string' in args) {
+ args = { agent: args.string }
+ }
+
+ if (Array.isArray(args)) {
+ args = {
+ agent: args[0],
+ }
+ }
+
+ args = applyUrlDefaults(args)
+
+ const parsedArgs = {
+ agent: typeof args.agent === 'object'
+ ? args.agent.string
+ : args.agent,
+ }
+
+ return invoke.definition.url
+ .replace('{agent}', parsedArgs.agent.toString())
+ .replace(/\/+$/, '') + queryParams(options)
+}
+
+/**
+* @see \Cortex\Http\Controllers\AgentsController::invoke
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26
+* @route '/api/agents/{agent}/invoke'
+*/
+invoke.get = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: invoke.url(args, options),
+ method: 'get',
+})
+
+/**
+* @see \Cortex\Http\Controllers\AgentsController::invoke
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26
+* @route '/api/agents/{agent}/invoke'
+*/
+invoke.post = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({
+ url: invoke.url(args, options),
+ method: 'post',
+})
+
+/**
+* @see \Cortex\Http\Controllers\AgentsController::invoke
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26
+* @route '/api/agents/{agent}/invoke'
+*/
+invoke.head = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: invoke.url(args, options),
+ method: 'head',
+})
+
+/**
+* @see \Cortex\Http\Controllers\AgentsController::stream
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66
+* @route '/api/agents/{agent}/stream'
+*/
+export const stream = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: stream.url(args, options),
+ method: 'get',
+})
+
+stream.definition = {
+ methods: ["get","post","head"],
+ url: '/api/agents/{agent}/stream',
+} satisfies RouteDefinition<["get","post","head"]>
+
+/**
+* @see \Cortex\Http\Controllers\AgentsController::stream
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66
+* @route '/api/agents/{agent}/stream'
+*/
+stream.url = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions) => {
+ if (typeof args === 'string' || typeof args === 'number') {
+ args = { agent: args }
+ }
+
+ if (typeof args === 'object' && !Array.isArray(args) && 'string' in args) {
+ args = { agent: args.string }
+ }
+
+ if (Array.isArray(args)) {
+ args = {
+ agent: args[0],
+ }
+ }
+
+ args = applyUrlDefaults(args)
+
+ const parsedArgs = {
+ agent: typeof args.agent === 'object'
+ ? args.agent.string
+ : args.agent,
+ }
+
+ return stream.definition.url
+ .replace('{agent}', parsedArgs.agent.toString())
+ .replace(/\/+$/, '') + queryParams(options)
+}
+
+/**
+* @see \Cortex\Http\Controllers\AgentsController::stream
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66
+* @route '/api/agents/{agent}/stream'
+*/
+stream.get = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: stream.url(args, options),
+ method: 'get',
+})
+
+/**
+* @see \Cortex\Http\Controllers\AgentsController::stream
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66
+* @route '/api/agents/{agent}/stream'
+*/
+stream.post = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({
+ url: stream.url(args, options),
+ method: 'post',
+})
+
+/**
+* @see \Cortex\Http\Controllers\AgentsController::stream
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66
+* @route '/api/agents/{agent}/stream'
+*/
+stream.head = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: stream.url(args, options),
+ method: 'head',
+})
+
+const agents = {
+ invoke: Object.assign(invoke, invoke),
+ stream: Object.assign(stream, stream),
+}
+
+export default agents
\ No newline at end of file
diff --git a/resources/js/routes/cortex/api/agui/index.ts b/resources/js/routes/cortex/api/agui/index.ts
new file mode 100644
index 0000000..3578a63
--- /dev/null
+++ b/resources/js/routes/cortex/api/agui/index.ts
@@ -0,0 +1,40 @@
+import { queryParams, type RouteQueryOptions, type RouteDefinition } from './../../../../wayfinder'
+/**
+* @see \Cortex\Http\Controllers\AGUIController::__invoke
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AGUIController.php:19
+* @route '/api/agui'
+*/
+export const invoke = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
+ url: invoke.url(options),
+ method: 'post',
+})
+
+invoke.definition = {
+ methods: ["post"],
+ url: '/api/agui',
+} satisfies RouteDefinition<["post"]>
+
+/**
+* @see \Cortex\Http\Controllers\AGUIController::__invoke
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AGUIController.php:19
+* @route '/api/agui'
+*/
+invoke.url = (options?: RouteQueryOptions) => {
+ return invoke.definition.url + queryParams(options)
+}
+
+/**
+* @see \Cortex\Http\Controllers\AGUIController::__invoke
+* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AGUIController.php:19
+* @route '/api/agui'
+*/
+invoke.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
+ url: invoke.url(options),
+ method: 'post',
+})
+
+const agui = {
+ invoke: Object.assign(invoke, invoke),
+}
+
+export default agui
\ No newline at end of file
diff --git a/resources/js/routes/cortex/api/index.ts b/resources/js/routes/cortex/api/index.ts
new file mode 100644
index 0000000..f5bb48a
--- /dev/null
+++ b/resources/js/routes/cortex/api/index.ts
@@ -0,0 +1,9 @@
+import agents from './agents'
+import agui from './agui'
+
+const api = {
+ agents: Object.assign(agents, agents),
+ agui: Object.assign(agui, agui),
+}
+
+export default api
\ No newline at end of file
diff --git a/resources/js/routes/cortex/index.ts b/resources/js/routes/cortex/index.ts
new file mode 100644
index 0000000..5584bc3
--- /dev/null
+++ b/resources/js/routes/cortex/index.ts
@@ -0,0 +1,50 @@
+import { queryParams, type RouteQueryOptions, type RouteDefinition } from './../../wayfinder'
+import api from './api'
+import agents from './agents'
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:8
+* @route '/cortex'
+*/
+export const dashboard = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: dashboard.url(options),
+ method: 'get',
+})
+
+dashboard.definition = {
+ methods: ["get","head"],
+ url: '/cortex',
+} satisfies RouteDefinition<["get","head"]>
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:8
+* @route '/cortex'
+*/
+dashboard.url = (options?: RouteQueryOptions) => {
+ return dashboard.definition.url + queryParams(options)
+}
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:8
+* @route '/cortex'
+*/
+dashboard.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: dashboard.url(options),
+ method: 'get',
+})
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/routes/web.php:8
+* @route '/cortex'
+*/
+dashboard.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: dashboard.url(options),
+ method: 'head',
+})
+
+const cortex = {
+ api: Object.assign(api, api),
+ dashboard: Object.assign(dashboard, dashboard),
+ agents: Object.assign(agents, agents),
+}
+
+export default cortex
\ No newline at end of file
diff --git a/resources/js/routes/storage/index.ts b/resources/js/routes/storage/index.ts
new file mode 100644
index 0000000..33cad0e
--- /dev/null
+++ b/resources/js/routes/storage/index.ts
@@ -0,0 +1,65 @@
+import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../wayfinder'
+import localA91488 from './local'
+/**
+* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:98
+* @route '/storage/{path}'
+*/
+export const local = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: local.url(args, options),
+ method: 'get',
+})
+
+local.definition = {
+ methods: ["get","head"],
+ url: '/storage/{path}',
+} satisfies RouteDefinition<["get","head"]>
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:98
+* @route '/storage/{path}'
+*/
+local.url = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions) => {
+ if (typeof args === 'string' || typeof args === 'number') {
+ args = { path: args }
+ }
+
+ if (Array.isArray(args)) {
+ args = {
+ path: args[0],
+ }
+ }
+
+ args = applyUrlDefaults(args)
+
+ const parsedArgs = {
+ path: args.path,
+ }
+
+ return local.definition.url
+ .replace('{path}', parsedArgs.path.toString())
+ .replace(/\/+$/, '') + queryParams(options)
+}
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:98
+* @route '/storage/{path}'
+*/
+local.get = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: local.url(args, options),
+ method: 'get',
+})
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:98
+* @route '/storage/{path}'
+*/
+local.head = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: local.url(args, options),
+ method: 'head',
+})
+
+const storage = {
+ local: Object.assign(local, localA91488),
+}
+
+export default storage
\ No newline at end of file
diff --git a/resources/js/routes/storage/local/index.ts b/resources/js/routes/storage/local/index.ts
new file mode 100644
index 0000000..d86ca8a
--- /dev/null
+++ b/resources/js/routes/storage/local/index.ts
@@ -0,0 +1,55 @@
+import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../wayfinder'
+/**
+* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:106
+* @route '/storage/{path}'
+*/
+export const upload = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'put'> => ({
+ url: upload.url(args, options),
+ method: 'put',
+})
+
+upload.definition = {
+ methods: ["put"],
+ url: '/storage/{path}',
+} satisfies RouteDefinition<["put"]>
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:106
+* @route '/storage/{path}'
+*/
+upload.url = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions) => {
+ if (typeof args === 'string' || typeof args === 'number') {
+ args = { path: args }
+ }
+
+ if (Array.isArray(args)) {
+ args = {
+ path: args[0],
+ }
+ }
+
+ args = applyUrlDefaults(args)
+
+ const parsedArgs = {
+ path: args.path,
+ }
+
+ return upload.definition.url
+ .replace('{path}', parsedArgs.path.toString())
+ .replace(/\/+$/, '') + queryParams(options)
+}
+
+/**
+* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:106
+* @route '/storage/{path}'
+*/
+upload.put = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'put'> => ({
+ url: upload.url(args, options),
+ method: 'put',
+})
+
+const local = {
+ upload: Object.assign(upload, upload),
+}
+
+export default local
\ No newline at end of file
diff --git a/resources/js/routes/workbench/index.ts b/resources/js/routes/workbench/index.ts
new file mode 100644
index 0000000..6bc6f86
--- /dev/null
+++ b/resources/js/routes/workbench/index.ts
@@ -0,0 +1,250 @@
+import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults, validateParameters } from './../../wayfinder'
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18
+* @route '/_workbench'
+*/
+export const start = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: start.url(options),
+ method: 'get',
+})
+
+start.definition = {
+ methods: ["get","head"],
+ url: '/_workbench',
+} satisfies RouteDefinition<["get","head"]>
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18
+* @route '/_workbench'
+*/
+start.url = (options?: RouteQueryOptions) => {
+ return start.definition.url + queryParams(options)
+}
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18
+* @route '/_workbench'
+*/
+start.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: start.url(options),
+ method: 'get',
+})
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18
+* @route '/_workbench'
+*/
+start.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: start.url(options),
+ method: 'head',
+})
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60
+* @route '/_workbench/login/{userId}/{guard?}'
+*/
+export const login = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: login.url(args, options),
+ method: 'get',
+})
+
+login.definition = {
+ methods: ["get","head"],
+ url: '/_workbench/login/{userId}/{guard?}',
+} satisfies RouteDefinition<["get","head"]>
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60
+* @route '/_workbench/login/{userId}/{guard?}'
+*/
+login.url = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions) => {
+ if (Array.isArray(args)) {
+ args = {
+ userId: args[0],
+ guard: args[1],
+ }
+ }
+
+ args = applyUrlDefaults(args)
+
+ validateParameters(args, [
+ "guard",
+ ])
+
+ const parsedArgs = {
+ userId: args.userId,
+ guard: args.guard,
+ }
+
+ return login.definition.url
+ .replace('{userId}', parsedArgs.userId.toString())
+ .replace('{guard?}', parsedArgs.guard?.toString() ?? '')
+ .replace(/\/+$/, '') + queryParams(options)
+}
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60
+* @route '/_workbench/login/{userId}/{guard?}'
+*/
+login.get = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: login.url(args, options),
+ method: 'get',
+})
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60
+* @route '/_workbench/login/{userId}/{guard?}'
+*/
+login.head = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: login.url(args, options),
+ method: 'head',
+})
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84
+* @route '/_workbench/logout/{guard?}'
+*/
+export const logout = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: logout.url(args, options),
+ method: 'get',
+})
+
+logout.definition = {
+ methods: ["get","head"],
+ url: '/_workbench/logout/{guard?}',
+} satisfies RouteDefinition<["get","head"]>
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84
+* @route '/_workbench/logout/{guard?}'
+*/
+logout.url = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions) => {
+ if (typeof args === 'string' || typeof args === 'number') {
+ args = { guard: args }
+ }
+
+ if (Array.isArray(args)) {
+ args = {
+ guard: args[0],
+ }
+ }
+
+ args = applyUrlDefaults(args)
+
+ validateParameters(args, [
+ "guard",
+ ])
+
+ const parsedArgs = {
+ guard: args?.guard,
+ }
+
+ return logout.definition.url
+ .replace('{guard?}', parsedArgs.guard?.toString() ?? '')
+ .replace(/\/+$/, '') + queryParams(options)
+}
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84
+* @route '/_workbench/logout/{guard?}'
+*/
+logout.get = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: logout.url(args, options),
+ method: 'get',
+})
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84
+* @route '/_workbench/logout/{guard?}'
+*/
+logout.head = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: logout.url(args, options),
+ method: 'head',
+})
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39
+* @route '/_workbench/user/{guard?}'
+*/
+export const user = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: user.url(args, options),
+ method: 'get',
+})
+
+user.definition = {
+ methods: ["get","head"],
+ url: '/_workbench/user/{guard?}',
+} satisfies RouteDefinition<["get","head"]>
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39
+* @route '/_workbench/user/{guard?}'
+*/
+user.url = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions) => {
+ if (typeof args === 'string' || typeof args === 'number') {
+ args = { guard: args }
+ }
+
+ if (Array.isArray(args)) {
+ args = {
+ guard: args[0],
+ }
+ }
+
+ args = applyUrlDefaults(args)
+
+ validateParameters(args, [
+ "guard",
+ ])
+
+ const parsedArgs = {
+ guard: args?.guard,
+ }
+
+ return user.definition.url
+ .replace('{guard?}', parsedArgs.guard?.toString() ?? '')
+ .replace(/\/+$/, '') + queryParams(options)
+}
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39
+* @route '/_workbench/user/{guard?}'
+*/
+user.get = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
+ url: user.url(args, options),
+ method: 'get',
+})
+
+/**
+* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user
+* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39
+* @route '/_workbench/user/{guard?}'
+*/
+user.head = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
+ url: user.url(args, options),
+ method: 'head',
+})
+
+const workbench = {
+ start: Object.assign(start, start),
+ login: Object.assign(login, login),
+ logout: Object.assign(logout, logout),
+ user: Object.assign(user, user),
+}
+
+export default workbench
\ No newline at end of file
diff --git a/resources/js/ssr.tsx b/resources/js/ssr.tsx
new file mode 100644
index 0000000..2a29cf6
--- /dev/null
+++ b/resources/js/ssr.tsx
@@ -0,0 +1,22 @@
+import { createInertiaApp } from "@inertiajs/react";
+import createServer from "@inertiajs/react/server";
+import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
+import ReactDOMServer from "react-dom/server";
+
+const appName = import.meta.env.VITE_APP_NAME || "Cortex Studio";
+
+createServer((page) =>
+ createInertiaApp({
+ page,
+ render: ReactDOMServer.renderToString,
+ title: (title) => (title ? `${title} - ${appName}` : appName),
+ resolve: (name) =>
+ resolvePageComponent(
+ `./pages/${name}.tsx`,
+ import.meta.glob("./pages/**/*.tsx"),
+ ),
+ setup: ({ App, props }) => {
+ return ;
+ },
+ }),
+);
diff --git a/resources/js/types/agents.d.ts b/resources/js/types/agents.d.ts
new file mode 100644
index 0000000..feeb1c2
--- /dev/null
+++ b/resources/js/types/agents.d.ts
@@ -0,0 +1,5 @@
+export interface Agent {
+ id: string;
+ name: string;
+ description: string;
+}
diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts
new file mode 100644
index 0000000..9f3f572
--- /dev/null
+++ b/resources/js/types/index.d.ts
@@ -0,0 +1,42 @@
+import { InertiaLinkProps } from "@inertiajs/react";
+import { LucideIcon } from "lucide-react";
+
+export interface Auth {
+ user: User;
+}
+
+export interface BreadcrumbItem {
+ title: string;
+ href: string;
+}
+
+export interface NavGroup {
+ title: string;
+ items: NavItem[];
+}
+
+export interface NavItem {
+ title: string;
+ href: NonNullable;
+ icon?: LucideIcon | null;
+ isActive?: boolean;
+}
+
+export interface SharedData {
+ name: string;
+ auth: Auth;
+ sidebarOpen: boolean;
+ [key: string]: unknown;
+}
+
+export interface User {
+ id: number;
+ name: string;
+ email: string;
+ avatar?: string;
+ email_verified_at: string | null;
+ two_factor_enabled?: boolean;
+ created_at: string;
+ updated_at: string;
+ [key: string]: unknown; // This allows for additional properties...
+}
diff --git a/resources/js/types/vite-env.d.ts b/resources/js/types/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/resources/js/types/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/resources/js/wayfinder/index.ts b/resources/js/wayfinder/index.ts
new file mode 100644
index 0000000..c9d6526
--- /dev/null
+++ b/resources/js/wayfinder/index.ts
@@ -0,0 +1,158 @@
+export type QueryParams = {
+ [key: string]:
+ | string
+ | number
+ | boolean
+ | (string | number)[]
+ | null
+ | undefined
+ | QueryParams;
+};
+
+type Method = "get" | "post" | "put" | "delete" | "patch" | "head" | "options";
+type UrlDefaults = Record;
+
+let urlDefaults: () => UrlDefaults = () => ({});
+
+export type RouteDefinition = {
+ url: string;
+} & (TMethod extends Method[] ? { methods: TMethod } : { method: TMethod });
+
+export type RouteFormDefinition = {
+ action: string;
+ method: TMethod;
+};
+
+export type RouteQueryOptions = {
+ query?: QueryParams;
+ mergeQuery?: QueryParams;
+};
+
+const getValue = (value: string | number | boolean) => {
+ if (value === true) {
+ return "1";
+ }
+
+ if (value === false) {
+ return "0";
+ }
+
+ return value.toString();
+};
+
+const addNestedParams = (
+ obj: QueryParams,
+ prefix: string,
+ params: URLSearchParams,
+) => {
+ Object.entries(obj).forEach(([subKey, value]) => {
+ if (value === undefined) return;
+
+ const paramKey = `${prefix}[${subKey}]`;
+
+ if (Array.isArray(value)) {
+ value.forEach((v) => params.append(`${paramKey}[]`, getValue(v)));
+ } else if (value !== null && typeof value === "object") {
+ addNestedParams(value, paramKey, params);
+ } else if (["string", "number", "boolean"].includes(typeof value)) {
+ params.set(paramKey, getValue(value as string | number | boolean));
+ }
+ });
+};
+
+export const queryParams = (options?: RouteQueryOptions) => {
+ if (!options || (!options.query && !options.mergeQuery)) {
+ return "";
+ }
+
+ const query = options.query ?? options.mergeQuery;
+ const includeExisting = options.mergeQuery !== undefined;
+
+ const params = new URLSearchParams(
+ includeExisting && typeof window !== "undefined"
+ ? window.location.search
+ : "",
+ );
+
+ for (const key in query) {
+ const queryValue = query[key];
+
+ if (queryValue === undefined || queryValue === null) {
+ params.delete(key);
+ continue;
+ }
+
+ if (Array.isArray(queryValue)) {
+ if (params.has(`${key}[]`)) {
+ params.delete(`${key}[]`);
+ }
+
+ queryValue.forEach((value) => {
+ params.append(`${key}[]`, value.toString());
+ });
+ } else if (typeof queryValue === "object") {
+ params.forEach((_, paramKey) => {
+ if (paramKey.startsWith(`${key}[`)) {
+ params.delete(paramKey);
+ }
+ });
+
+ addNestedParams(queryValue, key, params);
+ } else {
+ params.set(key, getValue(queryValue));
+ }
+ }
+
+ const str = params.toString();
+
+ return str.length > 0 ? `?${str}` : "";
+};
+
+export const setUrlDefaults = (params: UrlDefaults | (() => UrlDefaults)) => {
+ urlDefaults = typeof params === "function" ? params : () => params;
+};
+
+export const addUrlDefault = (
+ key: string,
+ value: string | number | boolean,
+) => {
+ const params = urlDefaults();
+ params[key] = value;
+
+ urlDefaults = () => params;
+};
+
+export const applyUrlDefaults = (
+ existing: T,
+): T => {
+ const existingParams = { ...(existing ?? ({} as UrlDefaults)) };
+ const defaultParams = urlDefaults();
+
+ for (const key in defaultParams) {
+ if (
+ existingParams[key] === undefined &&
+ defaultParams[key] !== undefined
+ ) {
+ (existingParams as Record)[key] =
+ defaultParams[key];
+ }
+ }
+
+ return existingParams as T;
+};
+
+export const validateParameters = (
+ args: Record | undefined,
+ optional: string[],
+) => {
+ const missing = optional.filter((key) => !args?.[key]);
+ const expectedMissing = optional.slice(missing.length * -1);
+
+ for (let i = 0; i < missing.length; i++) {
+ if (missing[i] !== expectedMissing[i]) {
+ throw Error(
+ "Unexpected optional parameters missing. Unable to generate a URL.",
+ );
+ }
+ }
+};
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
new file mode 100644
index 0000000..832ba11
--- /dev/null
+++ b/resources/views/app.blade.php
@@ -0,0 +1,49 @@
+
+ ($appearance ?? 'system') == 'dark'])>
+
+
+
+
+ {{-- Inline script to detect system dark mode preference and apply it immediately --}}
+
+
+ {{-- Inline style to set the HTML background color based on our theme in app.css --}}
+
+
+ {{ config('app.name', 'Cortex') }}
+
+
+
+
+
+
+
+
+ @viteReactRefresh
+ @vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
+ @inertiaHead
+
+
+ @inertia
+
+
diff --git a/routes/api.php b/routes/api.php
index d1a10e0..48b3e8f 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,11 +1,14 @@
name('cortex.')->group(function () {
+Route::prefix('api')->name('cortex.api.')->group(function () {
Route::prefix('agents')->name('agents.')->group(function () {
- Route::get('/{agent}/invoke', [AgentsController::class, 'invoke'])->name('invoke');
- Route::get('/{agent}/stream', [AgentsController::class, 'stream'])->name('stream');
+ Route::match(['get', 'post'], '/{agent:string}/invoke', [AgentsController::class, 'invoke'])->name('invoke');
+ Route::match(['get', 'post'], '/{agent:string}/stream', [AgentsController::class, 'stream'])->name('stream');
});
+
+ Route::post('/agui', AGUIController::class)->name('agui.invoke');
});
diff --git a/routes/web.php b/routes/web.php
new file mode 100644
index 0000000..cfae667
--- /dev/null
+++ b/routes/web.php
@@ -0,0 +1,31 @@
+name('cortex.')->group(function () {
+ Route::get('/', function () {
+ return Inertia::render('dashboard')->rootView('cortex::app');
+ })->name('dashboard');
+
+ Route::prefix('agents')->name('agents.')->group(function () {
+ Route::get('/', function () {
+ return Inertia::render('agents/index', [
+ 'agents' => AgentRegistry::toArray(),
+ ])
+ ->rootView('cortex::app');
+ })->name('index');
+
+ Route::get('/{agent:string}', function ($agent) {
+ $agent = AgentRegistry::get($agent);
+ return Inertia::render('agents/show', [
+ 'agent' => [
+ 'id' => $agent->getId(),
+ 'name' => $agent->getName(),
+ 'description' => $agent->getDescription(),
+ ],
+ ])->rootView('cortex::app');
+ })->name('show');
+ });
+});
diff --git a/scratchpad.php b/scratchpad.php
index 1910990..8ece8ea 100644
--- a/scratchpad.php
+++ b/scratchpad.php
@@ -10,7 +10,7 @@
use Cortex\Agents\Prebuilt\WeatherAgent;
use Cortex\LLM\Data\Messages\UserMessage;
use Cortex\LLM\Data\Messages\SystemMessage;
-use Cortex\Tools\Prebuilt\OpenMeteoWeatherTool;
+use Cortex\Tools\Prebuilt\GetCurrentWeatherTool;
$prompt = Cortex::prompt([
new SystemMessage('You are an expert at geography.'),
@@ -45,7 +45,7 @@
]);
// Get a generic agent builder from the prompt
-$agentBuilder = $prompt->agentBuilder();
+$agentBuilder = $prompt->agent();
$result = $agentBuilder->invoke(input: [
'country' => 'France',
@@ -69,6 +69,10 @@
new UserMessage('What is the capital of {country}?'),
]);
+$result = $prompt->agent()->invoke(input: [
+ 'country' => 'France',
+]);
+
// Uses default llm driver
$result = Cortex::llm()->invoke([
new SystemMessage('You are a helpful assistant'),
@@ -93,7 +97,7 @@
]);
$jokeAgent = new Agent(
- name: 'joke_generator',
+ id: 'joke_generator',
prompt: 'You are a joke generator. You generate jokes about {topic}.',
llm: 'ollama:gpt-oss:20b',
);
@@ -117,11 +121,11 @@
// ]));
$agent = Cortex::agent()
- ->withName('weather_agent')
+ ->withId('weather_agent')
->withPrompt('You are a weather agent. You tell the weather in {location}.')
->withLLM('lmstudio/openai/gpt-oss-20b')
->withTools([
- OpenMeteoWeatherTool::class,
+ GetCurrentWeatherTool::class,
])
->withOutput([
Schema::string('location')->required(),
diff --git a/src/AGUI/Contracts/Event.php b/src/AGUI/Contracts/Event.php
index 5c0a25d..530f204 100644
--- a/src/AGUI/Contracts/Event.php
+++ b/src/AGUI/Contracts/Event.php
@@ -4,14 +4,40 @@
namespace Cortex\AGUI\Contracts;
-use DateTimeImmutable;
+use DateTimeInterface;
use Cortex\AGUI\Enums\EventType;
interface Event
{
+ /**
+ * The type of the event.
+ */
public EventType $type { get; }
- public ?DateTimeImmutable $timestamp { get; }
+ /**
+ * The timestamp of the event.
+ */
+ public ?DateTimeInterface $timestamp { get; }
+ /**
+ * The raw event data.
+ */
public mixed $rawEvent { get; }
+
+ /**
+ * Set the timestamp for the event.
+ */
+ public function withTimestamp(DateTimeInterface $timestamp): static;
+
+ /**
+ * Set the raw event for the event.
+ */
+ public function withRawEvent(mixed $rawEvent): static;
+
+ /**
+ * Convert the event to an array.
+ *
+ * @return array
+ */
+ public function toArray(): array;
}
diff --git a/src/AGUI/Enums/EventType.php b/src/AGUI/Enums/EventType.php
index 671c760..ba0534e 100644
--- a/src/AGUI/Enums/EventType.php
+++ b/src/AGUI/Enums/EventType.php
@@ -6,30 +6,84 @@
enum EventType: string
{
+ /** Signals the start of an agent run. */
+ case RunStarted = 'RUN_STARTED';
+
+ /** Signals the successful completion of an agent run. */
+ case RunFinished = 'RUN_FINISHED';
+
+ /** Signals an error during an agent run. */
+ case RunError = 'RUN_ERROR';
+
+ /** Signals the start of a step within an agent run. */
+ case StepStarted = 'STEP_STARTED';
+
+ /** Signals the completion of a step within an agent run. */
+ case StepFinished = 'STEP_FINISHED';
+
+ /** Signals the start of a text message. */
case TextMessageStart = 'TEXT_MESSAGE_START';
+
+ /** Represents a chunk of content in a streaming text message. */
case TextMessageContent = 'TEXT_MESSAGE_CONTENT';
+
+ /** Signals the end of a text message. */
case TextMessageEnd = 'TEXT_MESSAGE_END';
+
+ /** Convenience event that expands to Start → Content → End automatically. */
case TextMessageChunk = 'TEXT_MESSAGE_CHUNK';
- case ThinkingTextMessageStart = 'THINKING_TEXT_MESSAGE_START';
- case ThinkingTextMessageContent = 'THINKING_TEXT_MESSAGE_CONTENT';
- case ThinkingTextMessageEnd = 'THINKING_TEXT_MESSAGE_END';
+
+ /** Signals the start of a tool call. */
case ToolCallStart = 'TOOL_CALL_START';
+
+ /** Represents a chunk of argument data for a tool call. */
case ToolCallArgs = 'TOOL_CALL_ARGS';
+
+ /** Signals the end of a tool call. */
case ToolCallEnd = 'TOOL_CALL_END';
+
+ /** Convenience event that expands to Start → Args → End automatically. */
case ToolCallChunk = 'TOOL_CALL_CHUNK';
+
+ /** Provides the result of a tool call execution. */
case ToolCallResult = 'TOOL_CALL_RESULT';
- case ThinkingStart = 'THINKING_START';
- case ThinkingEnd = 'THINKING_END';
+
+ /** Marks the start of reasoning. */
+ case ReasoningStart = 'THINKING_START';
+
+ /** Marks the end of reasoning. */
+ case ReasoningEnd = 'THINKING_END';
+
+ /** Signals the start of a reasoning message. */
+ case ReasoningMessageStart = 'THINKING_TEXT_MESSAGE_START';
+
+ /** Represents a chunk of content in a streaming reasoning message. */
+ case ReasoningMessageContent = 'THINKING_TEXT_MESSAGE_CONTENT';
+
+ /** Signals the end of a reasoning message. */
+ case ReasoningMessageEnd = 'THINKING_TEXT_MESSAGE_END';
+
+ /** A convenience event to auto start/close reasoning messages. */
+ case ReasoningMessageChunk = 'THINKING_TEXT_MESSAGE_CHUNK';
+
+ /** Provides a complete snapshot of an agent’s state. */
case StateSnapshot = 'STATE_SNAPSHOT';
+
+ /** Provides a partial update to an agent’s state using JSON Patch. */
case StateDelta = 'STATE_DELTA';
+
+ /** Provides a snapshot of all messages in a conversation. */
case MessagesSnapshot = 'MESSAGES_SNAPSHOT';
+
+ /** Delivers a complete snapshot of an activity message. */
case ActivitySnapshot = 'ACTIVITY_SNAPSHOT';
+
+ /** Applies incremental updates to an existing activity using JSON Patch operations. */
case ActivityDelta = 'ACTIVITY_DELTA';
+
+ /** Used to pass through events from external systems. */
case Raw = 'RAW';
+
+ /** Used for application-specific custom events. */
case Custom = 'CUSTOM';
- case RunStarted = 'RUN_STARTED';
- case RunFinished = 'RUN_FINISHED';
- case RunError = 'RUN_ERROR';
- case StepStarted = 'STEP_STARTED';
- case StepFinished = 'STEP_FINISHED';
}
diff --git a/src/AGUI/Events/AbstractEvent.php b/src/AGUI/Events/AbstractEvent.php
index 1394ca1..eb8cf57 100644
--- a/src/AGUI/Events/AbstractEvent.php
+++ b/src/AGUI/Events/AbstractEvent.php
@@ -4,16 +4,53 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
+use DateTimeInterface;
use Cortex\AGUI\Contracts\Event;
use Cortex\AGUI\Enums\EventType;
abstract class AbstractEvent implements Event
{
- public EventType $type;
+ public protected(set) EventType $type;
- public function __construct(
- public ?DateTimeImmutable $timestamp = null,
- public mixed $rawEvent = null,
- ) {}
+ public protected(set) ?DateTimeInterface $timestamp = null;
+
+ public protected(set) mixed $rawEvent;
+
+ public function withTimestamp(DateTimeInterface $timestamp): static
+ {
+ $this->timestamp = $timestamp;
+
+ return $this;
+ }
+
+ public function withRawEvent(mixed $rawEvent): static
+ {
+ $this->rawEvent = $rawEvent;
+
+ return $this;
+ }
+
+ protected function formattedTimestamp(): ?int
+ {
+ return $this->timestamp?->getTimestamp();
+ }
+
+ /**
+ * @param array $additional
+ *
+ * @return array
+ */
+ protected function buildArray(array $additional = []): array
+ {
+ $payload = [
+ 'type' => $this->type->value,
+ ...$additional,
+ ];
+
+ if ($this->timestamp !== null) {
+ $payload['timestamp'] = $this->timestamp->getTimestamp();
+ }
+
+ return array_filter($payload, fn(mixed $value): bool => $value !== null);
+ }
}
diff --git a/src/AGUI/Events/ActivityDelta.php b/src/AGUI/Events/ActivityDelta.php
index ccbf378..a814669 100644
--- a/src/AGUI/Events/ActivityDelta.php
+++ b/src/AGUI/Events/ActivityDelta.php
@@ -4,22 +4,36 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class ActivityDelta extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class ActivityDelta extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::ActivityDelta;
+
/**
- * @param array $patch
+ * @param string $messageId Identifier for the target activity message
+ * @param string $activityType Activity discriminator (mirrors the value from the most recent snapshot)
+ * @param array $patch Array of RFC 6902 JSON Patch operations to apply to the activity data
*/
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $messageId = '',
public string $activityType = '',
public array $patch = [],
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::ActivityDelta;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ 'activityType' => $this->activityType,
+ 'patch' => $this->patch,
+ ]);
}
}
diff --git a/src/AGUI/Events/ActivitySnapshot.php b/src/AGUI/Events/ActivitySnapshot.php
index 5ab8e1c..a3fafda 100644
--- a/src/AGUI/Events/ActivitySnapshot.php
+++ b/src/AGUI/Events/ActivitySnapshot.php
@@ -4,23 +4,36 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class ActivitySnapshot extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class ActivitySnapshot extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::ActivitySnapshot;
+
/**
* @param array $content
*/
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $messageId = '',
public string $activityType = '',
public array $content = [],
public bool $replace = true,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::ActivitySnapshot;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ 'activityType' => $this->activityType,
+ 'content' => $this->content,
+ 'replace' => $this->replace,
+ ]);
}
}
diff --git a/src/AGUI/Events/Custom.php b/src/AGUI/Events/Custom.php
index a8c74aa..667a5c9 100644
--- a/src/AGUI/Events/Custom.php
+++ b/src/AGUI/Events/Custom.php
@@ -4,18 +4,29 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class Custom extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class Custom extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::Custom;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $name = '',
public mixed $value = null,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::Custom;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'name' => $this->name,
+ 'value' => $this->value,
+ ]);
}
}
diff --git a/src/AGUI/Events/MessagesSnapshot.php b/src/AGUI/Events/MessagesSnapshot.php
index 788de82..2a00c3b 100644
--- a/src/AGUI/Events/MessagesSnapshot.php
+++ b/src/AGUI/Events/MessagesSnapshot.php
@@ -4,20 +4,30 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class MessagesSnapshot extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class MessagesSnapshot extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::MessagesSnapshot;
+
/**
* @param array $messages
*/
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public array $messages = [],
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::MessagesSnapshot;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messages' => $this->messages,
+ ]);
}
}
diff --git a/src/AGUI/Events/Raw.php b/src/AGUI/Events/Raw.php
index f79c473..485500c 100644
--- a/src/AGUI/Events/Raw.php
+++ b/src/AGUI/Events/Raw.php
@@ -4,18 +4,29 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class Raw extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class Raw extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::Raw;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public mixed $event = null,
public ?string $source = null,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::Raw;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'event' => $this->event,
+ 'source' => $this->source,
+ ]);
}
}
diff --git a/src/AGUI/Events/ReasoningEnd.php b/src/AGUI/Events/ReasoningEnd.php
new file mode 100644
index 0000000..fa89bb4
--- /dev/null
+++ b/src/AGUI/Events/ReasoningEnd.php
@@ -0,0 +1,30 @@
+
+ */
+final class ReasoningEnd extends AbstractEvent implements Arrayable
+{
+ public EventType $type = EventType::ReasoningEnd;
+
+ public function __construct(
+ public string $messageId = '',
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ ]);
+ }
+}
diff --git a/src/AGUI/Events/ReasoningMessageContent.php b/src/AGUI/Events/ReasoningMessageContent.php
new file mode 100644
index 0000000..ef2d94c
--- /dev/null
+++ b/src/AGUI/Events/ReasoningMessageContent.php
@@ -0,0 +1,32 @@
+
+ */
+final class ReasoningMessageContent extends AbstractEvent implements Arrayable
+{
+ public EventType $type = EventType::ReasoningMessageContent;
+
+ public function __construct(
+ public string $messageId = '',
+ public string $delta = '',
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ 'delta' => $this->delta,
+ ]);
+ }
+}
diff --git a/src/AGUI/Events/ReasoningMessageEnd.php b/src/AGUI/Events/ReasoningMessageEnd.php
new file mode 100644
index 0000000..a01ca0b
--- /dev/null
+++ b/src/AGUI/Events/ReasoningMessageEnd.php
@@ -0,0 +1,30 @@
+
+ */
+final class ReasoningMessageEnd extends AbstractEvent implements Arrayable
+{
+ public EventType $type = EventType::ReasoningMessageEnd;
+
+ public function __construct(
+ public string $messageId = '',
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ ]);
+ }
+}
diff --git a/src/AGUI/Events/ReasoningMessageStart.php b/src/AGUI/Events/ReasoningMessageStart.php
new file mode 100644
index 0000000..eedb193
--- /dev/null
+++ b/src/AGUI/Events/ReasoningMessageStart.php
@@ -0,0 +1,32 @@
+
+ */
+final class ReasoningMessageStart extends AbstractEvent implements Arrayable
+{
+ public EventType $type = EventType::ReasoningMessageStart;
+
+ public function __construct(
+ public string $messageId = '',
+ public string $role = 'assistant',
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ 'role' => $this->role,
+ ]);
+ }
+}
diff --git a/src/AGUI/Events/ReasoningStart.php b/src/AGUI/Events/ReasoningStart.php
new file mode 100644
index 0000000..483354e
--- /dev/null
+++ b/src/AGUI/Events/ReasoningStart.php
@@ -0,0 +1,32 @@
+
+ */
+final class ReasoningStart extends AbstractEvent implements Arrayable
+{
+ public EventType $type = EventType::ReasoningStart;
+
+ public function __construct(
+ public string $messageId = '',
+ public ?string $encryptedContent = null,
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ 'encryptedContent' => $this->encryptedContent,
+ ]);
+ }
+}
diff --git a/src/AGUI/Events/RunError.php b/src/AGUI/Events/RunError.php
index adb58c2..dd41f11 100644
--- a/src/AGUI/Events/RunError.php
+++ b/src/AGUI/Events/RunError.php
@@ -4,18 +4,29 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class RunError extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class RunError extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::RunError;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $message = '',
public ?string $code = null,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::RunError;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'message' => $this->message,
+ 'code' => $this->code,
+ ]);
}
}
diff --git a/src/AGUI/Events/RunFinished.php b/src/AGUI/Events/RunFinished.php
index 4c2f6ed..dfb5bb8 100644
--- a/src/AGUI/Events/RunFinished.php
+++ b/src/AGUI/Events/RunFinished.php
@@ -4,19 +4,31 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class RunFinished extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class RunFinished extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::RunFinished;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $threadId = '',
public string $runId = '',
public mixed $result = null,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::RunFinished;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'threadId' => $this->threadId,
+ 'runId' => $this->runId,
+ 'result' => $this->result,
+ ]);
}
}
diff --git a/src/AGUI/Events/RunStarted.php b/src/AGUI/Events/RunStarted.php
index 006cf05..ea2c2fa 100644
--- a/src/AGUI/Events/RunStarted.php
+++ b/src/AGUI/Events/RunStarted.php
@@ -4,20 +4,33 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class RunStarted extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class RunStarted extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::RunStarted;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $threadId = '',
public string $runId = '',
public ?string $parentRunId = null,
public mixed $input = null,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::RunStarted;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'threadId' => $this->threadId,
+ 'runId' => $this->runId,
+ 'parentRunId' => $this->parentRunId,
+ 'input' => $this->input,
+ ]);
}
}
diff --git a/src/AGUI/Events/StateDelta.php b/src/AGUI/Events/StateDelta.php
index 8f9f50b..7d62899 100644
--- a/src/AGUI/Events/StateDelta.php
+++ b/src/AGUI/Events/StateDelta.php
@@ -4,20 +4,30 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class StateDelta extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class StateDelta extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::StateDelta;
+
/**
* @param array $delta
*/
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public array $delta = [],
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::StateDelta;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'delta' => $this->delta,
+ ]);
}
}
diff --git a/src/AGUI/Events/StateSnapshot.php b/src/AGUI/Events/StateSnapshot.php
index ad17898..81894c8 100644
--- a/src/AGUI/Events/StateSnapshot.php
+++ b/src/AGUI/Events/StateSnapshot.php
@@ -4,17 +4,27 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class StateSnapshot extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class StateSnapshot extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::StateSnapshot;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public mixed $snapshot = null,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::StateSnapshot;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'snapshot' => $this->snapshot,
+ ]);
}
}
diff --git a/src/AGUI/Events/StepFinished.php b/src/AGUI/Events/StepFinished.php
index dbfea42..d5d1a70 100644
--- a/src/AGUI/Events/StepFinished.php
+++ b/src/AGUI/Events/StepFinished.php
@@ -4,17 +4,27 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class StepFinished extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class StepFinished extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::StepFinished;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $stepName = '',
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::StepFinished;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'stepName' => $this->stepName,
+ ]);
}
}
diff --git a/src/AGUI/Events/StepStarted.php b/src/AGUI/Events/StepStarted.php
index c13d7cd..1ac5b3d 100644
--- a/src/AGUI/Events/StepStarted.php
+++ b/src/AGUI/Events/StepStarted.php
@@ -4,17 +4,27 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class StepStarted extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class StepStarted extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::StepStarted;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $stepName = '',
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::StepStarted;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'stepName' => $this->stepName,
+ ]);
}
}
diff --git a/src/AGUI/Events/TextMessageChunk.php b/src/AGUI/Events/TextMessageChunk.php
index ca0848a..8ff37ee 100644
--- a/src/AGUI/Events/TextMessageChunk.php
+++ b/src/AGUI/Events/TextMessageChunk.php
@@ -4,19 +4,31 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class TextMessageChunk extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class TextMessageChunk extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::TextMessageChunk;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public ?string $messageId = null,
public ?string $role = null,
public ?string $delta = null,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::TextMessageChunk;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ 'role' => $this->role,
+ 'delta' => $this->delta,
+ ]);
}
}
diff --git a/src/AGUI/Events/TextMessageContent.php b/src/AGUI/Events/TextMessageContent.php
index 9fae383..cc6407b 100644
--- a/src/AGUI/Events/TextMessageContent.php
+++ b/src/AGUI/Events/TextMessageContent.php
@@ -4,18 +4,29 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class TextMessageContent extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class TextMessageContent extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::TextMessageContent;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $messageId = '',
public string $delta = '',
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::TextMessageContent;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ 'delta' => $this->delta,
+ ]);
}
}
diff --git a/src/AGUI/Events/TextMessageEnd.php b/src/AGUI/Events/TextMessageEnd.php
index 4dc018c..434ea83 100644
--- a/src/AGUI/Events/TextMessageEnd.php
+++ b/src/AGUI/Events/TextMessageEnd.php
@@ -4,17 +4,27 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class TextMessageEnd extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class TextMessageEnd extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::TextMessageEnd;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $messageId = '',
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::TextMessageEnd;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ ]);
}
}
diff --git a/src/AGUI/Events/TextMessageStart.php b/src/AGUI/Events/TextMessageStart.php
index 65075b1..8f5e5db 100644
--- a/src/AGUI/Events/TextMessageStart.php
+++ b/src/AGUI/Events/TextMessageStart.php
@@ -4,18 +4,29 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class TextMessageStart extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class TextMessageStart extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::TextMessageStart;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $messageId = '',
public string $role = 'assistant',
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::TextMessageStart;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ 'role' => $this->role,
+ ]);
}
}
diff --git a/src/AGUI/Events/ThinkingEnd.php b/src/AGUI/Events/ThinkingEnd.php
deleted file mode 100644
index ec1ae51..0000000
--- a/src/AGUI/Events/ThinkingEnd.php
+++ /dev/null
@@ -1,19 +0,0 @@
-type = EventType::ThinkingEnd;
- }
-}
diff --git a/src/AGUI/Events/ThinkingStart.php b/src/AGUI/Events/ThinkingStart.php
deleted file mode 100644
index 07178a4..0000000
--- a/src/AGUI/Events/ThinkingStart.php
+++ /dev/null
@@ -1,20 +0,0 @@
-type = EventType::ThinkingStart;
- }
-}
diff --git a/src/AGUI/Events/ThinkingTextMessageContent.php b/src/AGUI/Events/ThinkingTextMessageContent.php
deleted file mode 100644
index 404dc90..0000000
--- a/src/AGUI/Events/ThinkingTextMessageContent.php
+++ /dev/null
@@ -1,20 +0,0 @@
-type = EventType::ThinkingTextMessageContent;
- }
-}
diff --git a/src/AGUI/Events/ThinkingTextMessageEnd.php b/src/AGUI/Events/ThinkingTextMessageEnd.php
deleted file mode 100644
index 0f6de9f..0000000
--- a/src/AGUI/Events/ThinkingTextMessageEnd.php
+++ /dev/null
@@ -1,19 +0,0 @@
-type = EventType::ThinkingTextMessageEnd;
- }
-}
diff --git a/src/AGUI/Events/ThinkingTextMessageStart.php b/src/AGUI/Events/ThinkingTextMessageStart.php
deleted file mode 100644
index b12b970..0000000
--- a/src/AGUI/Events/ThinkingTextMessageStart.php
+++ /dev/null
@@ -1,19 +0,0 @@
-type = EventType::ThinkingTextMessageStart;
- }
-}
diff --git a/src/AGUI/Events/ToolCallArgs.php b/src/AGUI/Events/ToolCallArgs.php
index 241bfa5..1e2dc65 100644
--- a/src/AGUI/Events/ToolCallArgs.php
+++ b/src/AGUI/Events/ToolCallArgs.php
@@ -4,18 +4,29 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class ToolCallArgs extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class ToolCallArgs extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::ToolCallArgs;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $toolCallId = '',
public string $delta = '',
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::ToolCallArgs;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'toolCallId' => $this->toolCallId,
+ 'delta' => $this->delta,
+ ]);
}
}
diff --git a/src/AGUI/Events/ToolCallChunk.php b/src/AGUI/Events/ToolCallChunk.php
index 41844fa..119c271 100644
--- a/src/AGUI/Events/ToolCallChunk.php
+++ b/src/AGUI/Events/ToolCallChunk.php
@@ -4,20 +4,33 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class ToolCallChunk extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class ToolCallChunk extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::ToolCallChunk;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public ?string $toolCallId = null,
public ?string $toolCallName = null,
public ?string $parentMessageId = null,
public ?string $delta = null,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::ToolCallChunk;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'toolCallId' => $this->toolCallId,
+ 'toolCallName' => $this->toolCallName,
+ 'parentMessageId' => $this->parentMessageId,
+ 'delta' => $this->delta,
+ ]);
}
}
diff --git a/src/AGUI/Events/ToolCallEnd.php b/src/AGUI/Events/ToolCallEnd.php
index cd8f7c3..f210c4d 100644
--- a/src/AGUI/Events/ToolCallEnd.php
+++ b/src/AGUI/Events/ToolCallEnd.php
@@ -4,17 +4,27 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class ToolCallEnd extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class ToolCallEnd extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::ToolCallEnd;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $toolCallId = '',
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::ToolCallEnd;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'toolCallId' => $this->toolCallId,
+ ]);
}
}
diff --git a/src/AGUI/Events/ToolCallResult.php b/src/AGUI/Events/ToolCallResult.php
index bf66d2d..b25aa4f 100644
--- a/src/AGUI/Events/ToolCallResult.php
+++ b/src/AGUI/Events/ToolCallResult.php
@@ -4,20 +4,33 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class ToolCallResult extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class ToolCallResult extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::ToolCallResult;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $messageId = '',
public string $toolCallId = '',
public string $content = '',
public ?string $role = 'tool',
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::ToolCallResult;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'messageId' => $this->messageId,
+ 'toolCallId' => $this->toolCallId,
+ 'content' => $this->content,
+ 'role' => $this->role,
+ ]);
}
}
diff --git a/src/AGUI/Events/ToolCallStart.php b/src/AGUI/Events/ToolCallStart.php
index 491ed38..4808dba 100644
--- a/src/AGUI/Events/ToolCallStart.php
+++ b/src/AGUI/Events/ToolCallStart.php
@@ -4,19 +4,31 @@
namespace Cortex\AGUI\Events;
-use DateTimeImmutable;
use Cortex\AGUI\Enums\EventType;
+use Illuminate\Contracts\Support\Arrayable;
-final class ToolCallStart extends AbstractEvent
+/**
+ * @implements Arrayable
+ */
+final class ToolCallStart extends AbstractEvent implements Arrayable
{
+ public EventType $type = EventType::ToolCallStart;
+
public function __construct(
- ?DateTimeImmutable $timestamp = null,
- mixed $rawEvent = null,
public string $toolCallId = '',
public string $toolCallName = '',
public ?string $parentMessageId = null,
- ) {
- parent::__construct($timestamp, $rawEvent);
- $this->type = EventType::ToolCallStart;
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->buildArray([
+ 'toolCallId' => $this->toolCallId,
+ 'toolCallName' => $this->toolCallName,
+ 'parentMessageId' => $this->parentMessageId,
+ ]);
}
}
diff --git a/src/Agents/AbstractAgentBuilder.php b/src/Agents/AbstractAgentBuilder.php
index 52f508f..08e5d61 100644
--- a/src/Agents/AbstractAgentBuilder.php
+++ b/src/Agents/AbstractAgentBuilder.php
@@ -22,6 +22,16 @@
abstract class AbstractAgentBuilder implements AgentBuilder
{
+ public function name(): ?string
+ {
+ return null;
+ }
+
+ public function description(): ?string
+ {
+ return null;
+ }
+
public function llm(): LLM|string|null
{
return null;
@@ -84,9 +94,11 @@ public function middleware(): array
public function build(): Agent
{
return new Agent(
- name: $this->name(),
+ id: $this->id(),
prompt: $this->prompt(),
llm: $this->llm(),
+ name: $this->name(),
+ description: $this->description(),
tools: $this->tools(),
toolChoice: $this->toolChoice(),
output: $this->output(),
diff --git a/src/Agents/Agent.php b/src/Agents/Agent.php
index d19b6c8..8f8af47 100644
--- a/src/Agents/Agent.php
+++ b/src/Agents/Agent.php
@@ -10,6 +10,7 @@
use Cortex\LLM\Data\Usage;
use Cortex\Prompts\Prompt;
use Cortex\Events\AgentEnd;
+use Cortex\Tools\AgentTool;
use Illuminate\Support\Str;
use Cortex\Contracts\ToolKit;
use Cortex\Events\AgentStart;
@@ -38,7 +39,6 @@
use Cortex\Memory\Stores\InMemoryStore;
use Cortex\LLM\Data\ChatGenerationChunk;
use Cortex\Agents\Stages\HandleToolCalls;
-use Cortex\Agents\Stages\TrackAgentStart;
use Cortex\JsonSchema\Types\ObjectSchema;
use Cortex\LLM\Data\Messages\UserMessage;
use Cortex\LLM\Enums\StructuredOutputMode;
@@ -83,11 +83,13 @@ class Agent implements Pipeable
* @param array|\Cortex\Contracts\ToolKit $tools
* @param array $initialPromptVariables
* @param array $middleware
+ * @param array $metadata
*/
public function __construct(
- protected string $name,
+ protected string $id,
ChatPromptTemplate|ChatPromptBuilder|string|null $prompt = null,
LLMContract|string|null $llm = null,
+ protected ?string $name = null,
protected ?string $description = null,
protected array|ToolKit $tools = [],
protected ToolChoice|string $toolChoice = ToolChoice::Auto,
@@ -99,9 +101,10 @@ public function __construct(
protected bool $strict = true,
protected ?RuntimeConfig $runtimeConfig = null,
protected array $middleware = [],
+ protected array $metadata = [],
) {
$this->prompt = self::buildPromptTemplate($prompt, $strict, $initialPromptVariables);
- $this->memory = self::buildMemory($this->prompt, $this->memoryStore);
+ $this->memory = self::buildMemory($this->prompt, $runtimeConfig?->threadId, $this->memoryStore);
$this->middleware = [...$this->defaultMiddleware(), ...$this->middleware];
// Reset the prompt to only the message placeholders, since the initial
@@ -111,7 +114,7 @@ public function __construct(
$this->output = self::buildOutput($output);
$this->llm = self::buildLLM(
$this->prompt,
- $this->name,
+ $this->id,
$llm,
$this->tools,
$this->toolChoice,
@@ -125,18 +128,16 @@ public function __construct(
/**
* @param array $messages
* @param array $input
+ *
+ * @return ($streaming is true ? \Cortex\LLM\Data\ChatStreamResult : \Cortex\LLM\Data\ChatResult)
*/
public function invoke(
MessageCollection|UserMessage|array|string $messages = [],
array $input = [],
?RuntimeConfig $config = null,
- ): ChatResult {
- return $this->invokePipeline(
- messages: $messages,
- input: $input,
- config: $config,
- streaming: false,
- );
+ bool $streaming = false,
+ ): ChatResult|ChatStreamResult {
+ return $this->__invoke($messages, $input, $config, $streaming);
}
/**
@@ -150,12 +151,7 @@ public function stream(
array $input = [],
?RuntimeConfig $config = null,
): ChatStreamResult {
- return $this->invokePipeline(
- messages: $messages,
- input: $input,
- config: $config,
- streaming: true,
- );
+ return $this->__invoke($messages, $input, $config, true);
}
/**
@@ -198,7 +194,28 @@ public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $n
return $next($this->invoke($messages, $input, $config), $config);
}
- public function getName(): string
+ /**
+ * Wrap the agent as a tool, which can be used by another agent.
+ */
+ public function asTool(
+ ?string $name = null,
+ ?string $description = null,
+ ?ObjectSchema $schema = null,
+ ): AgentTool {
+ return new AgentTool(
+ $this,
+ $name ?? $this->id,
+ $description ?? $this->description,
+ $schema ?? $this->getInputSchema(),
+ );
+ }
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+
+ public function getName(): ?string
{
return $this->name;
}
@@ -265,6 +282,34 @@ public function getRuntimeConfig(): ?RuntimeConfig
return $this->runtimeConfig;
}
+ /**
+ * Get the output schema for the agent.
+ */
+ public function getOutputSchema(): ?ObjectSchema
+ {
+ if ($this->output === null) {
+ return null;
+ }
+
+ $schema = $this->output instanceof ObjectSchema
+ ? $this->output
+ : Schema::from($this->output);
+
+ if (! $schema instanceof ObjectSchema) {
+ throw new GenericException(sprintf('Output schema for agent [%s] is not an object schema', $this->id));
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Get the input schema for the agent.
+ */
+ public function getInputSchema(): ObjectSchema
+ {
+ return $this->prompt->getInputSchema();
+ }
+
/**
* Register a listener for the start of the agent.
*/
@@ -320,7 +365,7 @@ public function withLLM(LLMContract|string|null $llm): self
{
$this->llm = self::buildLLM(
$this->prompt,
- $this->name,
+ $this->id,
$llm,
$this->tools,
$this->toolChoice,
@@ -352,17 +397,28 @@ public function withRuntimeConfig(RuntimeConfig $runtimeConfig): self
return $this;
}
+ /**
+ * Check if the agent has structured output.
+ */
+ public function hasStructuredOutput(): bool
+ {
+ return $this->output !== null;
+ }
+
+ /**
+ * Check if the agent has prompt variables.
+ */
+ public function hasPromptVariables(): bool
+ {
+ return $this->prompt->variables()->isNotEmpty();
+ }
+
protected function buildPipeline(): Pipeline
{
- $tools = Utils::toToolCollection($this->getTools());
$executionStages = $this->executionStages();
+ $tools = Utils::toToolCollection($this->getTools());
- $pipeline = new Pipeline(
- new TrackAgentStart($this),
- ...$executionStages,
- );
-
- return $pipeline->when(
+ return new Pipeline(...$executionStages)->when(
$tools->isNotEmpty(),
fn(Pipeline $pipeline): Pipeline => $pipeline->pipe(
new HandleToolCalls($tools, $this->memory, $executionStages, $this->maxSteps),
@@ -405,6 +461,10 @@ protected function invokePipeline(
$config ??= $this->runtimeConfig ?? new RuntimeConfig();
$this->withRuntimeConfig($config);
+ foreach ($config->tools as $tool) {
+ $this->llm->addTool($tool);
+ }
+
if ($streaming) {
$config->onStreamChunk(function (RuntimeConfigStreamChunk $event): void {
$this->withRuntimeConfig($event->config);
@@ -428,6 +488,7 @@ protected function invokePipeline(
$messages = Utils::toMessageCollection($messages);
$this->memory
+ ->setThreadId($config->threadId)
->setMessages($this->memory->getMessages()->merge($messages))
->setVariables([
...$this->initialPromptVariables,
@@ -442,19 +503,42 @@ protected function invokePipeline(
$result = $this->pipeline
->enableStreaming($streaming)
->onStart(function (PipelineStart $event): void {
+ $event->config->pushChunkWhenStreaming(
+ new ChatGenerationChunk(
+ ChunkType::RunStart,
+ metadata: [
+ 'run_id' => $event->config->runId,
+ 'thread_id' => $event->config->threadId,
+ ],
+ ),
+ fn() => $this->dispatchEvent(new AgentStart($this, $event->config)),
+ );
$this->withRuntimeConfig($event->config);
})
->onEnd(function (PipelineEnd $event): void {
$this->withRuntimeConfig($event->config);
$event->config->pushChunkWhenStreaming(
- new ChatGenerationChunk(ChunkType::RunEnd),
+ new ChatGenerationChunk(
+ ChunkType::RunEnd,
+ metadata: [
+ 'run_id' => $event->config->runId,
+ 'thread_id' => $event->config->threadId,
+ ],
+ ),
fn() => $this->dispatchEvent(new AgentEnd($this, $event->config)),
);
})
->onError(function (PipelineError $event): void {
$this->withRuntimeConfig($event->config);
$event->config->pushChunkWhenStreaming(
- new ChatGenerationChunk(ChunkType::Error),
+ new ChatGenerationChunk(
+ ChunkType::Error,
+ exception: $event->exception,
+ metadata: [
+ 'run_id' => $event->config->runId,
+ 'thread_id' => $event->config->threadId,
+ ],
+ ),
fn() => $this->dispatchEvent(new AgentStepError($this, $event->exception, $event->config)),
);
})
@@ -498,9 +582,12 @@ protected static function buildPromptTemplate(
/**
* Build the memory instance for the agent.
*/
- protected static function buildMemory(ChatPromptTemplate $prompt, ?Store $memoryStore = null): ChatMemoryContract
- {
- $memoryStore ??= new InMemoryStore(Str::uuid7()->toString());
+ protected static function buildMemory(
+ ChatPromptTemplate $prompt,
+ ?string $threadId = null,
+ ?Store $memoryStore = null,
+ ): ChatMemoryContract {
+ $memoryStore ??= new InMemoryStore($threadId ?? Str::uuid7()->toString());
$memoryStore->setMessages($prompt->messages->withoutPlaceholders());
return new ChatMemory($memoryStore);
@@ -558,7 +645,7 @@ protected static function buildOutput(ObjectSchema|array|string|null $output): O
try {
collect($output)->ensure(JsonSchema::class);
} catch (UnexpectedValueException $e) {
- throw new GenericException('Invalid output schema: ' . $e->getMessage(), previous: $e);
+ throw new GenericException('Invalid output schema: ' . $e->getMessage(), $e->getCode(), previous: $e);
}
return Schema::object()->properties(...$output);
diff --git a/src/Agents/Contracts/AgentBuilder.php b/src/Agents/Contracts/AgentBuilder.php
index 75aa0da..003278e 100644
--- a/src/Agents/Contracts/AgentBuilder.php
+++ b/src/Agents/Contracts/AgentBuilder.php
@@ -15,10 +15,20 @@
interface AgentBuilder
{
+ /**
+ * Specify the id of the agent.
+ */
+ public static function id(): string;
+
/**
* Specify the name of the agent.
*/
- public static function name(): string;
+ public function name(): ?string;
+
+ /**
+ * Specify the description of the agent.
+ */
+ public function description(): ?string;
/**
* Specify the prompt for the agent.
diff --git a/src/Agents/Middleware/SkillMiddleware.php b/src/Agents/Middleware/SkillMiddleware.php
new file mode 100644
index 0000000..b88ed00
--- /dev/null
+++ b/src/Agents/Middleware/SkillMiddleware.php
@@ -0,0 +1,136 @@
+ $autoActivateSkills Skills to automatically activate
+ */
+ public function __construct(
+ protected SkillRegistry $registry,
+ protected array $autoActivateSkills = [],
+ ) {}
+
+ #[Override]
+ public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $next): mixed
+ {
+ // Get active skills from context (set by UseSkillTool)
+ /** @var array $activeSkills */
+ $activeSkills = $config->context->get(UseSkillTool::ACTIVE_SKILLS_KEY, []);
+
+ // Merge with auto-activated skills
+ foreach ($this->autoActivateSkills as $skillName) {
+ if ($this->registry->has($skillName)) {
+ $activeSkills[$skillName] = true;
+ }
+ }
+
+ // If no active skills, pass through
+ if ($activeSkills === []) {
+ return $next($payload, $config);
+ }
+
+ // Build skill instructions to inject
+ $skillInstructions = $this->buildSkillInstructions(array_keys($activeSkills));
+
+ if ($skillInstructions === '') {
+ return $next($payload, $config);
+ }
+
+ // Inject skill instructions into the payload
+ $payload = $this->injectSkillInstructions($payload, $skillInstructions);
+
+ return $next($payload, $config);
+ }
+
+ /**
+ * Build the skill instructions string from active skills.
+ *
+ * @param array $skillNames
+ */
+ protected function buildSkillInstructions(array $skillNames): string
+ {
+ $instructions = [];
+
+ foreach ($skillNames as $name) {
+ if (! $this->registry->has($name)) {
+ continue;
+ }
+
+ $skill = $this->registry->get($name);
+ $instructions[] = sprintf(
+ "\n%s\n ",
+ $skill->name,
+ $skill->instructions,
+ );
+ }
+
+ if ($instructions === []) {
+ return '';
+ }
+
+ return "\n" . implode("\n\n", $instructions) . "\n ";
+ }
+
+ /**
+ * Inject skill instructions into the payload.
+ */
+ protected function injectSkillInstructions(mixed $payload, string $skillInstructions): mixed
+ {
+ if (! is_array($payload)) {
+ return $payload;
+ }
+
+ // Add skill instructions as a variable that can be used in prompts
+ $payload['skill_instructions'] = $skillInstructions;
+
+ // If there are messages in the payload, insert skill instructions after existing system messages
+ if (isset($payload['messages']) && $payload['messages'] instanceof MessageCollection) {
+ $payload['messages'] = $this->insertAfterSystemMessages(
+ $payload['messages'],
+ new SystemMessage($skillInstructions),
+ );
+ }
+
+ return $payload;
+ }
+
+ /**
+ * Insert a message after all existing system messages.
+ */
+ protected function insertAfterSystemMessages(
+ MessageCollection $messages,
+ SystemMessage $skillMessage,
+ ): MessageCollection {
+ // Find the index after the last system message
+ $lastSystemIndex = -1;
+
+ foreach ($messages->values() as $index => $message) {
+ if ($message instanceof SystemMessage) {
+ $lastSystemIndex = $index;
+ }
+ }
+
+ // Insert after the last system message, or at the beginning if none exist
+ $insertPosition = $lastSystemIndex + 1;
+
+ // Split the collection and insert the skill message
+ $before = $messages->slice(0, $insertPosition);
+ $after = $messages->slice($insertPosition);
+
+ return $before->push($skillMessage)->merge($after);
+ }
+}
diff --git a/src/Agents/Prebuilt/GenericAgentBuilder.php b/src/Agents/Prebuilt/GenericAgentBuilder.php
index a23b8db..884c852 100644
--- a/src/Agents/Prebuilt/GenericAgentBuilder.php
+++ b/src/Agents/Prebuilt/GenericAgentBuilder.php
@@ -18,11 +18,11 @@ class GenericAgentBuilder extends AbstractAgentBuilder
{
use SetsAgentProperties;
- protected static string $name = 'generic_agent';
+ protected static string $id = 'generic_agent';
- public static function name(): string
+ public static function id(): string
{
- return static::$name;
+ return static::$id;
}
public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string
@@ -82,9 +82,9 @@ public function middleware(): array
return $this->middleware;
}
- public function withName(string $name): self
+ public function withId(string $id): self
{
- static::$name = $name;
+ static::$id = $id;
return $this;
}
diff --git a/src/Agents/Prebuilt/SkillsAgent.php b/src/Agents/Prebuilt/SkillsAgent.php
new file mode 100644
index 0000000..8f0b790
--- /dev/null
+++ b/src/Agents/Prebuilt/SkillsAgent.php
@@ -0,0 +1,183 @@
+
+ */
+ protected array $autoActivateSkills = [];
+
+ public function __construct(
+ protected ?string $skillsDirectory = null,
+ protected ?LLM $llm = null,
+ ) {}
+
+ public static function id(): string
+ {
+ return 'skills_agent';
+ }
+
+ public function name(): ?string
+ {
+ return 'Skills Agent';
+ }
+
+ public function description(): ?string
+ {
+ return 'An agent that can discover, read, and use skills from SKILL.md files to accomplish tasks.';
+ }
+
+ public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string
+ {
+ return Cortex::prompt([
+ new SystemMessage(
+ <<<'INSTRUCTIONS'
+ You are a helpful assistant with access to skills that provide specialized instructions for various tasks.
+
+ ## Available Tools
+
+ You have access to the following skill-related tools:
+ - `list_skills`: List all available skills with their names and descriptions
+ - `read_skill`: Read a skill's full instructions by name
+ - `use_skill`: Activate a skill to follow its instructions
+
+ ## How to Use Skills
+
+ 1. When the user asks for help with a task, first use `list_skills` to see what skills are available
+ 2. If a relevant skill exists, use `read_skill` to understand its instructions
+ 3. Use `use_skill` to activate the skill and follow its instructions to complete the task
+ 4. If no relevant skill exists, help the user with your general knowledge
+
+ ## Guidelines
+
+ - Always check available skills before attempting a task that might have a specialized skill
+ - Follow skill instructions carefully when a skill is activated
+ - Be transparent about which skill you're using to help the user
+ - If a skill's instructions conflict with the user's request, clarify with the user
+ INSTRUCTIONS,
+ ),
+ ]);
+ }
+
+ public function llm(): LLM|string|null
+ {
+ return $this->llm ?? Cortex::llm('lmstudio', 'openai/gpt-oss-20b')->ignoreFeatures();
+ }
+
+ #[Override]
+ public function tools(): array|ToolKit
+ {
+ return $this->getToolkit();
+ }
+
+ /**
+ * @return array
+ */
+ #[Override]
+ public function middleware(): array
+ {
+ return [
+ new SkillMiddleware($this->getRegistry(), $this->autoActivateSkills),
+ ];
+ }
+
+ #[Override]
+ public function maxSteps(): int
+ {
+ return 10;
+ }
+
+ /**
+ * Set the skills directory to load skills from.
+ */
+ public function withSkillsDirectory(string $directory): self
+ {
+ $this->skillsDirectory = $directory;
+ $this->registry = null;
+ $this->toolkit = null;
+
+ return $this;
+ }
+
+ /**
+ * Set a custom skill registry.
+ */
+ public function withRegistry(SkillRegistry $registry): self
+ {
+ $this->registry = $registry;
+ $this->toolkit = null;
+
+ return $this;
+ }
+
+ /**
+ * Set skills to automatically activate.
+ *
+ * @param array $skillNames
+ */
+ public function withAutoActivateSkills(array $skillNames): self
+ {
+ $this->autoActivateSkills = $skillNames;
+
+ return $this;
+ }
+
+ /**
+ * Set the LLM to use.
+ */
+ public function withLLM(LLM|string $llm): self
+ {
+ $this->llm = is_string($llm) ? Cortex::llm($llm) : $llm;
+
+ return $this;
+ }
+
+ /**
+ * Get the skill registry, creating it if necessary.
+ */
+ protected function getRegistry(): SkillRegistry
+ {
+ if ($this->registry === null) {
+ $this->registry = new SkillRegistry();
+
+ if ($this->skillsDirectory !== null) {
+ $this->registry->registerFromDirectory($this->skillsDirectory);
+ }
+ }
+
+ return $this->registry;
+ }
+
+ /**
+ * Get the skill toolkit, creating it if necessary.
+ */
+ protected function getToolkit(): SkillToolKit
+ {
+ if ($this->toolkit === null) {
+ $this->toolkit = new SkillToolKit($this->getRegistry());
+ }
+
+ return $this->toolkit;
+ }
+}
diff --git a/src/Agents/Prebuilt/WeatherAgent.php b/src/Agents/Prebuilt/WeatherAgent.php
index 64858e7..7cac370 100644
--- a/src/Agents/Prebuilt/WeatherAgent.php
+++ b/src/Agents/Prebuilt/WeatherAgent.php
@@ -11,16 +11,26 @@
use Cortex\Agents\AbstractAgentBuilder;
use Cortex\LLM\Data\Messages\SystemMessage;
use Cortex\Prompts\Builders\ChatPromptBuilder;
-use Cortex\Tools\Prebuilt\OpenMeteoWeatherTool;
use Cortex\Prompts\Templates\ChatPromptTemplate;
+use Cortex\Tools\Prebuilt\GetCurrentWeatherTool;
class WeatherAgent extends AbstractAgentBuilder
{
- public static function name(): string
+ public static function id(): string
{
return 'weather';
}
+ public function name(): ?string
+ {
+ return 'Weather Assistant';
+ }
+
+ public function description(): ?string
+ {
+ return 'A helpful weather assistant that provides accurate weather information and can help planning activities based on the weather.';
+ }
+
public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string
{
return Cortex::prompt([
@@ -35,8 +45,9 @@ public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string
- Keep responses concise but informative
- If the user asks for activities and provides the weather forecast, suggest activities based on the weather forecast.
- If the user asks for activities, respond in the format they request.
+ - Respond in sentences, you don't need to show the weather data, since it's handled with the tool output.
- Use the get_weather tool to fetch current weather data.
+ Use the `get_weather` tool to fetch current weather data.
INSTRUCTIONS,
),
]);
@@ -44,16 +55,16 @@ public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string
public function llm(): LLM|string|null
{
- // return Cortex::llm('ollama', 'gpt-oss:20b')->ignoreFeatures();
- // return Cortex::llm('openai', 'gpt-4.1-mini')->ignoreFeatures();
return Cortex::llm('lmstudio', 'openai/gpt-oss-20b')->ignoreFeatures();
+ // return Cortex::llm('anthropic', 'claude-haiku-4-5')->ignoreFeatures();
+ // return Cortex::llm('openai', 'gpt-5-mini')->ignoreFeatures();
}
#[Override]
public function tools(): array|ToolKit
{
return [
- OpenMeteoWeatherTool::class,
+ GetCurrentWeatherTool::class,
];
}
}
diff --git a/src/Agents/Registry.php b/src/Agents/Registry.php
index 3a4fec3..eb06da8 100644
--- a/src/Agents/Registry.php
+++ b/src/Agents/Registry.php
@@ -20,7 +20,7 @@ final class Registry
*
* @param \Cortex\Agents\Agent|class-string<\Cortex\Agents\AbstractAgentBuilder> $agent
*/
- public function register(Agent|string $agent, ?string $nameOverride = null): void
+ public function register(Agent|string $agent, ?string $idOverride = null): void
{
if (is_string($agent)) {
if (! class_exists($agent)) {
@@ -40,30 +40,30 @@ public function register(Agent|string $agent, ?string $nameOverride = null): voi
);
}
- $name = $agent::name();
+ $id = $agent::id();
} else {
- $name = $agent->getName();
+ $id = $agent->getId();
}
- $this->agents[$nameOverride ?? $name] = $agent;
+ $this->agents[$idOverride ?? $id] = $agent;
}
/**
- * Get an agent instance by name.
+ * Get an agent instance by id.
*
* @param array $parameters
*
* @throws \InvalidArgumentException
*/
- public function get(string $name, array $parameters = []): Agent
+ public function get(string $id, array $parameters = []): Agent
{
- if (! isset($this->agents[$name])) {
+ if (! isset($this->agents[$id])) {
throw new InvalidArgumentException(
- sprintf('Agent [%s] not found.', $name),
+ sprintf('Agent [%s] not found.', $id),
);
}
- $agent = $this->agents[$name];
+ $agent = $this->agents[$id];
if ($agent instanceof Agent) {
return $agent;
@@ -76,18 +76,38 @@ public function get(string $name, array $parameters = []): Agent
/**
* Check if an agent is registered.
*/
- public function has(string $name): bool
+ public function has(string $id): bool
{
- return isset($this->agents[$name]);
+ return isset($this->agents[$id]);
}
/**
- * Get all registered agent names.
+ * Get all registered agent ids.
*
* @return array
*/
- public function names(): array
+ public function ids(): array
{
return array_keys($this->agents);
}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return collect($this->agents)
+ ->map(function (string|Agent $agent): Agent {
+ return $agent instanceof Agent ? $agent : $agent::make();
+ })
+ ->map(function (Agent $agent): array {
+ return [
+ 'id' => $agent->getId(),
+ 'name' => $agent->getName(),
+ 'description' => $agent->getDescription(),
+ ];
+ })
+ ->values()
+ ->toArray();
+ }
}
diff --git a/src/Agents/Stages/HandleToolCalls.php b/src/Agents/Stages/HandleToolCalls.php
index 528cd3e..a38c6bb 100644
--- a/src/Agents/Stages/HandleToolCalls.php
+++ b/src/Agents/Stages/HandleToolCalls.php
@@ -18,6 +18,7 @@
use Cortex\LLM\Data\ChatStreamResult;
use Cortex\LLM\Data\ChatGenerationChunk;
use Cortex\LLM\Data\Messages\ToolMessage;
+use Cortex\LLM\Data\Messages\AssistantMessage;
class HandleToolCalls implements Pipeable
{
@@ -39,7 +40,10 @@ public function __construct(
public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed
{
return match (true) {
- $payload instanceof ChatGenerationChunk && $payload->type === ChunkType::ToolInputEnd => $this->handleStreamingChunk($payload, $config, $next),
+ // We trigger on isFinal (not ToolInputEnd) so that AddMessageToMemoryMiddleware
+ // has already added the assistant message before we process tool calls.
+ // We must check that message is AssistantMessage since ToolMessage doesn't have hasToolCalls()
+ $payload instanceof ChatGenerationChunk && $payload->isFinal && $payload->message instanceof AssistantMessage && $payload->message->hasToolCalls() => $this->handleStreamingChunk($payload, $config, $next),
$payload instanceof ChatStreamResult => $this->handleStreamingResult($payload),
default => $this->handleNonStreaming($payload, $config, $next),
};
@@ -58,15 +62,28 @@ protected function handleStreamingChunk(ChatGenerationChunk $chunk, RuntimeConfi
if ($nestedPayload !== null) {
// Return stream with ToolInputEnd chunk + nested stream
- // AbstractLLM will yield from this stream
- return new ChatStreamResult(function () use ($processedChunk, $nestedPayload): Generator {
+ // We need to recursively process nested chunks through handlePipeable
+ // to handle any subsequent tool calls in the nested stream
+ return new ChatStreamResult(function () use ($processedChunk, $nestedPayload, $config, $next): Generator {
if ($processedChunk instanceof ChatGenerationChunk) {
yield $processedChunk;
}
if ($nestedPayload instanceof ChatStreamResult) {
foreach ($nestedPayload as $nestedChunk) {
- yield $nestedChunk;
+ // Recursively process nested chunks to handle any tool calls
+ // This is critical for multi-step tool calling during streaming
+ $processedNestedChunk = $this->handlePipeable($nestedChunk, $config, $next);
+
+ // If the processed chunk is itself a stream (from recursive tool call handling),
+ // yield from it to flatten the stream
+ if ($processedNestedChunk instanceof ChatStreamResult) {
+ foreach ($processedNestedChunk as $recursiveChunk) {
+ yield $recursiveChunk;
+ }
+ } else {
+ yield $processedNestedChunk;
+ }
}
}
});
@@ -158,7 +175,7 @@ protected function getGeneration(mixed $payload): ChatGeneration|ChatGenerationC
{
return match (true) {
$payload instanceof ChatGeneration => $payload,
- $payload instanceof ChatGenerationChunk && $payload->type === ChunkType::ToolInputEnd => $payload,
+ $payload instanceof ChatGenerationChunk && $payload->isFinal && $payload->message instanceof AssistantMessage && $payload->message->hasToolCalls() => $payload,
$payload instanceof ChatResult => $payload->generation,
default => null,
};
diff --git a/src/Agents/Stages/TrackAgentStart.php b/src/Agents/Stages/TrackAgentStart.php
deleted file mode 100644
index 8b34e3c..0000000
--- a/src/Agents/Stages/TrackAgentStart.php
+++ /dev/null
@@ -1,33 +0,0 @@
-pushChunkWhenStreaming(
- new ChatGenerationChunk(ChunkType::RunStart),
- fn() => $this->agent->dispatchEvent(new AgentStart($this->agent, $config)),
- );
-
- return $next($payload, $config);
- }
-}
diff --git a/src/Console/AgentChat.php b/src/Console/AgentChat.php
index 478b499..7ed223c 100644
--- a/src/Console/AgentChat.php
+++ b/src/Console/AgentChat.php
@@ -60,13 +60,13 @@ protected function getAgent(): ?Agent
} catch (InvalidArgumentException) {
promptsError(sprintf("Agent '%s' not found in registry.", $agentName));
- $availableAgents = AgentRegistry::names();
+ $availableAgents = AgentRegistry::ids();
if (! empty($availableAgents)) {
info('Available agents:');
table(
- headers: ['Name'],
- rows: array_map(fn(string $name): array => [$name], $availableAgents),
+ headers: ['Id'],
+ rows: array_map(fn(string $id): array => [$id], $availableAgents),
);
}
diff --git a/src/Console/ChatPrompt.php b/src/Console/ChatPrompt.php
index 4ed4be8..d936332 100644
--- a/src/Console/ChatPrompt.php
+++ b/src/Console/ChatPrompt.php
@@ -407,6 +407,8 @@ protected function processAgentResponse(string $userInput): void
// Stream response in real-time
foreach ($result as $chunk) {
+ $textSoFar = $chunk->textSoFar();
+
// Handle tool calls in debug mode
if ($this->debug) {
match ($chunk->type) {
@@ -417,9 +419,9 @@ protected function processAgentResponse(string $userInput): void
};
}
- if ($chunk->type === ChunkType::TextDelta && $chunk->contentSoFar !== '') {
- $fullResponse = $chunk->contentSoFar;
- $this->streamingContent = $chunk->contentSoFar;
+ if ($chunk->type === ChunkType::TextDelta && $textSoFar !== null) {
+ $fullResponse = $textSoFar;
+ $this->streamingContent = $textSoFar;
// Auto-scroll to bottom during streaming if enabled
if ($this->autoScroll) {
@@ -430,9 +432,9 @@ protected function processAgentResponse(string $userInput): void
$this->throttledRender();
}
- if ($chunk->isFinal && $chunk->contentSoFar !== '') {
- $fullResponse = $chunk->contentSoFar;
- $this->streamingContent = $chunk->contentSoFar;
+ if ($chunk->isFinal && $textSoFar !== null) {
+ $fullResponse = $textSoFar;
+ $this->streamingContent = $textSoFar;
// Force render for final chunk (not throttled)
$this->render();
}
diff --git a/src/Console/ChatRenderer.php b/src/Console/ChatRenderer.php
index 7488b31..0817e3c 100644
--- a/src/Console/ChatRenderer.php
+++ b/src/Console/ChatRenderer.php
@@ -54,7 +54,7 @@ public function __invoke(ChatPrompt $prompt): string
protected function drawHeader(ChatPrompt $prompt): void
{
- $agentName = $prompt->agent?->getName() ?? 'Unknown';
+ $agentName = $prompt->agent?->getId() ?? 'Unknown';
$width = Prompt::terminal()->cols();
$this->line(str_repeat('═', $width));
diff --git a/src/Contracts/ChatMemory.php b/src/Contracts/ChatMemory.php
index db96b22..0efd8ec 100644
--- a/src/Contracts/ChatMemory.php
+++ b/src/Contracts/ChatMemory.php
@@ -50,4 +50,9 @@ public function getVariables(): array;
* Delegates to the underlying store - the store is the source of truth for threadId.
*/
public function getThreadId(): string;
+
+ /**
+ * Set the thread ID for this memory instance.
+ */
+ public function setThreadId(string $threadId): static;
}
diff --git a/src/Cortex.php b/src/Cortex.php
index 416864b..79eacf0 100644
--- a/src/Cortex.php
+++ b/src/Cortex.php
@@ -69,15 +69,15 @@ public static function llm(?string $provider = null, Closure|string|null $model
}
/**
- * Get an agent instance from the registry by name.
+ * Get an agent instance from the registry by id.
*
- * @return ($name is null ? \Cortex\Agents\Prebuilt\GenericAgentBuilder : \Cortex\Agents\Agent)
+ * @return ($id is null ? \Cortex\Agents\Prebuilt\GenericAgentBuilder : \Cortex\Agents\Agent)
*/
- public static function agent(?string $name = null): Agent|GenericAgentBuilder
+ public static function agent(?string $id = null): Agent|GenericAgentBuilder
{
- return $name === null
+ return $id === null
? new GenericAgentBuilder()
- : AgentRegistry::get($name);
+ : AgentRegistry::get($id);
}
/**
diff --git a/src/CortexServiceProvider.php b/src/CortexServiceProvider.php
index 7f9284e..77acb85 100644
--- a/src/CortexServiceProvider.php
+++ b/src/CortexServiceProvider.php
@@ -5,11 +5,14 @@
namespace Cortex;
use Throwable;
+use Monolog\Logger;
use Cortex\LLM\LLMManager;
use Cortex\Agents\Registry;
use Cortex\Console\AgentChat;
use Cortex\LLM\Contracts\LLM;
use Cortex\Mcp\McpServerManager;
+use Monolog\Handler\StreamHandler;
+use Monolog\Formatter\LineFormatter;
use Illuminate\Support\Facades\Blade;
use Cortex\ModelInfo\ModelInfoFactory;
use Spatie\LaravelPackageTools\Package;
@@ -18,7 +21,9 @@
use Cortex\Embeddings\Contracts\Embeddings;
use Cortex\Prompts\Contracts\PromptFactory;
use Illuminate\Contracts\Container\Container;
+use Cortex\Support\Events\InternalEventDispatcher;
use Spatie\LaravelPackageTools\PackageServiceProvider;
+use Cortex\Support\Events\Subscribers\LoggingSubscriber;
class CortexServiceProvider extends PackageServiceProvider
{
@@ -26,7 +31,8 @@ public function configurePackage(Package $package): void
{
$package->name('cortex')
->hasConfigFile()
- ->hasRoutes('api')
+ ->hasRoutes('api', 'web')
+ ->hasViews('cortex')
->hasCommand(AgentChat::class);
}
@@ -47,6 +53,8 @@ public function packageBooted(): void
}
$this->registerBladeDirectives();
+
+ $this->setupLogging();
}
protected function registerBladeDirectives(): void
@@ -147,4 +155,20 @@ protected function registerAgentRegistry(): void
$this->app->singleton('cortex.agent_registry', fn(Container $app): Registry => new Registry());
$this->app->alias('cortex.agent_registry', Registry::class);
}
+
+ protected function setupLogging(): void
+ {
+ if ($this->app->runningUnitTests()) {
+ return;
+ }
+
+ // TODO: This will be configurable.
+ $logger = new Logger('cortex');
+ $handler = new StreamHandler('php://stdout');
+ $handler->setFormatter(new LineFormatter());
+
+ $logger->pushHandler($handler);
+
+ InternalEventDispatcher::instance()->subscribe(new LoggingSubscriber($logger));
+ }
}
diff --git a/src/Events/AgentEnd.php b/src/Events/AgentEnd.php
index 7358a25..1a480bf 100644
--- a/src/Events/AgentEnd.php
+++ b/src/Events/AgentEnd.php
@@ -14,4 +14,18 @@ public function __construct(
public Agent $agent,
public ?RuntimeConfig $config = null,
) {}
+
+ public function eventId(): string
+ {
+ return 'agent.end';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'run_id' => $this->config?->runId,
+ 'thread_id' => $this->config?->threadId,
+ 'agent' => $this->agent->getId(),
+ ];
+ }
}
diff --git a/src/Events/AgentStart.php b/src/Events/AgentStart.php
index 51bca2b..7d21214 100644
--- a/src/Events/AgentStart.php
+++ b/src/Events/AgentStart.php
@@ -14,4 +14,18 @@ public function __construct(
public Agent $agent,
public ?RuntimeConfig $config = null,
) {}
+
+ public function eventId(): string
+ {
+ return 'agent.start';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'run_id' => $this->config?->runId,
+ 'thread_id' => $this->config?->threadId,
+ 'agent' => $this->agent->getId(),
+ ];
+ }
}
diff --git a/src/Events/AgentStepEnd.php b/src/Events/AgentStepEnd.php
index 1dccdd6..0a8dc49 100644
--- a/src/Events/AgentStepEnd.php
+++ b/src/Events/AgentStepEnd.php
@@ -14,4 +14,18 @@ public function __construct(
public Agent $agent,
public ?RuntimeConfig $config = null,
) {}
+
+ public function eventId(): string
+ {
+ return 'agent.step_end';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'run_id' => $this->config?->runId,
+ 'thread_id' => $this->config?->threadId,
+ 'agent' => $this->agent->getId(),
+ ];
+ }
}
diff --git a/src/Events/AgentStepError.php b/src/Events/AgentStepError.php
index ce05c61..85105eb 100644
--- a/src/Events/AgentStepError.php
+++ b/src/Events/AgentStepError.php
@@ -16,4 +16,19 @@ public function __construct(
public Throwable $exception,
public ?RuntimeConfig $config = null,
) {}
+
+ public function eventId(): string
+ {
+ return 'agent.step_error';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'run_id' => $this->config?->runId,
+ 'thread_id' => $this->config?->threadId,
+ 'agent' => $this->agent->getId(),
+ 'error' => $this->exception->getMessage(),
+ ];
+ }
}
diff --git a/src/Events/AgentStepStart.php b/src/Events/AgentStepStart.php
index a98e54b..41246e4 100644
--- a/src/Events/AgentStepStart.php
+++ b/src/Events/AgentStepStart.php
@@ -14,4 +14,18 @@ public function __construct(
public Agent $agent,
public ?RuntimeConfig $config = null,
) {}
+
+ public function eventId(): string
+ {
+ return 'agent.step_start';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'run_id' => $this->config?->runId,
+ 'thread_id' => $this->config?->threadId,
+ 'agent' => $this->agent->getId(),
+ ];
+ }
}
diff --git a/src/Events/AgentStreamChunk.php b/src/Events/AgentStreamChunk.php
index f7c6f5f..d415ad7 100644
--- a/src/Events/AgentStreamChunk.php
+++ b/src/Events/AgentStreamChunk.php
@@ -16,4 +16,18 @@ public function __construct(
public ChatGenerationChunk $chunk,
public ?RuntimeConfig $config = null,
) {}
+
+ public function eventId(): string
+ {
+ return 'agent.stream_chunk';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'agent' => $this->agent->getId(),
+ // 'chunk' => $this->chunk->toArray(),
+ // 'config' => $this->config->toArray(),
+ ];
+ }
}
diff --git a/src/Events/ChatModelEnd.php b/src/Events/ChatModelEnd.php
index a4d9fa7..360c47b 100644
--- a/src/Events/ChatModelEnd.php
+++ b/src/Events/ChatModelEnd.php
@@ -15,4 +15,14 @@ public function __construct(
public LLM $llm,
public ChatResult|ChatStreamResult $result,
) {}
+
+ public function eventId(): string
+ {
+ return 'chat_model.end';
+ }
+
+ public function toArray(): array
+ {
+ return [];
+ }
}
diff --git a/src/Events/ChatModelError.php b/src/Events/ChatModelError.php
index ab60d54..2e483e2 100644
--- a/src/Events/ChatModelError.php
+++ b/src/Events/ChatModelError.php
@@ -18,4 +18,14 @@ public function __construct(
public array $parameters,
public Throwable $exception,
) {}
+
+ public function eventId(): string
+ {
+ return 'chat_model.error';
+ }
+
+ public function toArray(): array
+ {
+ return [];
+ }
}
diff --git a/src/Events/ChatModelStart.php b/src/Events/ChatModelStart.php
index 2d12658..eff1e57 100644
--- a/src/Events/ChatModelStart.php
+++ b/src/Events/ChatModelStart.php
@@ -18,4 +18,16 @@ public function __construct(
public MessageCollection $messages,
public array $parameters = [],
) {}
+
+ public function eventId(): string
+ {
+ return 'chat_model.start';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'parameters' => $this->parameters,
+ ];
+ }
}
diff --git a/src/Events/ChatModelStream.php b/src/Events/ChatModelStream.php
index 37c15b4..0befb79 100644
--- a/src/Events/ChatModelStream.php
+++ b/src/Events/ChatModelStream.php
@@ -14,4 +14,14 @@ public function __construct(
public LLM $llm,
public ChatGenerationChunk $chunk,
) {}
+
+ public function eventId(): string
+ {
+ return 'chat_model.stream';
+ }
+
+ public function toArray(): array
+ {
+ return [];
+ }
}
diff --git a/src/Events/ChatModelStreamEnd.php b/src/Events/ChatModelStreamEnd.php
index e53ae15..51ec278 100644
--- a/src/Events/ChatModelStreamEnd.php
+++ b/src/Events/ChatModelStreamEnd.php
@@ -14,4 +14,14 @@ public function __construct(
public LLM $llm,
public ChatGenerationChunk $chunk,
) {}
+
+ public function eventId(): string
+ {
+ return 'chat_model.stream_end';
+ }
+
+ public function toArray(): array
+ {
+ return [];
+ }
}
diff --git a/src/Events/Contracts/AgentEvent.php b/src/Events/Contracts/AgentEvent.php
index 8c260b3..b4f8f63 100644
--- a/src/Events/Contracts/AgentEvent.php
+++ b/src/Events/Contracts/AgentEvent.php
@@ -6,7 +6,7 @@
use Cortex\Agents\Agent;
-interface AgentEvent
+interface AgentEvent extends CortexEvent
{
public Agent $agent { get; }
}
diff --git a/src/Events/Contracts/ChatModelEvent.php b/src/Events/Contracts/ChatModelEvent.php
index fd4c792..6acf3d0 100644
--- a/src/Events/Contracts/ChatModelEvent.php
+++ b/src/Events/Contracts/ChatModelEvent.php
@@ -6,7 +6,7 @@
use Cortex\LLM\Contracts\LLM;
-interface ChatModelEvent
+interface ChatModelEvent extends CortexEvent
{
public LLM $llm { get; }
}
diff --git a/src/Events/Contracts/CortexEvent.php b/src/Events/Contracts/CortexEvent.php
new file mode 100644
index 0000000..1fef055
--- /dev/null
+++ b/src/Events/Contracts/CortexEvent.php
@@ -0,0 +1,15 @@
+
+ */
+ public function toArray(): array;
+}
diff --git a/src/Events/Contracts/OutputParserEvent.php b/src/Events/Contracts/OutputParserEvent.php
index 427a619..b765ea7 100644
--- a/src/Events/Contracts/OutputParserEvent.php
+++ b/src/Events/Contracts/OutputParserEvent.php
@@ -6,7 +6,7 @@
use Cortex\Contracts\OutputParser;
-interface OutputParserEvent
+interface OutputParserEvent extends CortexEvent
{
public OutputParser $outputParser { get; }
}
diff --git a/src/Events/Contracts/PipelineEvent.php b/src/Events/Contracts/PipelineEvent.php
index 329a660..6615a8a 100644
--- a/src/Events/Contracts/PipelineEvent.php
+++ b/src/Events/Contracts/PipelineEvent.php
@@ -6,7 +6,7 @@
use Cortex\Pipeline;
-interface PipelineEvent
+interface PipelineEvent extends CortexEvent
{
public Pipeline $pipeline { get; }
}
diff --git a/src/Events/Contracts/RuntimeConfigEvent.php b/src/Events/Contracts/RuntimeConfigEvent.php
index 8a7e903..da41aed 100644
--- a/src/Events/Contracts/RuntimeConfigEvent.php
+++ b/src/Events/Contracts/RuntimeConfigEvent.php
@@ -6,7 +6,7 @@
use Cortex\Pipeline\RuntimeConfig;
-interface RuntimeConfigEvent
+interface RuntimeConfigEvent extends CortexEvent
{
public RuntimeConfig $config { get; }
}
diff --git a/src/Events/Contracts/StageEvent.php b/src/Events/Contracts/StageEvent.php
index bf5ce5e..2cf64a5 100644
--- a/src/Events/Contracts/StageEvent.php
+++ b/src/Events/Contracts/StageEvent.php
@@ -8,7 +8,7 @@
use Cortex\Pipeline;
use Cortex\Contracts\Pipeable;
-interface StageEvent
+interface StageEvent extends CortexEvent
{
public Pipeline $pipeline { get; }
diff --git a/src/Events/Contracts/ToolCallEvent.php b/src/Events/Contracts/ToolCallEvent.php
new file mode 100644
index 0000000..68a0fc4
--- /dev/null
+++ b/src/Events/Contracts/ToolCallEvent.php
@@ -0,0 +1,7 @@
+ $this->config->runId,
+ 'thread_id' => $this->config->threadId,
+ 'stage' => $this->stage::class,
+ ];
+ }
}
diff --git a/src/Events/StageError.php b/src/Events/StageError.php
index 1790f7e..fd97261 100644
--- a/src/Events/StageError.php
+++ b/src/Events/StageError.php
@@ -20,4 +20,14 @@ public function __construct(
public RuntimeConfig $config,
public Throwable $exception,
) {}
+
+ public function eventId(): string
+ {
+ return 'stage.error';
+ }
+
+ public function toArray(): array
+ {
+ return [];
+ }
}
diff --git a/src/Events/StageStart.php b/src/Events/StageStart.php
index 1379585..f5ab81f 100644
--- a/src/Events/StageStart.php
+++ b/src/Events/StageStart.php
@@ -18,4 +18,18 @@ public function __construct(
public mixed $payload,
public RuntimeConfig $config,
) {}
+
+ public function eventId(): string
+ {
+ return 'stage.start';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'run_id' => $this->config->runId,
+ 'thread_id' => $this->config->threadId,
+ 'stage' => $this->stage::class,
+ ];
+ }
}
diff --git a/src/Events/ToolCallEnd.php b/src/Events/ToolCallEnd.php
new file mode 100644
index 0000000..da1c01d
--- /dev/null
+++ b/src/Events/ToolCallEnd.php
@@ -0,0 +1,31 @@
+ $this->config?->runId,
+ 'thread_id' => $this->config?->threadId,
+ 'tool_message' => $this->toolMessage->toArray(),
+ ];
+ }
+}
diff --git a/src/Events/ToolCallStart.php b/src/Events/ToolCallStart.php
new file mode 100644
index 0000000..ae521fd
--- /dev/null
+++ b/src/Events/ToolCallStart.php
@@ -0,0 +1,31 @@
+ $this->config?->runId,
+ 'thread_id' => $this->config?->threadId,
+ 'tool_call' => $this->toolCall->toArray(),
+ ];
+ }
+}
diff --git a/src/Facades/AgentRegistry.php b/src/Facades/AgentRegistry.php
index 9bb47ed..fb42238 100644
--- a/src/Facades/AgentRegistry.php
+++ b/src/Facades/AgentRegistry.php
@@ -7,10 +7,10 @@
use Illuminate\Support\Facades\Facade;
/**
- * @method static \Cortex\Agents\Agent get(string $name)
+ * @method static \Cortex\Agents\Agent get(string $id)
* @method static void register(\Cortex\Agents\Agent|class-string<\Cortex\Agents\AbstractAgentBuilder> $agent, ?string $nameOverride = null)
- * @method static bool has(string $name)
- * @method static array names()
+ * @method static bool has(string $id)
+ * @method static array ids()
*
* @see \Cortex\Agents\Registry
*/
diff --git a/src/Http/Controllers/AGUIController.php b/src/Http/Controllers/AGUIController.php
new file mode 100644
index 0000000..7097845
--- /dev/null
+++ b/src/Http/Controllers/AGUIController.php
@@ -0,0 +1,47 @@
+all());
+
+ $messages = $request->collect('messages')
+ ->map(function (array $message): UserMessage {
+ return new UserMessage(
+ content: $message['content'],
+ id: $message['id'] ?? null,
+ );
+ });
+
+ try {
+ return Cortex::agent('weather')
+ ->stream(
+ messages: $messages->all(),
+ input: $request->all(),
+ config: new RuntimeConfig(
+ state: new State($request->input('state', [])),
+ threadId: $request->input('thread_id'),
+ runId: $request->input('run_id'),
+ ),
+ )
+ ->streamResponse(StreamingProtocol::AGUI);
+ } catch (Throwable $e) {
+ dd($e);
+ }
+ }
+}
diff --git a/src/Http/Controllers/AgentsController.php b/src/Http/Controllers/AgentsController.php
index 99ff7ee..1fd3067 100644
--- a/src/Http/Controllers/AgentsController.php
+++ b/src/Http/Controllers/AgentsController.php
@@ -6,15 +6,20 @@
use Throwable;
use Cortex\Cortex;
-use Cortex\Events\AgentEnd;
+use Illuminate\Support\Arr;
use Illuminate\Http\Request;
-use Cortex\Events\AgentStart;
-use Cortex\Events\AgentStepEnd;
-use Cortex\Events\AgentStepError;
-use Cortex\Events\AgentStepStart;
+use Cortex\Pipeline\Metadata;
+use Cortex\Tools\FrontendTool;
+use Cortex\LLM\Contracts\Content;
use Illuminate\Http\JsonResponse;
+use Cortex\Pipeline\RuntimeConfig;
use Illuminate\Routing\Controller;
+use Cortex\LLM\Enums\StreamingProtocol;
use Cortex\LLM\Data\Messages\UserMessage;
+use Cortex\LLM\Data\Messages\Content\FileContent;
+use Cortex\LLM\Data\Messages\Content\TextContent;
+use Cortex\LLM\Data\Messages\Content\ImageContent;
+use Symfony\Component\HttpFoundation\StreamedResponse;
class AgentsController extends Controller
{
@@ -22,31 +27,18 @@ public function invoke(string $agent, Request $request): JsonResponse
{
try {
$agent = Cortex::agent($agent);
- $agent->onStart(function (AgentStart $event): void {
- // dump('-- agent start');
- });
- $agent->onEnd(function (AgentEnd $event): void {
- // dump('-- agent end');
- });
-
- $agent->onStepStart(function (AgentStepStart $event): void {
- // dump(
- // sprintf('---- step %d start', $event->config?->context?->getCurrentStepNumber()),
- // // $event->config?->context->toArray(),
- // );
- });
- $agent->onStepEnd(function (AgentStepEnd $event): void {
- // dump(
- // sprintf('---- step %d end', $event->config?->context?->getCurrentStepNumber()),
- // // $event->config?->toArray(),
- // );
- });
- $agent->onStepError(function (AgentStepError $event): void {
- // dump(sprintf('step error: %d', $event->config?->context?->getCurrentStepNumber()));
- // dump($event->exception->getMessage());
- // dump($event->exception->getTraceAsString());
- });
- $result = $agent->invoke(input: $request->all());
+
+ $result = $agent->invoke(
+ messages: $request->has('message') ? [
+ new UserMessage($request->input('message')),
+ ] : [],
+ input: $request->input('input', []),
+ config: new RuntimeConfig(
+ metadata: new Metadata($request->input('metadata', [])),
+ threadId: $request->input('id'),
+ runId: $request->input('run_id'),
+ ),
+ );
} catch (Throwable $e) {
return response()->json([
'error' => $e->getMessage(),
@@ -71,58 +63,86 @@ public function invoke(string $agent, Request $request): JsonResponse
]);
}
- public function stream(string $agent, Request $request): void// : StreamedResponse
+ public function stream(string $agent, Request $request): StreamedResponse
{
- $agent = Cortex::agent($agent);
-
- // $agent->onStart(function (AgentStart $event): void {
- // dump('---- AGENT START ----');
- // });
- // $agent->onEnd(function (AgentEnd $event): void {
- // dump('---- AGENT END ----');
- // });
- // $agent->onStepStart(function (AgentStepStart $event): void {
- // dump('-- STEP START --');
- // });
- // $agent->onStepEnd(function (AgentStepEnd $event): void {
- // dump('-- STEP END --');
- // });
- // $agent->onStepError(function (AgentStepError $event): void {
- // dump('-- STEP ERROR --');
- // });
- // $agent->onChunk(function (AgentStreamChunk $event): void {
- // dump($event->chunk->type->value);
- // $toolCalls = $event->chunk->message->toolCalls;
-
- // if ($toolCalls !== null) {
- // dump(sprintf('chunk: %s', $event->chunk->message->toolCalls?->toJson()));
- // } else {
- // dump(sprintf('chunk: %s', $event->chunk->message->content));
- // }
- // });
-
- $result = $agent->stream(
- messages: $request->has('message') ? [
- new UserMessage($request->input('message')),
- ] : [],
- input: $request->all(),
- );
-
try {
- foreach ($result as $chunk) {
- dump(sprintf('%s: %s', $chunk->type->value, $chunk->message->content()));
+ $agent = Cortex::agent($agent);
+
+ if ($request->isMethod('post')) {
+ $messages = $this->getMessages($request);
+ } else {
+ $messages = $request->has('message') ? [
+ new UserMessage($request->input('message')),
+ ] : [];
}
- // return $result->streamResponse();
+ /** @var array> $toolsInput */
+ $toolsInput = $request->input('tools', []);
+ $tools = collect($toolsInput)
+ ->filter(function (array $tool): bool {
+ return $tool !== [];
+ })
+ ->map(function (array $tool, string $toolName): FrontendTool {
+ return new FrontendTool(
+ $toolName,
+ $tool['description'] ?? null,
+ $tool['parameters'] ?? [],
+ );
+ })
+ ->values();
+
+ $result = $agent->stream(
+ messages: $messages,
+ input: $request->input('input', []),
+ config: new RuntimeConfig(
+ tools: $tools->all(),
+ // temporary hack to get a unique thread id for the session
+ threadId: $request->input('id') . '-' . Arr::get($request->collect('messages')->first(), 'id', ''),
+ ),
+ );
+
+ /** @var StreamingProtocol $defaultProtocol */
+ $defaultProtocol = config('cortex.default_streaming_protocol') ?? StreamingProtocol::Vercel;
+
+ return $result->streamResponse(
+ $request->enum('protocol', StreamingProtocol::class, $defaultProtocol),
+ );
} catch (Throwable $e) {
dd($e);
}
- dd([
- 'total_usage' => $agent->getTotalUsage()->toArray(),
- 'steps' => $agent->getSteps()->toArray(),
- 'parsed_output' => $agent->getParsedOutput(),
- 'memory' => $agent->getMemory()->getMessages()->toArray(),
- ]);
+ // dd([
+ // 'total_usage' => $agent->getTotalUsage()->toArray(),
+ // 'steps' => $agent->getSteps()->toArray(),
+ // 'parsed_output' => $agent->getParsedOutput(),
+ // 'memory' => $agent->getMemory()->getMessages()->toArray(),
+ // ]);
+ }
+
+ /**
+ * @return array
+ */
+ protected function getMessages(Request $request): array
+ {
+ return $request->collect('messages')
+ ->map(function (array $message): UserMessage {
+ /** @var array> $parts */
+ $parts = $message['parts'];
+ $content = collect($parts)
+ ->map(function (array $part): ?Content {
+ return match (true) {
+ $part['type'] === 'text' => new TextContent($part['text']),
+ $part['type'] === 'file' && str_starts_with((string) $part['mediaType'], 'image/') => new ImageContent($part['url'], $part['mediaType'] ?? null),
+ $part['type'] === 'file' => new FileContent($part['url'], $part['mediaType'] ?? null, $part['filename'] ?? null),
+ default => null,
+ };
+ })
+ ->filter()
+ ->values()
+ ->all();
+
+ return new UserMessage($content, id: $message['id'] ?? null);
+ })
+ ->all();
}
}
diff --git a/src/LLM/AbstractLLM.php b/src/LLM/AbstractLLM.php
index c9c4a36..d5f47c4 100644
--- a/src/LLM/AbstractLLM.php
+++ b/src/LLM/AbstractLLM.php
@@ -6,6 +6,7 @@
use Closure;
use Generator;
+use Throwable;
use BackedEnum;
use Cortex\Pipeline;
use Cortex\Support\Utils;
@@ -16,6 +17,7 @@
use Cortex\LLM\Contracts\Tool;
use Cortex\Events\ChatModelEnd;
use Cortex\LLM\Data\ToolConfig;
+use Cortex\LLM\Enums\ChunkType;
use Cortex\LLM\Enums\ToolChoice;
use Cortex\Events\ChatModelError;
use Cortex\Events\ChatModelStart;
@@ -62,6 +64,10 @@ abstract class AbstractLLM implements LLM
*/
protected array $parameters = [];
+ protected ?int $maxTokens = null;
+
+ protected ?float $temperature = null;
+
protected ?ToolConfig $toolConfig = null;
protected ?StructuredOutputConfig $structuredOutputConfig = null;
@@ -103,6 +109,13 @@ public function __construct(
}
}
+ public function stream(
+ MessageCollection|Message|array|string $messages,
+ array $additionalParameters = [],
+ ): ChatStreamResult {
+ return $this->withStreaming(true)->invoke($messages, $additionalParameters);
+ }
+
public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed
{
$this->shouldParseOutput($config->context->shouldParseOutput());
@@ -136,14 +149,18 @@ public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $n
// Otherwise, we return the message as is.
return $result instanceof ChatStreamResult
? new ChatStreamResult(function () use ($result, $config, $next) {
- foreach ($result as $chunk) {
- try {
- $chunk = $next($chunk, $config);
-
- yield from $this->flattenAndYield($chunk, $config, dispatchEvents: true);
- } catch (OutputParserException) {
- // Ignore any parsing errors and continue
+ try {
+ foreach ($result as $chunk) {
+ try {
+ $chunk = $next($chunk, $config);
+
+ yield from $this->flattenAndYield($chunk, $config, dispatchEvents: true);
+ } catch (OutputParserException) {
+ // Ignore any parsing errors and continue
+ }
}
+ } catch (Throwable $e) {
+ yield from $this->yieldErrorChunk($e, $config);
}
})
: $next($result, $config);
@@ -154,8 +171,12 @@ protected function flattenAndYield(mixed $content, RuntimeConfig $config, bool $
if ($content instanceof ChatStreamResult) {
// When flattening a nested stream, don't dispatch events here
// The inner stream's AbstractLLM already dispatched them
- foreach ($content as $chunk) {
- yield from $this->flattenAndYield($chunk, $config, dispatchEvents: false);
+ try {
+ foreach ($content as $chunk) {
+ yield from $this->flattenAndYield($chunk, $config, dispatchEvents: false);
+ }
+ } catch (Throwable $e) {
+ yield from $this->yieldErrorChunk($e, $config);
}
} else {
$shouldDispatchEvent = $dispatchEvents && $content instanceof ChatGenerationChunk;
@@ -183,6 +204,31 @@ protected function flattenAndYield(mixed $content, RuntimeConfig $config, bool $
}
}
+ /**
+ * Yield an error chunk and re-throw the exception.
+ *
+ * This ensures errors during streaming are output to the stream protocol (e.g., Vercel AI SDK)
+ * instead of being caught by Laravel's exception handler and output as HTML.
+ */
+ protected function yieldErrorChunk(Throwable $e, RuntimeConfig $config): Generator
+ {
+ $config->setException($e);
+
+ $errorChunk = new ChatGenerationChunk(
+ type: ChunkType::Error,
+ exception: $e,
+ );
+
+ $config->dispatchEvent(
+ event: new RuntimeConfigStreamChunk($config, $errorChunk),
+ dispatchToGlobalDispatcher: false,
+ );
+
+ yield $errorChunk;
+
+ throw $e;
+ }
+
public function output(OutputParser $parser): Pipeline
{
return $this->pipe($parser);
@@ -303,14 +349,14 @@ public function withModel(string $model): static
public function withTemperature(?float $temperature): static
{
- $this->parameters['temperature'] = $temperature;
+ $this->temperature = $temperature;
return $this;
}
public function withMaxTokens(?int $maxTokens): static
{
- $this->parameters['max_tokens'] = $maxTokens;
+ $this->maxTokens = $maxTokens;
return $this;
}
@@ -356,6 +402,16 @@ public function getParameters(): array
return $this->parameters;
}
+ public function getTemperature(): ?float
+ {
+ return $this->temperature;
+ }
+
+ public function getMaxTokens(): ?int
+ {
+ return $this->maxTokens;
+ }
+
public function isStreaming(): bool
{
return $this->streaming;
@@ -403,6 +459,16 @@ public function getOutputParserError(): ?string
return $this->outputParserError;
}
+ public function getStructuredOutputConfig(): ?StructuredOutputConfig
+ {
+ return $this->structuredOutputConfig;
+ }
+
+ public function getStructuredOutputMode(): StructuredOutputMode
+ {
+ return $this->structuredOutputMode;
+ }
+
/**
* @return array<\Cortex\ModelInfo\Enums\ModelFeature>
*/
@@ -522,10 +588,8 @@ protected function applyOutputParserIfApplicable(
): ChatGeneration|ChatGenerationChunk {
if ($this->shouldParseOutput && $this->outputParser !== null) {
try {
- // $this->streamBuffer?->push(new ChatGenerationChunk(type: ChunkType::OutputParserStart));
$this->dispatchEvent(new OutputParserStart($this->outputParser, $generationOrChunk));
$parsedOutput = $this->outputParser->parse($generationOrChunk);
- // $this->streamBuffer?->push(new ChatGenerationChunk(type: ChunkType::OutputParserEnd));
$this->dispatchEvent(new OutputParserEnd($this->outputParser, $parsedOutput));
$generationOrChunk = $generationOrChunk->cloneWithParsedOutput($parsedOutput);
diff --git a/src/LLM/Contracts/LLM.php b/src/LLM/Contracts/LLM.php
index 20c02f8..8d18566 100644
--- a/src/LLM/Contracts/LLM.php
+++ b/src/LLM/Contracts/LLM.php
@@ -14,6 +14,7 @@
use Cortex\JsonSchema\Types\ObjectSchema;
use Cortex\ModelInfo\Enums\ModelProvider;
use Cortex\LLM\Enums\StructuredOutputMode;
+use Cortex\LLM\Data\StructuredOutputConfig;
use Cortex\LLM\Data\Messages\MessageCollection;
interface LLM extends Pipeable
@@ -31,6 +32,19 @@ public function invoke(
array $additionalParameters = [],
): ChatResult|ChatStreamResult;
+ /**
+ * Convenience method to stream the LLM response.
+ *
+ * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Contracts\Message|array|string $messages
+ * @param array $additionalParameters
+ *
+ * @return \Cortex\LLM\Data\ChatStreamResult<\Cortex\LLM\Data\ChatGenerationChunk>
+ */
+ public function stream(
+ MessageCollection|Message|array|string $messages,
+ array $additionalParameters = [],
+ ): ChatStreamResult;
+
/**
* Specify the tools to use for the LLM.
*
@@ -151,6 +165,33 @@ public function getModelProvider(): ModelProvider;
*/
public function getModelInfo(): ?ModelInfo;
+ /**
+ * Get the parameters for the LLM.
+ *
+ * @return array
+ */
+ public function getParameters(): array;
+
+ /**
+ * Get the temperature for the LLM.
+ */
+ public function getTemperature(): ?float;
+
+ /**
+ * Get the max tokens for the LLM.
+ */
+ public function getMaxTokens(): ?int;
+
+ /**
+ * Get the structured output config for the LLM.
+ */
+ public function getStructuredOutputConfig(): ?StructuredOutputConfig;
+
+ /**
+ * Get the structured output mode for the LLM.
+ */
+ public function getStructuredOutputMode(): StructuredOutputMode;
+
/**
* Set whether the raw provider response should be included in the result, if available.
*/
diff --git a/src/LLM/Contracts/StreamingProtocol.php b/src/LLM/Contracts/StreamingProtocol.php
deleted file mode 100644
index 1d20116..0000000
--- a/src/LLM/Contracts/StreamingProtocol.php
+++ /dev/null
@@ -1,19 +0,0 @@
-
- */
- public function mapChunkToPayload(ChatGenerationChunk $chunk): array;
-}
diff --git a/src/LLM/Contracts/StreamingProtocolDriver.php b/src/LLM/Contracts/StreamingProtocolDriver.php
new file mode 100644
index 0000000..39226b6
--- /dev/null
+++ b/src/LLM/Contracts/StreamingProtocolDriver.php
@@ -0,0 +1,23 @@
+
+ */
+ public function headers(): array;
+}
diff --git a/src/LLM/Data/ChatGenerationChunk.php b/src/LLM/Data/ChatGenerationChunk.php
index 936bdd0..ac6cc2b 100644
--- a/src/LLM/Data/ChatGenerationChunk.php
+++ b/src/LLM/Data/ChatGenerationChunk.php
@@ -8,10 +8,13 @@
use DateTimeImmutable;
use DateTimeInterface;
use Cortex\LLM\Enums\ChunkType;
+use Cortex\LLM\Contracts\Content;
use Cortex\LLM\Enums\FinishReason;
use Cortex\LLM\Data\Messages\ToolMessage;
use Illuminate\Contracts\Support\Arrayable;
use Cortex\LLM\Data\Messages\AssistantMessage;
+use Cortex\LLM\Data\Messages\Content\TextContent;
+use Cortex\LLM\Data\Messages\Content\ReasoningContent;
/**
* @implements Arrayable
@@ -19,6 +22,7 @@
readonly class ChatGenerationChunk implements Arrayable
{
/**
+ * @param array<\Cortex\LLM\Contracts\Content> $contentSoFar
* @param array|null $rawChunk
* @param array $metadata
*/
@@ -29,7 +33,7 @@ public function __construct(
public DateTimeInterface $createdAt = new DateTimeImmutable(),
public ?FinishReason $finishReason = null,
public ?Usage $usage = null,
- public string $contentSoFar = '',
+ public array $contentSoFar = [],
public bool $isFinal = false,
public mixed $parsedOutput = null,
public ?string $outputParserError = null,
@@ -48,6 +52,50 @@ public function text(): ?string
return $this->message->text();
}
+ public function reasoning(): ?string
+ {
+ return $this->message->reasoning();
+ }
+
+ public function isTextEmpty(): bool
+ {
+ return $this->message->isTextEmpty();
+ }
+
+ public function isReasoningEmpty(): bool
+ {
+ return $this->message->isReasoningEmpty();
+ }
+
+ public function isToolInputEmpty(): bool
+ {
+ return $this->message->isToolInputEmpty();
+ }
+
+ /**
+ * Get the text content that has been streamed so far.
+ */
+ public function textSoFar(): ?string
+ {
+ /** @var \Cortex\LLM\Data\Messages\Content\TextContent|null $textContent */
+ $textContent = collect($this->contentSoFar)
+ ->first(fn(Content $content): bool => $content instanceof TextContent);
+
+ return $textContent?->text;
+ }
+
+ /**
+ * Get the reasoning content that has been streamed so far.
+ */
+ public function reasoningSoFar(): ?string
+ {
+ /** @var \Cortex\LLM\Data\Messages\Content\ReasoningContent|null $reasoningContent */
+ $reasoningContent = collect($this->contentSoFar)
+ ->first(fn(Content $content): bool => $content instanceof ReasoningContent);
+
+ return $reasoningContent?->reasoning;
+ }
+
public function cloneWithParsedOutput(mixed $parsedOutput): self
{
return new self(
diff --git a/src/LLM/Data/ChatResult.php b/src/LLM/Data/ChatResult.php
index 9e01ade..dcdc07c 100644
--- a/src/LLM/Data/ChatResult.php
+++ b/src/LLM/Data/ChatResult.php
@@ -15,11 +15,13 @@
/**
* @param array|null $rawResponse
+ * @param array $metadata
*/
public function __construct(
public ChatGeneration $generation,
public Usage $usage,
public ?array $rawResponse = null,
+ public array $metadata = [],
) {
$this->parsedOutput = $this->generation->parsedOutput;
}
@@ -50,6 +52,7 @@ public function toArray(): array
'generation' => $this->generation,
'usage' => $this->usage,
'raw_response' => $this->rawResponse,
+ 'metadata' => $this->metadata,
];
}
}
diff --git a/src/LLM/Data/ChatStreamResult.php b/src/LLM/Data/ChatStreamResult.php
index 313fa1b..6fb6a8e 100644
--- a/src/LLM/Data/ChatStreamResult.php
+++ b/src/LLM/Data/ChatStreamResult.php
@@ -12,6 +12,7 @@
use Cortex\Events\RuntimeConfigStreamChunk;
use Cortex\LLM\Data\Messages\AssistantMessage;
use Cortex\LLM\Data\Concerns\HasStreamResponses;
+use Cortex\LLM\Data\Messages\Content\TextContent;
/**
* @extends LazyCollection
@@ -29,13 +30,29 @@ public function text(): self
}
/**
- * Stream only chunks where message content is not empty.
+ * Stream only chunks where delta content is not empty.
*/
- public function withoutEmpty(): self
+ public function withoutEmptyDeltas(): self
{
- return $this->reject(fn(ChatGenerationChunk $chunk): bool => $chunk->type->isText() && empty($chunk->content()));
+ return $this->reject(fn(ChatGenerationChunk $chunk): bool => match ($chunk->type) {
+ ChunkType::TextDelta => $chunk->isTextEmpty(),
+ ChunkType::ReasoningDelta => $chunk->isReasoningEmpty(),
+ ChunkType::ToolInputDelta => $chunk->isToolInputEmpty(),
+ default => false,
+ });
+ }
+
+ /**
+ * Stream without reasoning chunks.
+ */
+ public function withoutReasoning(): self
+ {
+ return $this->reject(fn(ChatGenerationChunk $chunk): bool => $chunk->type->isReasoning());
}
+ /**
+ * Append the stream buffer to the result.
+ */
public function appendStreamBuffer(RuntimeConfig $config): self
{
return new self(function () use ($config): Generator {
@@ -93,7 +110,7 @@ public static function fake(?string $string = null, ?ToolCallCollection $toolCal
completionTokens: $index,
totalTokens: $index,
),
- contentSoFar: $contentSoFar,
+ contentSoFar: [new TextContent($contentSoFar)],
isFinal: $isFinal,
);
diff --git a/src/LLM/Data/Concerns/HasStreamResponses.php b/src/LLM/Data/Concerns/HasStreamResponses.php
index 317e2b4..d885e32 100644
--- a/src/LLM/Data/Concerns/HasStreamResponses.php
+++ b/src/LLM/Data/Concerns/HasStreamResponses.php
@@ -4,11 +4,8 @@
namespace Cortex\LLM\Data\Concerns;
-use Cortex\LLM\Streaming\AgUiDataStream;
-use Cortex\LLM\Streaming\VercelDataStream;
-use Cortex\LLM\Streaming\VercelTextStream;
-use Cortex\LLM\Contracts\StreamingProtocol;
-use Cortex\LLM\Streaming\DefaultDataStream;
+use Cortex\LLM\Enums\StreamingProtocol;
+use Cortex\LLM\Contracts\StreamingProtocolDriver;
use Symfony\Component\HttpFoundation\StreamedResponse;
/** @mixin \Cortex\LLM\Data\ChatStreamResult */
@@ -17,49 +14,24 @@ trait HasStreamResponses
/**
* Create a streaming response using the Vercel AI SDK protocol.
*/
- public function streamResponse(): StreamedResponse
+ public function streamResponse(StreamingProtocol $protocol): StreamedResponse
{
- return $this->toStreamedResponse(new DefaultDataStream());
- }
-
- /**
- * Create a plain text streaming response (Vercel AI SDK text format).
- * Streams only the text content without any JSON encoding or metadata.
- *
- * @see https://sdk.vercel.ai/docs/ai-sdk-core/generating-text
- */
- public function vercelTextStreamResponse(): StreamedResponse
- {
- return $this->toStreamedResponse(new VercelTextStream());
- }
-
- public function vercelDataStreamResponse(): StreamedResponse
- {
- return $this->toStreamedResponse(new VercelDataStream());
- }
-
- /**
- * Create a streaming response using the AG-UI protocol.
- *
- * @see https://docs.ag-ui.com/concepts/events.md
- */
- public function agUiStreamResponse(): StreamedResponse
- {
- return $this->toStreamedResponse(new AgUiDataStream());
+ return $this->toStreamedResponse($protocol->driver());
}
/**
* Create a streaming response using a custom streaming protocol.
*/
- public function toStreamedResponse(StreamingProtocol $protocol): StreamedResponse
+ public function toStreamedResponse(StreamingProtocolDriver $driver): StreamedResponse
{
/** @var \Illuminate\Routing\ResponseFactory $responseFactory */
$responseFactory = response();
- return $responseFactory->stream($protocol->streamResponse($this), headers: [
+ return $responseFactory->stream($driver->streamResponse($this), headers: [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
+ ...$driver->headers(),
]);
}
}
diff --git a/src/LLM/Data/FunctionCall.php b/src/LLM/Data/FunctionCall.php
index d838c4e..d43c6b5 100644
--- a/src/LLM/Data/FunctionCall.php
+++ b/src/LLM/Data/FunctionCall.php
@@ -17,6 +17,7 @@
public function __construct(
public string $name,
public array $arguments,
+ public ?string $delta = null,
) {}
public function toArray(): array
@@ -24,6 +25,7 @@ public function toArray(): array
return [
'name' => $this->name,
'arguments' => $this->arguments,
+ 'delta' => $this->delta,
];
}
}
diff --git a/src/LLM/Data/Messages/AssistantMessage.php b/src/LLM/Data/Messages/AssistantMessage.php
index f8e2b51..14fdabe 100644
--- a/src/LLM/Data/Messages/AssistantMessage.php
+++ b/src/LLM/Data/Messages/AssistantMessage.php
@@ -43,11 +43,17 @@ public function content(): string|array|null
public function text(): ?string
{
if (is_array($this->content)) {
+ $texts = [];
+
foreach ($this->content as $content) {
if ($content instanceof TextContent) {
- return $content->text;
+ $texts[] = $content->text;
}
}
+
+ return $texts !== []
+ ? implode(PHP_EOL, $texts)
+ : null;
}
return is_string($this->content)
@@ -55,29 +61,45 @@ public function text(): ?string
: null;
}
- public function isTextEmpty(): bool
- {
- $text = $this->text();
-
- return $text === null || $text === '';
- }
-
/**
* Get the reasoning content of the message.
*/
public function reasoning(): ?string
{
if (is_array($this->content)) {
+ $reasonings = [];
+
foreach ($this->content as $content) {
if ($content instanceof ReasoningContent) {
- return $content->reasoning;
+ $reasonings[] = $content->reasoning;
}
}
+
+ return $reasonings !== []
+ ? implode(PHP_EOL, $reasonings)
+ : null;
}
return null;
}
+ public function isTextEmpty(): bool
+ {
+ return in_array($this->text(), [null, ''], true);
+ }
+
+ public function isReasoningEmpty(): bool
+ {
+ return in_array($this->reasoning(), [null, ''], true);
+ }
+
+ public function isToolInputEmpty(): bool
+ {
+ $toolCall = $this->toolCalls?->first();
+
+ return in_array($toolCall->function->arguments ?? null, [null, ''], true);
+ }
+
/**
* Determine if the message has tool calls.
*/
diff --git a/src/LLM/Data/Messages/Content/ReasoningContent.php b/src/LLM/Data/Messages/Content/ReasoningContent.php
index b5b49e3..87a1016 100644
--- a/src/LLM/Data/Messages/Content/ReasoningContent.php
+++ b/src/LLM/Data/Messages/Content/ReasoningContent.php
@@ -6,8 +6,18 @@
final class ReasoningContent extends AbstractContent
{
+ /**
+ * @param array $metadata
+ */
public function __construct(
- public string $id,
public string $reasoning,
+ public array $metadata = [],
) {}
+
+ public function append(string $reasoning): self
+ {
+ $this->reasoning .= $reasoning;
+
+ return $this;
+ }
}
diff --git a/src/LLM/Data/Messages/Content/TextContent.php b/src/LLM/Data/Messages/Content/TextContent.php
index 4611bf9..c0d77e8 100644
--- a/src/LLM/Data/Messages/Content/TextContent.php
+++ b/src/LLM/Data/Messages/Content/TextContent.php
@@ -27,4 +27,15 @@ public function replaceVariables(array $variables): static
return new self($this->getCompiler()->compile($this->text, $variables));
}
+
+ public function append(string $text): self
+ {
+ if ($this->text === null) {
+ return new self($text);
+ }
+
+ $this->text .= $text;
+
+ return $this;
+ }
}
diff --git a/src/LLM/Data/Messages/UserMessage.php b/src/LLM/Data/Messages/UserMessage.php
index 0bff5ed..d678e5e 100644
--- a/src/LLM/Data/Messages/UserMessage.php
+++ b/src/LLM/Data/Messages/UserMessage.php
@@ -21,6 +21,7 @@
*/
public function __construct(
public string|array $content,
+ public ?string $id = null,
public ?string $name = null,
) {
$this->role = MessageRole::User;
@@ -61,6 +62,10 @@ public function toArray(): array
'content' => $this->content,
];
+ if ($this->id !== null) {
+ $data['id'] = $this->id;
+ }
+
if ($this->name !== null) {
$data['name'] = $this->name;
}
@@ -70,7 +75,7 @@ public function toArray(): array
public function cloneWithContent(mixed $content): self
{
- return new self($content, $this->name);
+ return new self($content, $this->id, $this->name);
}
/**
diff --git a/src/LLM/Data/ToolCallCollection.php b/src/LLM/Data/ToolCallCollection.php
index 8958c59..a45f6a6 100644
--- a/src/LLM/Data/ToolCallCollection.php
+++ b/src/LLM/Data/ToolCallCollection.php
@@ -4,7 +4,9 @@
namespace Cortex\LLM\Data;
+use Cortex\Events\ToolCallEnd;
use Cortex\LLM\Contracts\Tool;
+use Cortex\Events\ToolCallStart;
use Cortex\Pipeline\RuntimeConfig;
use Illuminate\Support\Collection;
use Cortex\LLM\Data\Messages\MessageCollection;
@@ -37,7 +39,7 @@ public function invokeAsToolMessages(Collection $availableTools, ?RuntimeConfig
if ($matchingTool === null) {
// If we didn't find a matching tool, and there is only one tool and
// one tool call, we will assume it's the correct tool.
- if ($availableTools->containsOneItem() && $this->containsOneItem()) {
+ if ($availableTools->hasSole() && $this->hasSole()) {
/** @var \Cortex\LLM\Contracts\Tool $matchingTool */
$matchingTool = $availableTools->first();
} else {
@@ -46,7 +48,11 @@ public function invokeAsToolMessages(Collection $availableTools, ?RuntimeConfig
}
}
- return $matchingTool->invokeAsToolMessage($toolCall, $config);
+ $config->dispatchEvent(new ToolCallStart($toolCall, $config));
+ $toolMessage = $matchingTool->invokeAsToolMessage($toolCall, $config);
+ $config->dispatchEvent(new ToolCallEnd($toolMessage, $config));
+
+ return $toolMessage;
})
->filter()
->values()
diff --git a/src/LLM/Drivers/Anthropic/AnthropicChat.php b/src/LLM/Drivers/Anthropic/AnthropicChat.php
index 541df14..295e396 100644
--- a/src/LLM/Drivers/Anthropic/AnthropicChat.php
+++ b/src/LLM/Drivers/Anthropic/AnthropicChat.php
@@ -4,53 +4,37 @@
namespace Cortex\LLM\Drivers\Anthropic;
-use Generator;
use Throwable;
-use JsonException;
-use DateTimeImmutable;
-use Cortex\LLM\Data\Usage;
use Cortex\LLM\AbstractLLM;
-use Illuminate\Support\Arr;
-use Cortex\LLM\Data\ToolCall;
use Cortex\LLM\Contracts\Tool;
-use Cortex\Events\ChatModelEnd;
use Cortex\LLM\Data\ChatResult;
-use Cortex\LLM\Enums\ChunkType;
use Cortex\LLM\Enums\ToolChoice;
-use Anthropic\Testing\ClientFake;
use Cortex\Events\ChatModelError;
use Cortex\Events\ChatModelStart;
use Cortex\LLM\Contracts\Message;
-use Cortex\LLM\Data\FunctionCall;
-use Cortex\LLM\Enums\MessageRole;
-use Cortex\Events\ChatModelStream;
-use Cortex\LLM\Enums\FinishReason;
use Cortex\Exceptions\LLMException;
-use Cortex\LLM\Data\ChatGeneration;
+use Cortex\SDK\Anthropic\Anthropic;
use Cortex\LLM\Data\ChatStreamResult;
-use Cortex\LLM\Data\ResponseMetadata;
-use Anthropic\Contracts\ClientContract;
-use Cortex\LLM\Data\ToolCallCollection;
-use Cortex\LLM\Data\ChatGenerationChunk;
-use Cortex\LLM\Data\Messages\ToolMessage;
use Cortex\ModelInfo\Enums\ModelProvider;
use Cortex\LLM\Enums\StructuredOutputMode;
use Cortex\LLM\Data\Messages\SystemMessage;
-use Cortex\LLM\Data\Messages\AssistantMessage;
use Cortex\LLM\Data\Messages\MessageCollection;
-use Anthropic\Responses\Messages\CreateResponse;
-use Anthropic\Responses\Messages\StreamResponse;
-use Cortex\LLM\Data\Messages\Content\TextContent;
-use Anthropic\Responses\Messages\CreateResponseUsage;
-use Cortex\LLM\Data\Messages\Content\ReasoningContent;
-use Anthropic\Responses\Messages\CreateResponseContent;
-use Anthropic\Responses\Messages\CreateStreamedResponseUsage;
-use Anthropic\Testing\Responses\Fixtures\Messages\CreateResponseFixture;
+use Cortex\LLM\Drivers\Anthropic\Concerns\MapsUsage;
+use Cortex\LLM\Drivers\Anthropic\Concerns\MapsMessages;
+use Cortex\LLM\Drivers\Anthropic\Concerns\MapsResponse;
+use Cortex\LLM\Drivers\Anthropic\Concerns\MapsFinishReason;
+use Cortex\LLM\Drivers\Anthropic\Concerns\MapStreamResponse;
class AnthropicChat extends AbstractLLM
{
+ use MapsUsage;
+ use MapsMessages;
+ use MapsResponse;
+ use MapsFinishReason;
+ use MapStreamResponse;
+
public function __construct(
- protected readonly ClientContract $client,
+ protected readonly Anthropic $client,
protected string $model,
protected ModelProvider $modelProvider = ModelProvider::Anthropic,
protected bool $ignoreModelFeatures = false,
@@ -64,32 +48,45 @@ public function invoke(
): ChatResult|ChatStreamResult {
$messages = $this->resolveMessages($messages);
- [$systemMessages, $messages] = $messages->partition(
+ /** @var \Illuminate\Support\Collection $systemMessages */
+ $systemMessages = $messages->filter(
fn(Message $message): bool => $message instanceof SystemMessage,
);
- if ($systemMessages->count() > 1) {
- throw new LLMException('Only one system message is supported.');
- }
+ $nonSystemMessages = $messages->reject(
+ fn(Message $message): bool => $message instanceof SystemMessage,
+ );
$params = $this->buildParams([
...$additionalParameters,
- 'messages' => static::mapMessagesForInput($messages),
+ 'messages' => $this->mapMessagesForInput($nonSystemMessages),
]);
- /** @var \Cortex\LLM\Data\Messages\SystemMessage|null $systemMessage */
- $systemMessage = $systemMessages->first();
-
- if ($systemMessage !== null) {
- $params['system'] = $systemMessage->text();
+ // Anthropic only supports a single system message, so concatenate multiple if they exist
+ if ($systemMessages->isNotEmpty()) {
+ $params['system'] = $systemMessages
+ ->map(fn(SystemMessage $message): string => $message->text())
+ ->filter()
+ ->implode("\n\n");
}
$this->dispatchEvent(new ChatModelStart($this, $messages, $params));
+ if ($this->streaming) {
+ $params['stream'] = true;
+ }
+
try {
+ $response = $this->client->messages()->create(
+ parameters: $params,
+ cacheEnabled: $this->useCache,
+ );
+
+ $isCached = $response->isCached();
+
return $this->streaming
- ? $this->mapStreamResponse($this->client->messages()->createStreamed($params))
- : $this->mapResponse($this->client->messages()->create($params));
+ ? $this->mapStreamResponse($response->dtoOrFail(), $isCached)
+ : $this->mapResponse($response->dtoOrFail(), $isCached);
} catch (Throwable $e) {
$this->dispatchEvent(new ChatModelError($this, $params, $e));
@@ -97,309 +94,6 @@ public function invoke(
}
}
- /**
- * Map a standard (non-streaming) response to a ChatResult.
- */
- protected function mapResponse(CreateResponse $response): ChatResult
- {
- $toolCalls = array_filter(
- $response->content,
- fn(CreateResponseContent $content): bool => $content->type === 'tool_use',
- );
-
- $toolCalls = collect($toolCalls)
- ->map(fn(CreateResponseContent $content): ToolCall => new ToolCall(
- $content->id,
- new FunctionCall($content->name, $content->input ?? []),
- ))
- ->values()
- ->all();
-
- $toolCalls = $toolCalls !== []
- ? new ToolCallCollection($toolCalls)
- : null;
-
- $usage = $this->mapUsage($response->usage);
- $finishReason = static::mapFinishReason($response->stop_reason ?? null);
-
- $contents = collect($response->content)
- ->map(function (CreateResponseContent $content): TextContent|ReasoningContent|null {
- return match ($content->type) {
- 'text' => new TextContent($content->text),
- // TODO: Use different anthropic client, since current one seems abandoned.
- 'thinking' => $content->id !== null ? new ReasoningContent(
- $content->id,
- $content->text ?? '',
- ) : null,
- // 'tool_use' => new ToolUseContent($content->tool_use),
- default => null,
- };
- })
- ->filter()
- ->all();
-
- $generation = new ChatGeneration(
- message: new AssistantMessage(
- content: $contents,
- toolCalls: $toolCalls,
- metadata: new ResponseMetadata(
- id: $response->id,
- model: $response->model,
- provider: $this->modelProvider,
- finishReason: $finishReason,
- usage: $usage,
- ),
- ),
- createdAt: new DateTimeImmutable(),
- finishReason: $finishReason,
- );
-
- $generation = $this->applyOutputParserIfApplicable($generation);
-
- $result = new ChatResult(
- $generation,
- $usage,
- $response->toArray(), // @phpstan-ignore argument.type
- );
-
- $this->dispatchEvent(new ChatModelEnd($this, $result));
-
- return $result;
- }
-
- /**
- * Map a streaming response to a ChatStreamResult.
- *
- * @param StreamResponse<\Anthropic\Responses\Messages\CreateStreamedResponse> $response
- *
- * @return ChatStreamResult
- */
- protected function mapStreamResponse(StreamResponse $response): ChatStreamResult
- {
- return new ChatStreamResult(function () use ($response): Generator {
- $contentSoFar = '';
- $toolCallsSoFar = [];
- $messageId = null;
- $model = null;
- $finishReason = null;
- $usage = null;
- $currentToolCall = null;
-
- /** @var \Anthropic\Responses\Messages\CreateStreamedResponse $chunk */
- foreach ($response as $chunk) {
- $chunkDelta = null;
- $accumulatedToolCallsSoFar = null;
- $finishReason = static::mapFinishReason($chunk->delta->stop_reason);
-
- switch ($chunk->type) {
- case 'message_start':
- $messageId = $chunk->message->id;
- $model = $chunk->message->model;
- $usage = $chunk->usage !== null ? $this->mapUsage($chunk->usage) : null;
- break;
-
- case 'content_block_start':
- if ($chunk->content_block_start->type === 'tool_use') {
- // Start of a new tool call
- $currentToolCall = [
- 'id' => $chunk->content_block_start->id,
- 'function' => [
- 'name' => $chunk->content_block_start->name,
- 'arguments' => '',
- ],
- ];
- $toolCallsSoFar[] = $currentToolCall;
- }
-
- break;
-
- case 'content_block_delta':
- if ($chunk->delta->type === 'text_delta') {
- // Text content delta
- $chunkDelta = $chunk->delta->text;
- $contentSoFar .= $chunkDelta;
- } elseif ($chunk->delta->type === 'input_json_delta' && $currentToolCall !== null) {
- // Tool call arguments delta
- $lastIndex = count($toolCallsSoFar) - 1;
-
- if ($lastIndex >= 0) {
- $toolCallsSoFar[$lastIndex]['function']['arguments'] .= $chunk->delta->partial_json;
- }
- }
-
- break;
-
- case 'content_block_stop':
- // Content block finished - finalize current tool call if applicable
- $currentToolCall = null;
- break;
-
- case 'message_delta':
- if ($chunk->usage !== null) {
- $usage = $this->mapUsage($chunk->usage);
- }
-
- break;
-
- case 'message_stop':
- // Final event - this will be the last chunk
- break;
-
- case 'ping':
- // Skip ping events
- continue 2;
- }
-
- // Build accumulated tool calls if any exist
- if ($toolCallsSoFar !== []) {
- $accumulatedToolCallsSoFar = new ToolCallCollection(
- collect($toolCallsSoFar)
- ->map(function (array $toolCall): ToolCall {
- try {
- $arguments = json_decode((string) $toolCall['function']['arguments'], true, flags: JSON_THROW_ON_ERROR);
- } catch (JsonException) {
- $arguments = [];
- }
-
- return new ToolCall(
- $toolCall['id'],
- new FunctionCall(
- $toolCall['function']['name'],
- $arguments,
- ),
- );
- })
- ->values()
- ->all(),
- );
- }
-
- $chunk = new ChatGenerationChunk(
- type: ChunkType::TextDelta,
- id: $messageId,
- message: new AssistantMessage(
- content: $chunkDelta,
- toolCalls: $accumulatedToolCallsSoFar,
- metadata: new ResponseMetadata(
- id: $messageId ?? 'unknown',
- model: $model ?? $this->model,
- provider: $this->modelProvider,
- finishReason: $finishReason,
- usage: $usage,
- ),
- ),
- createdAt: new DateTimeImmutable(), // TODO
- finishReason: $finishReason,
- usage: $usage,
- contentSoFar: $contentSoFar,
- isFinal: $finishReason !== null,
- );
-
- $chunk = $this->applyOutputParserIfApplicable($chunk);
-
- $this->dispatchEvent(new ChatModelStream($this, $chunk));
-
- yield $chunk;
- }
- });
- }
-
- /**
- * Map the OpenAI usage response to a Usage object.
- */
- protected function mapUsage(CreateResponseUsage|CreateStreamedResponseUsage $usage): Usage
- {
- return new Usage(
- promptTokens: $usage->inputTokens ?? 0,
- completionTokens: $usage->outputTokens ?? null,
- cachedTokens: $usage->cacheCreationInputTokens ?? null,
- inputCost: $this->modelProvider->inputCostForTokens($this->model, $usage->inputTokens ?? 0),
- outputCost: $this->modelProvider->outputCostForTokens($this->model, $usage->outputTokens ?? 0),
- );
- }
-
- /**
- * Take the given messages and format them for the OpenAI API.
- *
- * @return array>
- */
- protected static function mapMessagesForInput(MessageCollection $messages): array
- {
- return $messages
- ->map(function (Message $message) {
- if ($message instanceof ToolMessage) {
- return [
- 'role' => MessageRole::User->value,
- 'content' => [
- [
- 'type' => 'tool_use',
- 'tool_use_id' => $message->id,
- 'content' => $message->content,
- ],
- ],
- ];
- }
-
- if ($message instanceof AssistantMessage && $message->toolCalls?->isNotEmpty()) {
- $formattedMessage = $message->toArray();
-
- // Ensure the function arguments are encoded as a string
- foreach ($message->toolCalls as $index => $toolCall) {
- Arr::set(
- $formattedMessage,
- 'tool_calls.' . $index . '.function.arguments',
- json_encode($toolCall->function->arguments),
- );
- }
-
- return $formattedMessage;
- }
-
- $formattedMessage = $message->toArray();
-
- if (isset($formattedMessage['content']) && is_array($formattedMessage['content'])) {
- $formattedMessage['content'] = array_map(function (mixed $content) {
- return match (true) {
- $content instanceof TextContent => [
- 'type' => 'text',
- 'text' => $content->text,
- ],
- // $content instanceof ImageContent => [
- // 'type' => 'image_url',
- // 'image_url' => [
- // 'url' => $content->urlOrBase64,
- // ],
- // ],
- // $content instanceof DocumentContent => [
- // 'type' => 'document',
- // 'document' => $content->data,
- // ],
- default => $content,
- };
- }, $formattedMessage['content']);
- }
-
- return $formattedMessage;
- })
- ->values()
- ->toArray();
- }
-
- protected static function mapFinishReason(?string $finishReason): ?FinishReason
- {
- if ($finishReason === null) {
- return null;
- }
-
- return match ($finishReason) {
- 'end_turn' => FinishReason::Stop,
- 'max_tokens' => FinishReason::Length,
- 'stop_sequence' => FinishReason::StopSequence,
- 'tool_use' => FinishReason::ToolCalls,
- default => FinishReason::Unknown,
- };
- }
-
/**
* @param array $additionalParameters
*
@@ -411,8 +105,22 @@ protected function buildParams(array $additionalParameters): array
'model' => $this->model,
];
+ if ($this->maxTokens !== null) {
+ $params['max_tokens'] = $this->maxTokens;
+ }
+
+ if ($this->temperature !== null) {
+ $params['temperature'] = $this->temperature;
+ }
+
if ($this->structuredOutputConfig !== null) {
$this->structuredOutputMode = StructuredOutputMode::Tool;
+
+ $params['output_format'] = [
+ 'type' => 'json_schema',
+ 'schema' => $this->structuredOutputConfig->schema->additionalProperties(false)->toArray(),
+ ];
+
} elseif ($this->forceJsonOutput) {
$this->structuredOutputMode = StructuredOutputMode::Json;
}
@@ -445,29 +153,44 @@ protected function buildParams(array $additionalParameters): array
->toArray();
}
- return [
+ $finalParams = [
...$params,
...$this->parameters,
...$additionalParameters,
];
- }
- public function getClient(): ClientContract
- {
- return $this->client;
+ if (! isset($finalParams['max_tokens'])) {
+ throw new LLMException('`max_tokens` parameter is required for Anthropic.');
+ }
+
+ if ($this->structuredOutputConfig !== null) {
+ $finalParams['betas'] ??= [];
+ $finalParams['betas'][] = 'structured-outputs-2025-11-13';
+ }
+
+ return $finalParams;
}
/**
- * @param array $responses
+ * @param array $responses
*/
- public static function fake(array $responses, ?string $model = null, ?ModelProvider $modelProvider = null): self
- {
- $client = new ClientFake($responses);
-
- return new self(
+ public static function fake(
+ array $responses,
+ ?string $apiKey = null,
+ ?string $model = null,
+ ?ModelProvider $modelProvider = null,
+ ): self {
+ $client = Anthropic::fake($responses, $apiKey);
+
+ $instance = new self(
$client,
- $model ?? CreateResponseFixture::ATTRIBUTES['model'],
+ $model ?? 'claude-4-5-sonnet-20250926',
$modelProvider ?? ModelProvider::Anthropic,
);
+
+ // Set a default max_tokens for testing
+ $instance->withMaxTokens(8096);
+
+ return $instance;
}
}
diff --git a/src/LLM/Drivers/Anthropic/Concerns/MapStreamResponse.php b/src/LLM/Drivers/Anthropic/Concerns/MapStreamResponse.php
new file mode 100644
index 0000000..1efa3ba
--- /dev/null
+++ b/src/LLM/Drivers/Anthropic/Concerns/MapStreamResponse.php
@@ -0,0 +1,398 @@
+
+ */
+ private array $completedContent = [];
+
+ private ?TextContent $pendingTextContent = null;
+
+ private ?ReasoningContent $pendingReasoningContent = null;
+
+ /**
+ * @var array>
+ */
+ private array $contentBlockTypes = [];
+
+ /**
+ * @var array
+ */
+ private array $completedToolCalls = [];
+
+ /**
+ * @var array{id: string, name: string, partialJson: string}|null
+ */
+ private ?array $pendingToolCall = null;
+
+ private ?int $pendingToolCallIndex = null;
+
+ /**
+ * Map a streaming response to a ChatStreamResult.
+ *
+ * @param \Cortex\SDK\Anthropic\Data\Messages\MessageStream<\Cortex\SDK\Anthropic\Contracts\StreamEvent> $response
+ */
+ protected function mapStreamResponse(MessageStream $response, bool $isCached = false): ChatStreamResult
+ {
+ return new ChatStreamResult(function () use ($response, $isCached): Generator {
+ $this->resetStreamState();
+
+ yield from $this->streamBuffer?->drain() ?? [];
+
+ $chatGenerationChunk = null;
+ $messageId = null;
+
+ foreach ($response as $event) {
+ yield from $this->streamBuffer?->drain() ?? [];
+
+ $chunkType = $this->mapChunkType($event, $this->contentBlockTypes);
+
+ if ($chunkType === null) {
+ continue;
+ }
+
+ $messageId = $this->extractMessageId($event, $messageId);
+
+ $this->processStreamEvent($event);
+
+ $chatGenerationChunk = $this->applyOutputParserIfApplicable(
+ $this->buildChunk($event, $chunkType, $messageId, $isCached),
+ );
+
+ $this->dispatchEvent(new ChatModelStream($this, $chatGenerationChunk));
+
+ yield $chatGenerationChunk;
+ }
+
+ yield from $this->streamBuffer?->drain() ?? [];
+
+ if ($chatGenerationChunk !== null) {
+ $this->dispatchEvent(new ChatModelStreamEnd($this, $chatGenerationChunk));
+ }
+ });
+ }
+
+ private function resetStreamState(): void
+ {
+ $this->completedContent = [];
+ $this->pendingTextContent = null;
+ $this->pendingReasoningContent = null;
+ $this->contentBlockTypes = [];
+ $this->completedToolCalls = [];
+ $this->pendingToolCall = null;
+ $this->pendingToolCallIndex = null;
+ }
+
+ private function processStreamEvent(StreamEvent $event): void
+ {
+ match (true) {
+ $event instanceof ContentBlockStart => $this->handleContentBlockStart($event),
+ $event instanceof ContentBlockDelta => $this->handleContentBlockDelta($event),
+ $event instanceof ContentBlockStop => $this->handleContentBlockStop($event),
+ default => null,
+ };
+ }
+
+ private function handleContentBlockStart(ContentBlockStart $event): void
+ {
+ $this->contentBlockTypes[$event->index] = $event->contentBlock::class;
+
+ match (true) {
+ $event->contentBlock instanceof TextContentBlock => $this->pendingTextContent = new TextContent($event->contentBlock->text),
+ $event->contentBlock instanceof ThinkingContentBlock => $this->pendingReasoningContent = new ReasoningContent(
+ reasoning: $event->contentBlock->thinking,
+ metadata: [
+ 'signature' => $event->contentBlock->signature,
+ 'type' => 'thinking',
+ ],
+ ),
+ $event->contentBlock instanceof RedactedThinkingContentBlock => $this->pendingReasoningContent = new ReasoningContent(
+ reasoning: $event->contentBlock->data,
+ metadata: [
+ 'text' => $event->contentBlock->text,
+ 'type' => 'redacted_thinking',
+ ],
+ ),
+ $event->contentBlock instanceof ToolUseContentBlock => $this->startToolCall($event),
+ default => null,
+ };
+ }
+
+ private function startToolCall(ContentBlockStart $event): void
+ {
+ /** @var ToolUseContentBlock $contentBlock */
+ $contentBlock = $event->contentBlock;
+
+ $this->pendingToolCall = [
+ 'id' => $contentBlock->id,
+ 'name' => $contentBlock->name,
+ 'partialJson' => '',
+ ];
+ $this->pendingToolCallIndex = $event->index;
+ }
+
+ private function handleContentBlockDelta(ContentBlockDelta $event): void
+ {
+ match (true) {
+ $event->delta instanceof TextDelta && $this->pendingTextContent !== null => $this->pendingTextContent = $this->pendingTextContent->append($event->delta->text),
+ $event->delta instanceof ThinkingDelta && $this->pendingReasoningContent !== null => $this->pendingReasoningContent = $this->pendingReasoningContent->append($event->delta->thinking),
+ $event->delta instanceof SignatureDelta && $this->pendingReasoningContent !== null => $this->pendingReasoningContent = new ReasoningContent(
+ reasoning: $this->pendingReasoningContent->reasoning,
+ metadata: array_merge($this->pendingReasoningContent->metadata, [
+ 'signature' => $event->delta->signature,
+ ]),
+ ),
+ $event->delta instanceof InputJsonDelta && $this->pendingToolCall !== null => $this->pendingToolCall['partialJson'] .= $event->delta->partialJson,
+ default => null,
+ };
+ }
+
+ private function handleContentBlockStop(ContentBlockStop $event): void
+ {
+ if ($this->pendingTextContent !== null) {
+ $this->completedContent[] = $this->pendingTextContent;
+ $this->pendingTextContent = null;
+
+ return;
+ }
+
+ if ($this->pendingReasoningContent !== null) {
+ $this->completedContent[] = $this->pendingReasoningContent;
+ $this->pendingReasoningContent = null;
+
+ return;
+ }
+
+ if ($this->pendingToolCall !== null && $this->pendingToolCallIndex === $event->index) {
+ $this->finalizeToolCall();
+ }
+ }
+
+ private function finalizeToolCall(): void
+ {
+ if ($this->pendingToolCall === null) {
+ return;
+ }
+
+ $this->completedToolCalls[] = new ToolCall(
+ $this->pendingToolCall['id'],
+ new FunctionCall(
+ $this->pendingToolCall['name'],
+ $this->parseJsonSafely($this->pendingToolCall['partialJson']),
+ $this->pendingToolCall['partialJson'],
+ ),
+ );
+
+ $this->pendingToolCall = null;
+ $this->pendingToolCallIndex = null;
+ }
+
+ /**
+ * @return array
+ */
+ private function parseJsonSafely(string $json): array
+ {
+ if ($json === '') {
+ return [];
+ }
+
+ try {
+ return new JsonOutputParser()->parse($json);
+ } catch (OutputParserException) {
+ return [];
+ }
+ }
+
+ private function extractMessageId(StreamEvent $event, ?string $currentId): ?string
+ {
+ return $event instanceof MessageStart
+ ? $event->message->id
+ : $currentId;
+ }
+
+ /**
+ * @return array
+ */
+ private function buildContentSnapshot(): array
+ {
+ $snapshot = [...$this->completedContent];
+
+ if ($this->pendingTextContent !== null) {
+ $snapshot[] = new TextContent($this->pendingTextContent->text);
+ } elseif ($this->pendingReasoningContent !== null) {
+ $snapshot[] = new ReasoningContent(
+ reasoning: $this->pendingReasoningContent->reasoning,
+ metadata: $this->pendingReasoningContent->metadata,
+ );
+ }
+
+ return $snapshot;
+ }
+
+ private function buildToolCallCollection(): ?ToolCallCollection
+ {
+ $toolCalls = [...$this->completedToolCalls];
+
+ if ($this->pendingToolCall !== null) {
+ $toolCalls[] = new ToolCall(
+ $this->pendingToolCall['id'],
+ new FunctionCall(
+ $this->pendingToolCall['name'],
+ $this->parseJsonSafely($this->pendingToolCall['partialJson']),
+ $this->pendingToolCall['partialJson'],
+ ),
+ );
+ }
+
+ return $toolCalls !== [] ? new ToolCallCollection($toolCalls) : null;
+ }
+
+ private function extractContentDelta(StreamEvent $event): TextContent|ReasoningContent|null
+ {
+ if (! $event instanceof ContentBlockDelta) {
+ return null;
+ }
+
+ return match (true) {
+ $event->delta instanceof TextDelta => new TextContent($event->delta->text),
+ $event->delta instanceof ThinkingDelta => new ReasoningContent($event->delta->thinking),
+ default => null,
+ };
+ }
+
+ private function buildChunk(
+ StreamEvent $event,
+ ChunkType $chunkType,
+ ?string $messageId,
+ bool $isCached = false,
+ ): ChatGenerationChunk {
+ $finishReason = $event instanceof MessageDelta
+ ? $this->mapFinishReason($event->stopReason)
+ : null;
+
+ $usage = $event instanceof MessageDelta
+ ? $this->mapUsage($event->cumulativeUsage)
+ : null;
+
+ $meta = $event->meta();
+
+ return new ChatGenerationChunk(
+ type: $chunkType,
+ id: $messageId,
+ message: new AssistantMessage(
+ content: [$this->extractContentDelta($event)],
+ toolCalls: $this->buildToolCallCollection(),
+ metadata: new ResponseMetadata(
+ id: $messageId,
+ model: $this->model,
+ provider: $this->modelProvider,
+ finishReason: $finishReason,
+ usage: $usage,
+ processingTime: $meta?->processingTime,
+ providerMetadata: $meta->raw ?? [],
+ ),
+ id: $messageId,
+ ),
+ createdAt: $meta->createdAt ?? new DateTimeImmutable(),
+ finishReason: $finishReason,
+ usage: $usage,
+ contentSoFar: $this->buildContentSnapshot(),
+ isFinal: $usage !== null,
+ rawChunk: $this->includeRaw ? $event->raw() : null,
+ metadata: [
+ 'is_cached' => $isCached,
+ ],
+ );
+ }
+
+ /**
+ * @param array> $contentBlocksByIndex
+ */
+ protected function mapChunkType(StreamEvent $event, array $contentBlocksByIndex = []): ?ChunkType
+ {
+ return match (true) {
+ $event instanceof MessageStart => ChunkType::MessageStart,
+ $event instanceof MessageDelta => ChunkType::MessageEnd,
+ $event instanceof ContentBlockStart => $this->mapContentBlockStartType($event),
+ $event instanceof ContentBlockDelta => $this->mapContentBlockDeltaType($event),
+ $event instanceof ContentBlockStop => $this->mapContentBlockStopType($event, $contentBlocksByIndex),
+ default => null,
+ };
+ }
+
+ private function mapContentBlockStartType(ContentBlockStart $event): ?ChunkType
+ {
+ return match ($event->contentBlock::class) {
+ TextContentBlock::class => ChunkType::TextStart,
+ ThinkingContentBlock::class, RedactedThinkingContentBlock::class => ChunkType::ReasoningStart,
+ ToolUseContentBlock::class => ChunkType::ToolInputStart,
+ default => null,
+ };
+ }
+
+ private function mapContentBlockDeltaType(ContentBlockDelta $event): ?ChunkType
+ {
+ return match ($event->delta::class) {
+ TextDelta::class => ChunkType::TextDelta,
+ ThinkingDelta::class => ChunkType::ReasoningDelta,
+ InputJsonDelta::class => ChunkType::ToolInputDelta,
+ default => null,
+ };
+ }
+
+ /**
+ * @param array> $contentBlocksByIndex
+ */
+ private function mapContentBlockStopType(ContentBlockStop $event, array $contentBlocksByIndex): ChunkType
+ {
+ $contentBlockClass = $contentBlocksByIndex[$event->index] ?? null;
+
+ return match ($contentBlockClass) {
+ TextContentBlock::class => ChunkType::TextEnd,
+ ThinkingContentBlock::class, RedactedThinkingContentBlock::class => ChunkType::ReasoningEnd,
+ ToolUseContentBlock::class => ChunkType::ToolInputEnd,
+ WebSearchToolResultContentBlock::class, ServerToolUseContentBlock::class => ChunkType::ToolOutputEnd,
+ default => ChunkType::TextEnd,
+ };
+ }
+}
diff --git a/src/LLM/Drivers/Anthropic/Concerns/MapsFinishReason.php b/src/LLM/Drivers/Anthropic/Concerns/MapsFinishReason.php
new file mode 100644
index 0000000..a87aba0
--- /dev/null
+++ b/src/LLM/Drivers/Anthropic/Concerns/MapsFinishReason.php
@@ -0,0 +1,26 @@
+ FinishReason::Stop,
+ 'max_tokens' => FinishReason::Length,
+ 'stop_sequence' => FinishReason::StopSequence,
+ 'tool_use' => FinishReason::ToolCalls,
+ default => FinishReason::Unknown,
+ };
+ }
+}
diff --git a/src/LLM/Drivers/Anthropic/Concerns/MapsMessages.php b/src/LLM/Drivers/Anthropic/Concerns/MapsMessages.php
new file mode 100644
index 0000000..20dde31
--- /dev/null
+++ b/src/LLM/Drivers/Anthropic/Concerns/MapsMessages.php
@@ -0,0 +1,262 @@
+>
+ */
+ protected function mapMessagesForInput(MessageCollection $messages): array
+ {
+ $mapped = [];
+ $pendingToolResults = [];
+
+ foreach ($messages as $message) {
+ if ($message instanceof ToolMessage) {
+ $pendingToolResults[] = $this->mapToolResultBlock($message);
+
+ continue;
+ }
+
+ // Flush any pending tool results before adding the next message
+ if ($pendingToolResults !== []) {
+ $mapped[] = $this->createToolResultsMessage($pendingToolResults);
+ $pendingToolResults = [];
+ }
+
+ $mapped[] = $this->mapMessage($message);
+ }
+
+ // Flush any remaining tool results at the end
+ if ($pendingToolResults !== []) {
+ $mapped[] = $this->createToolResultsMessage($pendingToolResults);
+ }
+
+ return $mapped;
+ }
+
+ /**
+ * Create a single user message containing all tool results.
+ *
+ * @param array> $toolResults
+ *
+ * @return array
+ */
+ private function createToolResultsMessage(array $toolResults): array
+ {
+ return [
+ 'role' => MessageRole::User->value,
+ 'content' => $toolResults,
+ ];
+ }
+
+ /**
+ * Map a tool message to a tool_result content block.
+ *
+ * @return array
+ */
+ private function mapToolResultBlock(ToolMessage $message): array
+ {
+ return [
+ 'type' => 'tool_result',
+ 'tool_use_id' => $message->id,
+ 'content' => $message->text() ?? '',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function mapMessage(Message $message): array
+ {
+ return match (true) {
+ $message instanceof AssistantMessage => $this->mapAssistantMessage($message),
+ default => $this->mapGenericMessage($message),
+ };
+ }
+
+ /**
+ * Map an assistant message to Anthropic format.
+ *
+ * Tool calls are represented as tool_use content blocks in Anthropic.
+ *
+ * @return array
+ */
+ private function mapAssistantMessage(AssistantMessage $message): array
+ {
+ $content = $this->mapMessageContent($message->content);
+
+ if ($message->toolCalls?->isNotEmpty()) {
+ foreach ($message->toolCalls as $toolCall) {
+ $content[] = [
+ 'type' => 'tool_use',
+ 'id' => $toolCall->id,
+ 'name' => $toolCall->function->name,
+ 'input' => new ArrayObject($toolCall->function->arguments),
+ ];
+ }
+ }
+
+ return [
+ 'role' => MessageRole::Assistant->value,
+ 'content' => $content,
+ ];
+ }
+
+ /**
+ * Map a generic message (user, etc.) to Anthropic format.
+ *
+ * @return array
+ */
+ private function mapGenericMessage(Message $message): array
+ {
+ $formattedMessage = $message->toArray();
+
+ if (isset($formattedMessage['content']) && is_array($formattedMessage['content'])) {
+ $formattedMessage['content'] = $this->mapMessageContent($formattedMessage['content']);
+ }
+
+ // Anthropic does not support message IDs, so we remove it.
+ unset($formattedMessage['id']);
+
+ return $formattedMessage;
+ }
+
+ /**
+ * Map message content to Anthropic content blocks.
+ *
+ * @param string|array|null $content
+ *
+ * @return array>
+ */
+ private function mapMessageContent(string|array|null $content): array
+ {
+ if ($content === null) {
+ return [];
+ }
+
+ if (is_string($content)) {
+ return [
+ [
+ 'type' => 'text',
+ 'text' => $content,
+ ],
+ ];
+ }
+
+ return array_values(array_filter(
+ array_map(fn(mixed $item): ?array => $this->mapContentBlock($item), $content),
+ ));
+ }
+
+ /**
+ * Map a single content block to Anthropic format.
+ *
+ * @return array|null
+ */
+ private function mapContentBlock(mixed $content): ?array
+ {
+ return match (true) {
+ $content instanceof TextContent => $this->mapTextContent($content),
+ $content instanceof ImageContent => $this->mapImageContent($content),
+ $content instanceof ReasoningContent => $this->mapReasoningContent($content),
+ is_array($content) => $content,
+ default => null,
+ };
+ }
+
+ /**
+ * @return array
+ */
+ private function mapTextContent(TextContent $content): array
+ {
+ return [
+ 'type' => 'text',
+ 'text' => $content->text,
+ ];
+ }
+
+ /**
+ * Map reasoning content back to Anthropic's thinking block format.
+ *
+ * According to Anthropic's documentation, thinking blocks must be passed
+ * back unmodified in multi-turn conversations to maintain reasoning flow.
+ *
+ * @return array
+ */
+ private function mapReasoningContent(ReasoningContent $content): array
+ {
+ // Check if this is a redacted_thinking block
+ if (isset($content->metadata['type']) && $content->metadata['type'] === 'redacted_thinking') {
+ return [
+ 'type' => 'redacted_thinking',
+ 'data' => $content->reasoning,
+ ];
+ }
+
+ // Default to thinking block
+ $block = [
+ 'type' => 'thinking',
+ 'thinking' => $content->reasoning,
+ ];
+
+ // Include signature if it exists in metadata
+ if (isset($content->metadata['signature'])) {
+ $block['signature'] = $content->metadata['signature'];
+ }
+
+ return $block;
+ }
+
+ /**
+ * Map image content to Anthropic's image format.
+ *
+ * @return array